From c523ed4806271854b832a21db11c1971e80d7cee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 18:25:58 +0000 Subject: [PATCH 01/10] Initial plan From 92f36c4ca49e48628366dadc8538a7e56addc062 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 18:36:54 +0000 Subject: [PATCH 02/10] Add AI session dashboard MVP Agent-Logs-Url: https://github.com/tninja/ai-code-interface.el/sessions/cc89fddd-a1d6-47bd-9862-95a4aa0ca75f Co-authored-by: tninja <714625+tninja@users.noreply.github.com> --- ai-code-backends-infra.el | 99 +++++++++---- ai-code-session-dashboard.el | 145 +++++++++++++++++++ ai-code-session.el | 189 +++++++++++++++++++++++++ ai-code.el | 2 + test/test_ai-code-session-dashboard.el | 148 +++++++++++++++++++ test/test_ai-code-session.el | 89 ++++++++++++ test/test_ai-code.el | 9 ++ 7 files changed, 656 insertions(+), 25 deletions(-) create mode 100644 ai-code-session-dashboard.el create mode 100644 ai-code-session.el create mode 100644 test/test_ai-code-session-dashboard.el create mode 100644 test/test_ai-code-session.el diff --git a/ai-code-backends-infra.el b/ai-code-backends-infra.el index 7bc0d737..c0b9ac92 100644 --- a/ai-code-backends-infra.el +++ b/ai-code-backends-infra.el @@ -18,6 +18,7 @@ (require 'cl-lib) (require 'project) (require 'subr-x) +(require 'ai-code-session) (require 'ai-code-session-link) ;; Terminal-specific implementations live in dedicated modules so this ;; file can stay focused on shared session orchestration. @@ -134,6 +135,12 @@ being sent for the response completion.") (defvar-local ai-code-backends-infra--session-terminal-backend nil "Terminal backend used by the current session buffer.") +(defvar-local ai-code-backends-infra--session-prefix nil + "Backend prefix associated with the current session buffer.") + +(defvar-local ai-code-backends-infra--session-task-file nil + "Task file associated with the current session buffer, when available.") + (defvar-local ai-code-backends-infra--multiline-input-sequence nil "Terminal sequence sent for multiline input in the current session buffer.") @@ -749,11 +756,40 @@ SOURCE-BUFFER unless FORCE-PROMPT is non-nil." (> (length ai-code-backends-infra--session-directory) 0)) (ai-code-backends-infra--normalize-session-directory ai-code-backends-infra--session-directory)) - ((and (stringp default-directory) - (> (length default-directory) 0)) - (ai-code-backends-infra--normalize-session-directory - default-directory)) - (t nil))))) + ((and (stringp default-directory) + (> (length default-directory) 0)) + (ai-code-backends-infra--normalize-session-directory + default-directory)) + (t nil))))) + +(defun ai-code-backends-infra--source-task-file (source-buffer) + "Return the AI task file associated with SOURCE-BUFFER, or nil." + (when (buffer-live-p source-buffer) + (with-current-buffer source-buffer + (when (and (stringp buffer-file-name) + (string-suffix-p ".org" buffer-file-name) + (or (eq major-mode 'ai-code-prompt-mode) + (string-match-p "/\\.ai\\.code\\.files/" buffer-file-name))) + (expand-file-name buffer-file-name))))) + +(defun ai-code-backends-infra--sync-session-registry (buffer working-dir prefix + &optional task-file) + "Register or update BUFFER in the AI session registry. +WORKING-DIR is the session root. PREFIX identifies the backend. +TASK-FILE records the originating AI task file when available." + (when (buffer-live-p buffer) + (let ((resolved-task-file + (or task-file + (with-current-buffer buffer + ai-code-backends-infra--session-task-file)))) + (with-current-buffer buffer + (setq-local ai-code-backends-infra--session-prefix prefix) + (setq-local ai-code-backends-infra--session-task-file resolved-task-file)) + (ai-code-session-register + :buffer buffer + :backend prefix + :repo-root working-dir + :task-file resolved-task-file)))) (defun ai-code-backends-infra--session-buffer-name (prefix directory &optional instance-name) "Return a session buffer name for PREFIX in DIRECTORY. @@ -953,11 +989,12 @@ any error output left behind by the CLI." (ai-code-backends-infra--session-instance-name buffer-name prefix)) - "default")) + "default")) (key (ai-code-backends-infra--session-key directory resolved-instance))) (remhash key process-table)) (when-let ((buffer (get-buffer buffer-name))) (ai-code-backends-infra--forget-session-buffer prefix directory buffer) + (ai-code-session-unregister buffer) (when (buffer-live-p buffer) (when (or (null event) (string-prefix-p "finished" event)) @@ -1028,9 +1065,11 @@ Return a plist with target information plus the current buffer and process." :existing-process (gethash session-key process-table))))) (defun ai-code-backends-infra--reuse-session-window (buffer working-dir - prefix multiline-input-sequence) + prefix multiline-input-sequence + &optional task-file) "Toggle visibility for an existing session BUFFER. -WORKING-DIR, PREFIX, and MULTILINE-INPUT-SEQUENCE refresh session state. +WORKING-DIR, PREFIX, MULTILINE-INPUT-SEQUENCE, and TASK-FILE refresh session +state. When BUFFER is already visible, close its window. Otherwise refresh session-local state and display it." (if (get-buffer-window buffer) @@ -1039,18 +1078,22 @@ Otherwise refresh session-local state and display it." (ai-code-backends-infra--configure-session-buffer buffer nil multiline-input-sequence) (ai-code-backends-infra--remember-session-buffer prefix working-dir buffer) + (ai-code-backends-infra--sync-session-registry + buffer working-dir prefix task-file) (ai-code-backends-infra--display-buffer-in-side-window buffer))) (defun ai-code-backends-infra--finalize-started-session (buffer process - working-dir buffer-name - process-table resolved-instance - prefix escape-fn cleanup-fn - multiline-input-sequence - post-start-fn) + working-dir buffer-name + process-table resolved-instance + prefix escape-fn cleanup-fn + multiline-input-sequence + post-start-fn + &optional task-file) "Finalize a successfully started session BUFFER and PROCESS. WORKING-DIR, BUFFER-NAME, PROCESS-TABLE, RESOLVED-INSTANCE, and PREFIX identify the session for cleanup and reuse. ESCAPE-FN, CLEANUP-FN, -MULTILINE-INPUT-SEQUENCE, and POST-START-FN install session behavior." +MULTILINE-INPUT-SEQUENCE, POST-START-FN, and TASK-FILE install session +behavior." (let ((previous-sentinel (ignore-errors (process-get process 'ai-code-backends-infra--ghostel-sentinel)))) @@ -1076,12 +1119,15 @@ MULTILINE-INPUT-SEQUENCE, and POST-START-FN install session behavior." (with-current-buffer buffer (add-hook 'kill-buffer-hook (lambda () + (ai-code-session-unregister (current-buffer)) (ai-code-backends-infra--forget-session-buffer prefix working-dir (current-buffer))) nil t)) (ai-code-backends-infra--remember-session-buffer prefix working-dir buffer) + (ai-code-backends-infra--sync-session-registry + buffer working-dir prefix task-file) (ai-code-backends-infra--display-buffer-in-side-window buffer)) (defun ai-code-backends-infra--handle-session-start-failure (buffer session-key process-table) @@ -1117,6 +1163,7 @@ session starts successfully." (setq process-table (or process-table ai-code-backends-infra--processes)) (ai-code-backends-infra--cleanup-dead-processes process-table) (let* ((source-buffer (current-buffer)) + (task-file (ai-code-backends-infra--source-task-file source-buffer)) (session-context (ai-code-backends-infra--resolve-session-context working-dir buffer-name @@ -1130,14 +1177,15 @@ session starts successfully." (existing-process (plist-get session-context :existing-process)) (buffer (plist-get session-context :buffer))) (if (and existing-process (process-live-p existing-process) buffer) - (progn - (ai-code-backends-infra--reuse-session-window - buffer - working-dir - prefix - multiline-input-sequence) - (ai-code-backends-infra--remember-file-session-buffer - prefix source-buffer buffer)) + (progn + (ai-code-backends-infra--reuse-session-window + buffer + working-dir + prefix + multiline-input-sequence + task-file) + (ai-code-backends-infra--remember-file-session-buffer + prefix source-buffer buffer)) (let* ((buffer-and-process (ai-code-backends-infra--create-terminal-session resolved-buffer-name working-dir command env-vars)) @@ -1160,9 +1208,10 @@ session starts successfully." escape-fn cleanup-fn multiline-input-sequence - post-start-fn) - (ai-code-backends-infra--remember-file-session-buffer - prefix source-buffer new-buffer)) + post-start-fn + task-file) + (ai-code-backends-infra--remember-file-session-buffer + prefix source-buffer new-buffer)) (ai-code-backends-infra--handle-session-start-failure new-buffer session-key diff --git a/ai-code-session-dashboard.el b/ai-code-session-dashboard.el new file mode 100644 index 00000000..ba85584e --- /dev/null +++ b/ai-code-session-dashboard.el @@ -0,0 +1,145 @@ +;;; ai-code-session-dashboard.el --- Dashboard for AI sessions -*- lexical-binding: t; -*- + +;; Author: Kang Tu +;; SPDX-License-Identifier: Apache-2.0 + +;;; Commentary: +;; This library provides a simple tabulated-list dashboard for active AI coding +;; sessions tracked by `ai-code-session'. + +;;; Code: + +(require 'subr-x) +(require 'tabulated-list) +(require 'ai-code-session) + +(declare-function magit-status "magit-status" (&optional directory)) +(declare-function magit-status-setup-buffer "magit-status" (directory)) + +(defconst ai-code-session-dashboard-buffer-name "*AI Code Sessions*" + "Buffer name used by the AI session dashboard.") + +(defun ai-code-session-dashboard--repo-name (session) + "Return a short repository name for SESSION." + (when-let ((repo-root (ai-code-session-repo-root session))) + (file-name-nondirectory (directory-file-name repo-root)))) + +(defun ai-code-session-dashboard--task-name (session) + "Return a display task file name for SESSION." + (when-let ((task-file (ai-code-session-task-file session))) + (file-name-nondirectory task-file))) + +(defun ai-code-session-dashboard--backend-label (backend) + "Return a human-friendly label for BACKEND." + (let ((text (cond + ((symbolp backend) (symbol-name backend)) + ((stringp backend) backend) + (t "")))) + (capitalize (replace-regexp-in-string "[-_]+" " " text)))) + +(defun ai-code-session-dashboard--entry (session) + "Return the `tabulated-list-mode' entry for SESSION." + (let* ((metadata (ai-code-session-metadata session)) + (branch (or (plist-get metadata :branch) "")) + (status (or (plist-get metadata :status) "")) + (dirty-count (number-to-string (or (plist-get metadata :dirty-count) 0)))) + (list (ai-code-session-id session) + (vector + (ai-code-session-id session) + (or (ai-code-session-dashboard--repo-name session) "") + (or (ai-code-session-dashboard--task-name session) "") + (ai-code-session-dashboard--backend-label + (ai-code-session-backend session)) + branch + status + dirty-count)))) + +(defun ai-code-session-dashboard--entries () + "Return dashboard entries for all active sessions." + (mapcar #'ai-code-session-dashboard--entry + (ai-code-session-refresh))) + +(defun ai-code-session-dashboard--session-at-point () + "Return the dashboard session at point." + (ai-code-session-get (tabulated-list-get-id))) + +(defun ai-code-session-dashboard-refresh () + "Refresh the AI session dashboard." + (interactive) + (setq tabulated-list-entries (ai-code-session-dashboard--entries)) + (tabulated-list-print t)) + +(defun ai-code-session-dashboard-visit () + "Visit the session buffer on the current line." + (interactive) + (if-let* ((session (ai-code-session-dashboard--session-at-point)) + (buffer (ai-code-session-buffer session)) + ((buffer-live-p buffer))) + (pop-to-buffer buffer) + (user-error "No live AI session on this line"))) + +(defun ai-code-session-dashboard-kill-session () + "Kill the session on the current line, after confirmation when needed." + (interactive) + (let* ((session (or (ai-code-session-dashboard--session-at-point) + (user-error "No AI session on this line"))) + (buffer (ai-code-session-buffer session)) + (process (and (buffer-live-p buffer) (get-buffer-process buffer)))) + (when (and process + (process-live-p process) + (not (y-or-n-p (format "Kill AI session %s? " + (ai-code-session-id session))))) + (user-error "Session kill canceled")) + (when (and process (process-live-p process)) + (delete-process process)) + (when (buffer-live-p buffer) + (kill-buffer buffer)) + (ai-code-session-unregister session) + (ai-code-session-dashboard-refresh))) + +(defun ai-code-session-dashboard-open-diff () + "Open Magit status for the repository on the current line." + (interactive) + (if-let* ((session (ai-code-session-dashboard--session-at-point)) + (repo-root (ai-code-session-repo-root session))) + (magit-status-setup-buffer repo-root) + (user-error "No repository is associated with this session"))) + +(defvar ai-code-session-dashboard-mode-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map tabulated-list-mode-map) + (define-key map (kbd "RET") #'ai-code-session-dashboard-visit) + (define-key map (kbd "r") #'ai-code-session-dashboard-refresh) + (define-key map (kbd "g") #'ai-code-session-dashboard-refresh) + (define-key map (kbd "k") #'ai-code-session-dashboard-kill-session) + (define-key map (kbd "D") #'ai-code-session-dashboard-open-diff) + map) + "Keymap used by `ai-code-session-dashboard-mode'.") + +(define-derived-mode ai-code-session-dashboard-mode tabulated-list-mode "AI Sessions" + "Major mode for the AI session dashboard." + (setq tabulated-list-format + [("Session" 10 t) + ("Repo" 18 t) + ("Task file" 24 t) + ("Backend" 14 t) + ("Branch" 20 t) + ("Status" 12 t) + ("Dirty files" 11 t)]) + (setq tabulated-list-padding 2) + (add-hook 'tabulated-list-revert-hook #'ai-code-session-dashboard-refresh nil t) + (tabulated-list-init-header)) + +;;;###autoload +(defun ai-code-session-dashboard () + "Show the AI session dashboard." + (interactive) + (let ((buffer (get-buffer-create ai-code-session-dashboard-buffer-name))) + (with-current-buffer buffer + (ai-code-session-dashboard-mode) + (ai-code-session-dashboard-refresh)) + (pop-to-buffer buffer))) + +(provide 'ai-code-session-dashboard) + +;;; ai-code-session-dashboard.el ends here diff --git a/ai-code-session.el b/ai-code-session.el new file mode 100644 index 00000000..7221cba1 --- /dev/null +++ b/ai-code-session.el @@ -0,0 +1,189 @@ +;;; ai-code-session.el --- Lightweight AI session registry -*- lexical-binding: t; -*- + +;; Author: Kang Tu +;; SPDX-License-Identifier: Apache-2.0 + +;;; Commentary: +;; This library tracks active AI coding sessions and exposes a small query/update +;; API used by higher-level session UIs such as the dashboard. + +;;; Code: + +(require 'cl-lib) +(require 'subr-x) + +(declare-function magit-get-current-branch "magit-git" ()) +(declare-function magit-git-lines "magit-git" (&rest args)) + +(cl-defstruct ai-code-session + id + buffer + backend + repo-root + task-file + created-at + updated-at + metadata) + +(defvar ai-code-session--sessions (make-hash-table :test 'equal) + "Hash table mapping AI session ids to `ai-code-session' objects.") + +(defvar ai-code-session--next-id 0 + "Counter used to generate lightweight AI session ids.") + +(defun ai-code-session--generate-id () + "Return a new AI session id." + (format "S%s" (cl-incf ai-code-session--next-id))) + +(defun ai-code-session--normalize-backend (backend) + "Return BACKEND normalized for storage." + (cond + ((symbolp backend) (symbol-name backend)) + ((stringp backend) backend) + (t nil))) + +(defun ai-code-session--normalize-directory (directory) + "Return DIRECTORY normalized as an absolute directory path." + (when (and (stringp directory) + (not (string-empty-p directory))) + (file-name-as-directory (expand-file-name directory)))) + +(defun ai-code-session--normalize-file (file) + "Return FILE normalized as an absolute file path." + (when (and (stringp file) + (not (string-empty-p file))) + (expand-file-name file))) + +(defun ai-code-session--find-by-buffer (buffer) + "Return the session object associated with BUFFER, or nil." + (when (buffer-live-p buffer) + (cl-find-if (lambda (session) + (eq (ai-code-session-buffer session) buffer)) + (hash-table-values ai-code-session--sessions)))) + +(defun ai-code-session--merge-plists (base plist) + "Return BASE with the plist values from PLIST applied." + (let ((result (copy-sequence (or base '())))) + (while plist + (setq result (plist-put result (pop plist) (pop plist)))) + result)) + +(cl-defun ai-code-session-register (&key buffer backend repo-root task-file metadata id) + "Register or update an AI session and return it. +BUFFER is the session buffer. BACKEND, REPO-ROOT, TASK-FILE, and METADATA +describe the session. ID is optional and mainly useful when restoring state." + (unless (buffer-live-p buffer) + (user-error "Cannot register session without a live buffer")) + (let* ((now (current-time)) + (session (or (and id (gethash id ai-code-session--sessions)) + (ai-code-session--find-by-buffer buffer)))) + (if session + (progn + (setf (ai-code-session-buffer session) buffer + (ai-code-session-updated-at session) now) + (when backend + (setf (ai-code-session-backend session) + (ai-code-session--normalize-backend backend))) + (when repo-root + (setf (ai-code-session-repo-root session) + (ai-code-session--normalize-directory repo-root))) + (when task-file + (setf (ai-code-session-task-file session) + (ai-code-session--normalize-file task-file))) + (when metadata + (setf (ai-code-session-metadata session) + (ai-code-session--merge-plists + (ai-code-session-metadata session) + metadata)))) + (setq session + (make-ai-code-session + :id (or id (ai-code-session--generate-id)) + :buffer buffer + :backend (ai-code-session--normalize-backend backend) + :repo-root (ai-code-session--normalize-directory repo-root) + :task-file (ai-code-session--normalize-file task-file) + :created-at now + :updated-at now + :metadata (copy-sequence metadata))) + (puthash (ai-code-session-id session) session ai-code-session--sessions)) + session)) + +(defun ai-code-session-unregister (id-or-buffer) + "Remove the session identified by ID-OR-BUFFER from the registry." + (when-let ((session (ai-code-session-get id-or-buffer))) + (remhash (ai-code-session-id session) ai-code-session--sessions))) + +(defun ai-code-session-get (id-or-buffer) + "Return the registered session identified by ID-OR-BUFFER." + (cond + ((bufferp id-or-buffer) + (ai-code-session--find-by-buffer id-or-buffer)) + ((and (stringp id-or-buffer) + (not (string-empty-p id-or-buffer))) + (gethash id-or-buffer ai-code-session--sessions)) + (t nil))) + +(defun ai-code-session-list () + "Return the current registered AI sessions ordered by recent activity." + (sort (copy-sequence (hash-table-values ai-code-session--sessions)) + (lambda (left right) + (time-less-p (ai-code-session-updated-at right) + (ai-code-session-updated-at left))))) + +(defun ai-code-session-update-metadata (id-or-buffer metadata) + "Merge METADATA into the session identified by ID-OR-BUFFER." + (when-let ((session (ai-code-session-get id-or-buffer))) + (setf (ai-code-session-metadata session) + (ai-code-session--merge-plists + (ai-code-session-metadata session) + metadata) + (ai-code-session-updated-at session) (current-time)) + session)) + +(defun ai-code-session--status (session) + "Return a simple status string for SESSION." + (let ((buffer (ai-code-session-buffer session))) + (if (and (buffer-live-p buffer) + (when-let ((process (get-buffer-process buffer))) + (process-live-p process))) + "running" + "stopped"))) + +(defun ai-code-session--branch (repo-root) + "Return the current branch for REPO-ROOT, or nil." + (when (and repo-root (file-directory-p repo-root)) + (let ((default-directory repo-root)) + (ignore-errors + (magit-get-current-branch))))) + +(defun ai-code-session--dirty-count (repo-root) + "Return the dirty file count for REPO-ROOT, or nil." + (when (and repo-root (file-directory-p repo-root)) + (let ((default-directory repo-root)) + (ignore-errors + (magit-git-lines "status" "--porcelain" "--untracked-files=normal"))))) + +(defun ai-code-session--default-metadata (session) + "Return refreshed metadata plist for SESSION." + (let* ((repo-root (ai-code-session-repo-root session)) + (metadata (ai-code-session-metadata session)) + (branch (ai-code-session--branch repo-root)) + (dirty-count (ai-code-session--dirty-count repo-root))) + (list :branch (or branch (plist-get metadata :branch)) + :status (ai-code-session--status session) + :dirty-count (or dirty-count (plist-get metadata :dirty-count) 0)))) + +(defun ai-code-session-refresh () + "Refresh session state and return the live session list." + (dolist (session (copy-sequence (hash-table-values ai-code-session--sessions))) + (let ((buffer (ai-code-session-buffer session))) + (if (not (buffer-live-p buffer)) + (ai-code-session-unregister (ai-code-session-id session)) + (ai-code-session-update-metadata + (ai-code-session-id session) + (ai-code-session--default-metadata session))))) + (ai-code-session-list)) + +(provide 'ai-code-session) + +;;; ai-code-session.el ends here diff --git a/ai-code.el b/ai-code.el index df40c7c9..71914f51 100644 --- a/ai-code.el +++ b/ai-code.el @@ -103,6 +103,7 @@ (require 'ai-code-backends) (require 'ai-code-backends-infra) +(require 'ai-code-session-dashboard) (require 'ai-code-input) (require 'ai-code-prompt-mode) (require 'ai-code-agile) @@ -386,6 +387,7 @@ Shows the current backend label to the right." ("R" "Resume AI CLI (C-u: args)" ai-code-cli-resume) ("z" "Switch to AI CLI (C-u: hide)" ai-code-cli-switch-to-buffer-or-hide) ("s" ai-code-select-backend :description ai-code--select-backend-description) + ("j" "Session dashboard" ai-code-session-dashboard) ;; DONE: similar to ai-code-select-backend, add ai-code-select-terminal, it will use ai-code-backends-infra-terminal-backend to select between different terminal emulators for AI sessions, such as vterm, eat, and ghostel. ("u" "Install / Upgrade AI CLI" ai-code-upgrade-backend) ("S" "(Un)Install skills for backend" ai-code-install-backend-skills) diff --git a/test/test_ai-code-session-dashboard.el b/test/test_ai-code-session-dashboard.el new file mode 100644 index 00000000..52f9892f --- /dev/null +++ b/test/test_ai-code-session-dashboard.el @@ -0,0 +1,148 @@ +;;; test_ai-code-session-dashboard.el --- Tests for ai-code-session-dashboard.el -*- lexical-binding: t; -*- + +;; SPDX-License-Identifier: Apache-2.0 + +;;; Commentary: +;; Tests for the AI session dashboard UI. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'ai-code-session-dashboard) + +(defmacro ai-code-test-session-dashboard--with-clean-state (&rest body) + "Run BODY with a fresh dashboard/session state." + `(let ((ai-code-session--sessions (make-hash-table :test 'equal)) + (ai-code-session--next-id 0)) + ,@body)) + +(defun ai-code-test-session-dashboard--goto-first-entry () + "Move point to the first dashboard entry." + (goto-char (point-min)) + (re-search-forward "^S[0-9]+" nil t) + (beginning-of-line)) + +(ert-deftest ai-code-test-session-dashboard-renders-mvp-columns () + "Dashboard should render the MVP session information." + (ai-code-test-session-dashboard--with-clean-state + (let ((session-buffer (get-buffer-create "*codex[demo]*")) + (repo-root (make-temp-file "ai-code-dashboard-demo-" t)) + dashboard-buffer) + (unwind-protect + (progn + (ai-code-session-register + :buffer session-buffer + :backend "codex" + :repo-root repo-root + :task-file "/tmp/demo-repo/.ai.code.files/task_x.org" + :metadata '(:branch "feature/x" :status "running" :dirty-count 3)) + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'mock-process)) + ((symbol-function 'process-live-p) + (lambda (_process) t)) + ((symbol-function 'magit-get-current-branch) + (lambda () "feature/x")) + ((symbol-function 'magit-git-lines) + (lambda (&rest _args) 3)) + ((symbol-function 'pop-to-buffer) + (lambda (buffer &rest _args) + (setq dashboard-buffer buffer) + (get-buffer-window buffer)))) + (ai-code-session-dashboard)) + (with-current-buffer dashboard-buffer + (should (derived-mode-p 'ai-code-session-dashboard-mode)) + (should (equal (length tabulated-list-entries) 1)) + (let ((entry (cadar tabulated-list-entries))) + (should (equal (aref entry 1) + (file-name-nondirectory + (directory-file-name repo-root)))) + (should (equal (aref entry 2) "task_x.org")) + (should (equal (aref entry 3) "Codex")) + (should (equal (aref entry 4) "feature/x")) + (should (equal (aref entry 5) "running")) + (should (equal (aref entry 6) "3"))))) + (when (buffer-live-p session-buffer) + (kill-buffer session-buffer)) + (when (file-directory-p repo-root) + (delete-directory repo-root t)) + (when (buffer-live-p dashboard-buffer) + (kill-buffer dashboard-buffer)))))) + +(ert-deftest ai-code-test-session-dashboard-visit-switches-to-session-buffer () + "RET should jump to the selected session buffer." + (ai-code-test-session-dashboard--with-clean-state + (let ((session-buffer (get-buffer-create "*codex[visit]*")) + (repo-root (make-temp-file "ai-code-dashboard-visit-" t)) + dashboard-buffer + visited-buffer) + (unwind-protect + (progn + (ai-code-session-register + :buffer session-buffer + :backend "codex" + :repo-root repo-root + :metadata '(:status "running" :dirty-count 0)) + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'mock-process)) + ((symbol-function 'process-live-p) + (lambda (_process) t)) + ((symbol-function 'pop-to-buffer) + (lambda (buffer &rest _args) + (if (eq buffer session-buffer) + (setq visited-buffer buffer) + (setq dashboard-buffer buffer)) + (get-buffer-window buffer)))) + (ai-code-session-dashboard) + (with-current-buffer dashboard-buffer + (ai-code-test-session-dashboard--goto-first-entry) + (ai-code-session-dashboard-visit)) + (should (eq visited-buffer session-buffer)))) + (when (buffer-live-p session-buffer) + (kill-buffer session-buffer)) + (when (file-directory-p repo-root) + (delete-directory repo-root t)) + (when (buffer-live-p dashboard-buffer) + (kill-buffer dashboard-buffer)))))) + +(ert-deftest ai-code-test-session-dashboard-open-diff-uses-magit-status () + "D should open `magit-status' for the session repository." + (ai-code-test-session-dashboard--with-clean-state + (let ((session-buffer (get-buffer-create "*codex[diff]*")) + (repo-root (make-temp-file "ai-code-dashboard-diff-" t)) + dashboard-buffer + opened-repo) + (unwind-protect + (progn + (ai-code-session-register + :buffer session-buffer + :backend "codex" + :repo-root repo-root + :metadata '(:status "running" :dirty-count 0)) + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'mock-process)) + ((symbol-function 'process-live-p) + (lambda (_process) t)) + ((symbol-function 'pop-to-buffer) + (lambda (buffer &rest _args) + (setq dashboard-buffer buffer) + (get-buffer-window buffer))) + ((symbol-function 'magit-status-setup-buffer) + (lambda (directory) + (setq opened-repo directory)))) + (ai-code-session-dashboard) + (with-current-buffer dashboard-buffer + (ai-code-test-session-dashboard--goto-first-entry) + (ai-code-session-dashboard-open-diff)) + (should (equal opened-repo + (file-name-as-directory repo-root))))) + (when (buffer-live-p session-buffer) + (kill-buffer session-buffer)) + (when (file-directory-p repo-root) + (delete-directory repo-root t)) + (when (buffer-live-p dashboard-buffer) + (kill-buffer dashboard-buffer)))))) + +(provide 'test_ai-code-session-dashboard) + +;;; test_ai-code-session-dashboard.el ends here diff --git a/test/test_ai-code-session.el b/test/test_ai-code-session.el new file mode 100644 index 00000000..36ba9c72 --- /dev/null +++ b/test/test_ai-code-session.el @@ -0,0 +1,89 @@ +;;; test_ai-code-session.el --- Tests for ai-code-session.el -*- lexical-binding: t; -*- + +;; SPDX-License-Identifier: Apache-2.0 + +;;; Commentary: +;; Tests for the lightweight AI session registry. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'ai-code-session) + +(defmacro ai-code-test-session--with-clean-registry (&rest body) + "Run BODY with a fresh session registry." + `(let ((ai-code-session--sessions (make-hash-table :test 'equal)) + (ai-code-session--next-id 0)) + ,@body)) + +(ert-deftest ai-code-test-session-register-reuses-existing-buffer-entry () + "Registering the same buffer twice should update the existing session." + (ai-code-test-session--with-clean-registry + (let ((buffer (get-buffer-create "*codex[test]*"))) + (unwind-protect + (let* ((first (ai-code-session-register + :buffer buffer + :backend "codex" + :repo-root "/tmp/repo/" + :task-file "/tmp/repo/.ai.code.files/task-a.org")) + (second (ai-code-session-register + :buffer buffer + :backend "codex" + :repo-root "/tmp/repo/" + :task-file "/tmp/repo/.ai.code.files/task-b.org"))) + (should (equal (ai-code-session-id first) + (ai-code-session-id second))) + (should (= (length (ai-code-session-list)) 1)) + (should (equal (ai-code-session-task-file second) + "/tmp/repo/.ai.code.files/task-b.org"))) + (when (buffer-live-p buffer) + (kill-buffer buffer)))))) + +(ert-deftest ai-code-test-session-refresh-populates-branch-status-and-dirty-count () + "Refreshing should populate simple git/process metadata." + (ai-code-test-session--with-clean-registry + (let ((buffer (get-buffer-create "*codex[test-refresh]*")) + (repo-root (make-temp-file "ai-code-session-refresh-" t))) + (unwind-protect + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'mock-process)) + ((symbol-function 'process-live-p) + (lambda (_process) t)) + ((symbol-function 'magit-get-current-branch) + (lambda () "feature/dashboard")) + ((symbol-function 'magit-git-lines) + (lambda (&rest _args) 3))) + (let ((session (ai-code-session-register + :buffer buffer + :backend "codex" + :repo-root repo-root + :task-file "/tmp/repo/.ai.code.files/task.org"))) + (ai-code-session-refresh) + (setq session (ai-code-session-get (ai-code-session-id session))) + (should (equal (plist-get (ai-code-session-metadata session) :branch) + "feature/dashboard")) + (should (equal (plist-get (ai-code-session-metadata session) :status) + "running")) + (should (= (plist-get (ai-code-session-metadata session) :dirty-count) + 3)))) + (when (buffer-live-p buffer) + (kill-buffer buffer)) + (when (file-directory-p repo-root) + (delete-directory repo-root t)))))) + +(ert-deftest ai-code-test-session-refresh-prunes-dead-buffers () + "Refreshing should drop sessions whose buffers are no longer live." + (ai-code-test-session--with-clean-registry + (let ((buffer (get-buffer-create "*codex[test-dead]*"))) + (ai-code-session-register + :buffer buffer + :backend "codex" + :repo-root "/tmp/repo/") + (kill-buffer buffer) + (ai-code-session-refresh) + (should-not (ai-code-session-list))))) + +(provide 'test_ai-code-session) + +;;; test_ai-code-session.el ends here diff --git a/test/test_ai-code.el b/test/test_ai-code.el index 4f399896..384a1bb5 100644 --- a/test/test_ai-code.el +++ b/test/test_ai-code.el @@ -176,6 +176,15 @@ (should (eq (plist-get (cdr suffix) :command) 'ai-code-select-terminal)))) +(ert-deftest ai-code-test-menu-ai-cli-session-includes-session-dashboard-entry () + "Test that the AI CLI session menu exposes the session dashboard." + (let ((suffix (transient-get-suffix 'ai-code--menu-ai-cli-session "j"))) + (should suffix) + (should (eq (plist-get (cdr suffix) :command) + 'ai-code-session-dashboard)) + (should (equal (plist-get (cdr suffix) :description) + "Session dashboard")))) + (ert-deftest ai-code-test-menu-other-tools-includes-debug-emacs-runtime-entry () "Test that the Other Tools menu exposes Emacs runtime debugging." (let ((suffix (transient-get-suffix 'ai-code--menu-other-tools "d"))) From ac93a3c8d221298abae2ccd0c9609af148e37b53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 18:39:56 +0000 Subject: [PATCH 03/10] Fix dashboard dirty file counting Agent-Logs-Url: https://github.com/tninja/ai-code-interface.el/sessions/cc89fddd-a1d6-47bd-9862-95a4aa0ca75f Co-authored-by: tninja <714625+tninja@users.noreply.github.com> --- ai-code-session.el | 3 ++- test/test_ai-code-session-dashboard.el | 2 +- test/test_ai-code-session.el | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ai-code-session.el b/ai-code-session.el index 7221cba1..2c9e9464 100644 --- a/ai-code-session.el +++ b/ai-code-session.el @@ -161,7 +161,8 @@ describe the session. ID is optional and mainly useful when restoring state." (when (and repo-root (file-directory-p repo-root)) (let ((default-directory repo-root)) (ignore-errors - (magit-git-lines "status" "--porcelain" "--untracked-files=normal"))))) + (length + (magit-git-lines "status" "--porcelain" "--untracked-files=normal")))))) (defun ai-code-session--default-metadata (session) "Return refreshed metadata plist for SESSION." diff --git a/test/test_ai-code-session-dashboard.el b/test/test_ai-code-session-dashboard.el index 52f9892f..5df00e27 100644 --- a/test/test_ai-code-session-dashboard.el +++ b/test/test_ai-code-session-dashboard.el @@ -44,7 +44,7 @@ ((symbol-function 'magit-get-current-branch) (lambda () "feature/x")) ((symbol-function 'magit-git-lines) - (lambda (&rest _args) 3)) + (lambda (&rest _args) '("a" "b" "c"))) ((symbol-function 'pop-to-buffer) (lambda (buffer &rest _args) (setq dashboard-buffer buffer) diff --git a/test/test_ai-code-session.el b/test/test_ai-code-session.el index 36ba9c72..9b98cc28 100644 --- a/test/test_ai-code-session.el +++ b/test/test_ai-code-session.el @@ -53,7 +53,7 @@ ((symbol-function 'magit-get-current-branch) (lambda () "feature/dashboard")) ((symbol-function 'magit-git-lines) - (lambda (&rest _args) 3))) + (lambda (&rest _args) '("a" "b" "c")))) (let ((session (ai-code-session-register :buffer buffer :backend "codex" From 48b484e594123fa8c6dc6e7f966d345f8b08cf99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 18:45:18 +0000 Subject: [PATCH 04/10] Add session dashboard edge case tests Agent-Logs-Url: https://github.com/tninja/ai-code-interface.el/sessions/cc89fddd-a1d6-47bd-9862-95a4aa0ca75f Co-authored-by: tninja <714625+tninja@users.noreply.github.com> --- test/test_ai-code-session-dashboard.el | 39 ++++++++++++++++++++++++++ test/test_ai-code-session.el | 6 ++++ 2 files changed, 45 insertions(+) diff --git a/test/test_ai-code-session-dashboard.el b/test/test_ai-code-session-dashboard.el index 5df00e27..13ccfeb3 100644 --- a/test/test_ai-code-session-dashboard.el +++ b/test/test_ai-code-session-dashboard.el @@ -143,6 +143,45 @@ (when (buffer-live-p dashboard-buffer) (kill-buffer dashboard-buffer)))))) +(ert-deftest ai-code-test-session-dashboard-kill-session-removes-registry-entry () + "Killing a running dashboard session should unregister it." + (ai-code-test-session-dashboard--with-clean-state + (let ((session-buffer (get-buffer-create "*codex[kill]*")) + (repo-root (make-temp-file "ai-code-dashboard-kill-" t)) + dashboard-buffer + process) + (unwind-protect + (progn + (setq process (start-process "ai-code-dashboard-kill" session-buffer + "sleep" "60")) + (set-process-query-on-exit-flag process nil) + (ai-code-session-register + :buffer session-buffer + :backend "codex" + :repo-root repo-root + :metadata '(:status "running" :dirty-count 0)) + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (&rest _args) t)) + ((symbol-function 'pop-to-buffer) + (lambda (buffer &rest _args) + (setq dashboard-buffer buffer) + (get-buffer-window buffer)))) + (ai-code-session-dashboard) + (with-current-buffer dashboard-buffer + (ai-code-test-session-dashboard--goto-first-entry) + (ai-code-session-dashboard-kill-session)) + (should-not (ai-code-session-list)) + (should-not (buffer-live-p session-buffer)) + (should-not (process-live-p process)))) + (when (and process (process-live-p process)) + (delete-process process)) + (when (buffer-live-p session-buffer) + (kill-buffer session-buffer)) + (when (file-directory-p repo-root) + (delete-directory repo-root t)) + (when (buffer-live-p dashboard-buffer) + (kill-buffer dashboard-buffer)))))) + (provide 'test_ai-code-session-dashboard) ;;; test_ai-code-session-dashboard.el ends here diff --git a/test/test_ai-code-session.el b/test/test_ai-code-session.el index 9b98cc28..b379b938 100644 --- a/test/test_ai-code-session.el +++ b/test/test_ai-code-session.el @@ -84,6 +84,12 @@ (ai-code-session-refresh) (should-not (ai-code-session-list))))) +(ert-deftest ai-code-test-session-get-invalid-targets-return-nil () + "Invalid `ai-code-session-get' targets should return nil." + (ai-code-test-session--with-clean-registry + (should-not (ai-code-session-get nil)) + (should-not (ai-code-session-get "missing-session-id")))) + (provide 'test_ai-code-session) ;;; test_ai-code-session.el ends here From 81402081b81d5c42c5ee3ae06d899ed81784905b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 00:11:55 +0000 Subject: [PATCH 05/10] Merge session dashboard into session module Agent-Logs-Url: https://github.com/tninja/ai-code-interface.el/sessions/b779b762-86e3-4b01-a210-778c5171230d Co-authored-by: tninja <714625+tninja@users.noreply.github.com> --- ai-code-session-dashboard.el | 145 ------------------- ai-code-session.el | 132 ++++++++++++++++- ai-code.el | 2 +- test/test_ai-code-session-dashboard.el | 187 ------------------------- test/test_ai-code-session.el | 167 +++++++++++++++++++++- 5 files changed, 296 insertions(+), 337 deletions(-) delete mode 100644 ai-code-session-dashboard.el delete mode 100644 test/test_ai-code-session-dashboard.el diff --git a/ai-code-session-dashboard.el b/ai-code-session-dashboard.el deleted file mode 100644 index ba85584e..00000000 --- a/ai-code-session-dashboard.el +++ /dev/null @@ -1,145 +0,0 @@ -;;; ai-code-session-dashboard.el --- Dashboard for AI sessions -*- lexical-binding: t; -*- - -;; Author: Kang Tu -;; SPDX-License-Identifier: Apache-2.0 - -;;; Commentary: -;; This library provides a simple tabulated-list dashboard for active AI coding -;; sessions tracked by `ai-code-session'. - -;;; Code: - -(require 'subr-x) -(require 'tabulated-list) -(require 'ai-code-session) - -(declare-function magit-status "magit-status" (&optional directory)) -(declare-function magit-status-setup-buffer "magit-status" (directory)) - -(defconst ai-code-session-dashboard-buffer-name "*AI Code Sessions*" - "Buffer name used by the AI session dashboard.") - -(defun ai-code-session-dashboard--repo-name (session) - "Return a short repository name for SESSION." - (when-let ((repo-root (ai-code-session-repo-root session))) - (file-name-nondirectory (directory-file-name repo-root)))) - -(defun ai-code-session-dashboard--task-name (session) - "Return a display task file name for SESSION." - (when-let ((task-file (ai-code-session-task-file session))) - (file-name-nondirectory task-file))) - -(defun ai-code-session-dashboard--backend-label (backend) - "Return a human-friendly label for BACKEND." - (let ((text (cond - ((symbolp backend) (symbol-name backend)) - ((stringp backend) backend) - (t "")))) - (capitalize (replace-regexp-in-string "[-_]+" " " text)))) - -(defun ai-code-session-dashboard--entry (session) - "Return the `tabulated-list-mode' entry for SESSION." - (let* ((metadata (ai-code-session-metadata session)) - (branch (or (plist-get metadata :branch) "")) - (status (or (plist-get metadata :status) "")) - (dirty-count (number-to-string (or (plist-get metadata :dirty-count) 0)))) - (list (ai-code-session-id session) - (vector - (ai-code-session-id session) - (or (ai-code-session-dashboard--repo-name session) "") - (or (ai-code-session-dashboard--task-name session) "") - (ai-code-session-dashboard--backend-label - (ai-code-session-backend session)) - branch - status - dirty-count)))) - -(defun ai-code-session-dashboard--entries () - "Return dashboard entries for all active sessions." - (mapcar #'ai-code-session-dashboard--entry - (ai-code-session-refresh))) - -(defun ai-code-session-dashboard--session-at-point () - "Return the dashboard session at point." - (ai-code-session-get (tabulated-list-get-id))) - -(defun ai-code-session-dashboard-refresh () - "Refresh the AI session dashboard." - (interactive) - (setq tabulated-list-entries (ai-code-session-dashboard--entries)) - (tabulated-list-print t)) - -(defun ai-code-session-dashboard-visit () - "Visit the session buffer on the current line." - (interactive) - (if-let* ((session (ai-code-session-dashboard--session-at-point)) - (buffer (ai-code-session-buffer session)) - ((buffer-live-p buffer))) - (pop-to-buffer buffer) - (user-error "No live AI session on this line"))) - -(defun ai-code-session-dashboard-kill-session () - "Kill the session on the current line, after confirmation when needed." - (interactive) - (let* ((session (or (ai-code-session-dashboard--session-at-point) - (user-error "No AI session on this line"))) - (buffer (ai-code-session-buffer session)) - (process (and (buffer-live-p buffer) (get-buffer-process buffer)))) - (when (and process - (process-live-p process) - (not (y-or-n-p (format "Kill AI session %s? " - (ai-code-session-id session))))) - (user-error "Session kill canceled")) - (when (and process (process-live-p process)) - (delete-process process)) - (when (buffer-live-p buffer) - (kill-buffer buffer)) - (ai-code-session-unregister session) - (ai-code-session-dashboard-refresh))) - -(defun ai-code-session-dashboard-open-diff () - "Open Magit status for the repository on the current line." - (interactive) - (if-let* ((session (ai-code-session-dashboard--session-at-point)) - (repo-root (ai-code-session-repo-root session))) - (magit-status-setup-buffer repo-root) - (user-error "No repository is associated with this session"))) - -(defvar ai-code-session-dashboard-mode-map - (let ((map (make-sparse-keymap))) - (set-keymap-parent map tabulated-list-mode-map) - (define-key map (kbd "RET") #'ai-code-session-dashboard-visit) - (define-key map (kbd "r") #'ai-code-session-dashboard-refresh) - (define-key map (kbd "g") #'ai-code-session-dashboard-refresh) - (define-key map (kbd "k") #'ai-code-session-dashboard-kill-session) - (define-key map (kbd "D") #'ai-code-session-dashboard-open-diff) - map) - "Keymap used by `ai-code-session-dashboard-mode'.") - -(define-derived-mode ai-code-session-dashboard-mode tabulated-list-mode "AI Sessions" - "Major mode for the AI session dashboard." - (setq tabulated-list-format - [("Session" 10 t) - ("Repo" 18 t) - ("Task file" 24 t) - ("Backend" 14 t) - ("Branch" 20 t) - ("Status" 12 t) - ("Dirty files" 11 t)]) - (setq tabulated-list-padding 2) - (add-hook 'tabulated-list-revert-hook #'ai-code-session-dashboard-refresh nil t) - (tabulated-list-init-header)) - -;;;###autoload -(defun ai-code-session-dashboard () - "Show the AI session dashboard." - (interactive) - (let ((buffer (get-buffer-create ai-code-session-dashboard-buffer-name))) - (with-current-buffer buffer - (ai-code-session-dashboard-mode) - (ai-code-session-dashboard-refresh)) - (pop-to-buffer buffer))) - -(provide 'ai-code-session-dashboard) - -;;; ai-code-session-dashboard.el ends here diff --git a/ai-code-session.el b/ai-code-session.el index 2c9e9464..b98c2db5 100644 --- a/ai-code-session.el +++ b/ai-code-session.el @@ -1,19 +1,21 @@ -;;; ai-code-session.el --- Lightweight AI session registry -*- lexical-binding: t; -*- +;;; ai-code-session.el --- AI session registry and dashboard -*- lexical-binding: t; -*- ;; Author: Kang Tu ;; SPDX-License-Identifier: Apache-2.0 ;;; Commentary: -;; This library tracks active AI coding sessions and exposes a small query/update -;; API used by higher-level session UIs such as the dashboard. +;; This library tracks active AI coding sessions and provides a small query/update +;; API plus a simple dashboard for returning to active work. ;;; Code: (require 'cl-lib) (require 'subr-x) +(require 'tabulated-list) (declare-function magit-get-current-branch "magit-git" ()) (declare-function magit-git-lines "magit-git" (&rest args)) +(declare-function magit-status-setup-buffer "magit-status" (directory)) (cl-defstruct ai-code-session id @@ -185,6 +187,130 @@ describe the session. ID is optional and mainly useful when restoring state." (ai-code-session--default-metadata session))))) (ai-code-session-list)) +(defconst ai-code-session-dashboard-buffer-name "*AI Code Sessions*" + "Buffer name used by the AI session dashboard.") + +(defun ai-code-session-dashboard--repo-name (session) + "Return a short repository name for SESSION." + (when-let ((repo-root (ai-code-session-repo-root session))) + (file-name-nondirectory (directory-file-name repo-root)))) + +(defun ai-code-session-dashboard--task-name (session) + "Return a display task file name for SESSION." + (when-let ((task-file (ai-code-session-task-file session))) + (file-name-nondirectory task-file))) + +(defun ai-code-session-dashboard--backend-label (backend) + "Return a human-friendly label for BACKEND." + (let ((text (cond + ((symbolp backend) (symbol-name backend)) + ((stringp backend) backend) + (t "")))) + (capitalize (replace-regexp-in-string "[-_]+" " " text)))) + +(defun ai-code-session-dashboard--entry (session) + "Return the `tabulated-list-mode' entry for SESSION." + (let* ((metadata (ai-code-session-metadata session)) + (branch (or (plist-get metadata :branch) "")) + (status (or (plist-get metadata :status) "")) + (dirty-count (number-to-string (or (plist-get metadata :dirty-count) 0)))) + (list (ai-code-session-id session) + (vector + (ai-code-session-id session) + (or (ai-code-session-dashboard--repo-name session) "") + (or (ai-code-session-dashboard--task-name session) "") + (ai-code-session-dashboard--backend-label + (ai-code-session-backend session)) + branch + status + dirty-count)))) + +(defun ai-code-session-dashboard--entries () + "Return dashboard entries for all active sessions." + (mapcar #'ai-code-session-dashboard--entry + (ai-code-session-refresh))) + +(defun ai-code-session-dashboard--session-at-point () + "Return the dashboard session at point." + (ai-code-session-get (tabulated-list-get-id))) + +(defun ai-code-session-dashboard-refresh () + "Refresh the AI session dashboard." + (interactive) + (setq tabulated-list-entries (ai-code-session-dashboard--entries)) + (tabulated-list-print t)) + +(defun ai-code-session-dashboard-visit () + "Visit the session buffer on the current line." + (interactive) + (if-let* ((session (ai-code-session-dashboard--session-at-point)) + (buffer (ai-code-session-buffer session)) + ((buffer-live-p buffer))) + (pop-to-buffer buffer) + (user-error "No live AI session on this line"))) + +(defun ai-code-session-dashboard-kill-session () + "Kill the session on the current line, after confirmation when needed." + (interactive) + (let* ((session (or (ai-code-session-dashboard--session-at-point) + (user-error "No AI session on this line"))) + (buffer (ai-code-session-buffer session)) + (process (and (buffer-live-p buffer) (get-buffer-process buffer)))) + (when (and process + (process-live-p process) + (not (y-or-n-p (format "Kill AI session %s? " + (ai-code-session-id session))))) + (user-error "Session kill canceled")) + (when (and process (process-live-p process)) + (delete-process process)) + (when (buffer-live-p buffer) + (kill-buffer buffer)) + (ai-code-session-unregister session) + (ai-code-session-dashboard-refresh))) + +(defun ai-code-session-dashboard-open-diff () + "Open Magit status for the repository on the current line." + (interactive) + (if-let* ((session (ai-code-session-dashboard--session-at-point)) + (repo-root (ai-code-session-repo-root session))) + (magit-status-setup-buffer repo-root) + (user-error "No repository is associated with this session"))) + +(defvar ai-code-session-dashboard-mode-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map tabulated-list-mode-map) + (define-key map (kbd "RET") #'ai-code-session-dashboard-visit) + (define-key map (kbd "r") #'ai-code-session-dashboard-refresh) + (define-key map (kbd "g") #'ai-code-session-dashboard-refresh) + (define-key map (kbd "k") #'ai-code-session-dashboard-kill-session) + (define-key map (kbd "D") #'ai-code-session-dashboard-open-diff) + map) + "Keymap used by `ai-code-session-dashboard-mode'.") + +(define-derived-mode ai-code-session-dashboard-mode tabulated-list-mode "AI Sessions" + "Major mode for the AI session dashboard." + (setq tabulated-list-format + [("Session" 10 t) + ("Repo" 18 t) + ("Task file" 24 t) + ("Backend" 14 t) + ("Branch" 20 t) + ("Status" 12 t) + ("Dirty files" 11 t)]) + (setq tabulated-list-padding 2) + (add-hook 'tabulated-list-revert-hook #'ai-code-session-dashboard-refresh nil t) + (tabulated-list-init-header)) + +;;;###autoload +(defun ai-code-session-dashboard () + "Show the AI session dashboard." + (interactive) + (let ((buffer (get-buffer-create ai-code-session-dashboard-buffer-name))) + (with-current-buffer buffer + (ai-code-session-dashboard-mode) + (ai-code-session-dashboard-refresh)) + (pop-to-buffer buffer))) + (provide 'ai-code-session) ;;; ai-code-session.el ends here diff --git a/ai-code.el b/ai-code.el index 71914f51..2c63adba 100644 --- a/ai-code.el +++ b/ai-code.el @@ -103,7 +103,7 @@ (require 'ai-code-backends) (require 'ai-code-backends-infra) -(require 'ai-code-session-dashboard) +(require 'ai-code-session) (require 'ai-code-input) (require 'ai-code-prompt-mode) (require 'ai-code-agile) diff --git a/test/test_ai-code-session-dashboard.el b/test/test_ai-code-session-dashboard.el deleted file mode 100644 index 13ccfeb3..00000000 --- a/test/test_ai-code-session-dashboard.el +++ /dev/null @@ -1,187 +0,0 @@ -;;; test_ai-code-session-dashboard.el --- Tests for ai-code-session-dashboard.el -*- lexical-binding: t; -*- - -;; SPDX-License-Identifier: Apache-2.0 - -;;; Commentary: -;; Tests for the AI session dashboard UI. - -;;; Code: - -(require 'ert) -(require 'cl-lib) -(require 'ai-code-session-dashboard) - -(defmacro ai-code-test-session-dashboard--with-clean-state (&rest body) - "Run BODY with a fresh dashboard/session state." - `(let ((ai-code-session--sessions (make-hash-table :test 'equal)) - (ai-code-session--next-id 0)) - ,@body)) - -(defun ai-code-test-session-dashboard--goto-first-entry () - "Move point to the first dashboard entry." - (goto-char (point-min)) - (re-search-forward "^S[0-9]+" nil t) - (beginning-of-line)) - -(ert-deftest ai-code-test-session-dashboard-renders-mvp-columns () - "Dashboard should render the MVP session information." - (ai-code-test-session-dashboard--with-clean-state - (let ((session-buffer (get-buffer-create "*codex[demo]*")) - (repo-root (make-temp-file "ai-code-dashboard-demo-" t)) - dashboard-buffer) - (unwind-protect - (progn - (ai-code-session-register - :buffer session-buffer - :backend "codex" - :repo-root repo-root - :task-file "/tmp/demo-repo/.ai.code.files/task_x.org" - :metadata '(:branch "feature/x" :status "running" :dirty-count 3)) - (cl-letf (((symbol-function 'get-buffer-process) - (lambda (_buffer) 'mock-process)) - ((symbol-function 'process-live-p) - (lambda (_process) t)) - ((symbol-function 'magit-get-current-branch) - (lambda () "feature/x")) - ((symbol-function 'magit-git-lines) - (lambda (&rest _args) '("a" "b" "c"))) - ((symbol-function 'pop-to-buffer) - (lambda (buffer &rest _args) - (setq dashboard-buffer buffer) - (get-buffer-window buffer)))) - (ai-code-session-dashboard)) - (with-current-buffer dashboard-buffer - (should (derived-mode-p 'ai-code-session-dashboard-mode)) - (should (equal (length tabulated-list-entries) 1)) - (let ((entry (cadar tabulated-list-entries))) - (should (equal (aref entry 1) - (file-name-nondirectory - (directory-file-name repo-root)))) - (should (equal (aref entry 2) "task_x.org")) - (should (equal (aref entry 3) "Codex")) - (should (equal (aref entry 4) "feature/x")) - (should (equal (aref entry 5) "running")) - (should (equal (aref entry 6) "3"))))) - (when (buffer-live-p session-buffer) - (kill-buffer session-buffer)) - (when (file-directory-p repo-root) - (delete-directory repo-root t)) - (when (buffer-live-p dashboard-buffer) - (kill-buffer dashboard-buffer)))))) - -(ert-deftest ai-code-test-session-dashboard-visit-switches-to-session-buffer () - "RET should jump to the selected session buffer." - (ai-code-test-session-dashboard--with-clean-state - (let ((session-buffer (get-buffer-create "*codex[visit]*")) - (repo-root (make-temp-file "ai-code-dashboard-visit-" t)) - dashboard-buffer - visited-buffer) - (unwind-protect - (progn - (ai-code-session-register - :buffer session-buffer - :backend "codex" - :repo-root repo-root - :metadata '(:status "running" :dirty-count 0)) - (cl-letf (((symbol-function 'get-buffer-process) - (lambda (_buffer) 'mock-process)) - ((symbol-function 'process-live-p) - (lambda (_process) t)) - ((symbol-function 'pop-to-buffer) - (lambda (buffer &rest _args) - (if (eq buffer session-buffer) - (setq visited-buffer buffer) - (setq dashboard-buffer buffer)) - (get-buffer-window buffer)))) - (ai-code-session-dashboard) - (with-current-buffer dashboard-buffer - (ai-code-test-session-dashboard--goto-first-entry) - (ai-code-session-dashboard-visit)) - (should (eq visited-buffer session-buffer)))) - (when (buffer-live-p session-buffer) - (kill-buffer session-buffer)) - (when (file-directory-p repo-root) - (delete-directory repo-root t)) - (when (buffer-live-p dashboard-buffer) - (kill-buffer dashboard-buffer)))))) - -(ert-deftest ai-code-test-session-dashboard-open-diff-uses-magit-status () - "D should open `magit-status' for the session repository." - (ai-code-test-session-dashboard--with-clean-state - (let ((session-buffer (get-buffer-create "*codex[diff]*")) - (repo-root (make-temp-file "ai-code-dashboard-diff-" t)) - dashboard-buffer - opened-repo) - (unwind-protect - (progn - (ai-code-session-register - :buffer session-buffer - :backend "codex" - :repo-root repo-root - :metadata '(:status "running" :dirty-count 0)) - (cl-letf (((symbol-function 'get-buffer-process) - (lambda (_buffer) 'mock-process)) - ((symbol-function 'process-live-p) - (lambda (_process) t)) - ((symbol-function 'pop-to-buffer) - (lambda (buffer &rest _args) - (setq dashboard-buffer buffer) - (get-buffer-window buffer))) - ((symbol-function 'magit-status-setup-buffer) - (lambda (directory) - (setq opened-repo directory)))) - (ai-code-session-dashboard) - (with-current-buffer dashboard-buffer - (ai-code-test-session-dashboard--goto-first-entry) - (ai-code-session-dashboard-open-diff)) - (should (equal opened-repo - (file-name-as-directory repo-root))))) - (when (buffer-live-p session-buffer) - (kill-buffer session-buffer)) - (when (file-directory-p repo-root) - (delete-directory repo-root t)) - (when (buffer-live-p dashboard-buffer) - (kill-buffer dashboard-buffer)))))) - -(ert-deftest ai-code-test-session-dashboard-kill-session-removes-registry-entry () - "Killing a running dashboard session should unregister it." - (ai-code-test-session-dashboard--with-clean-state - (let ((session-buffer (get-buffer-create "*codex[kill]*")) - (repo-root (make-temp-file "ai-code-dashboard-kill-" t)) - dashboard-buffer - process) - (unwind-protect - (progn - (setq process (start-process "ai-code-dashboard-kill" session-buffer - "sleep" "60")) - (set-process-query-on-exit-flag process nil) - (ai-code-session-register - :buffer session-buffer - :backend "codex" - :repo-root repo-root - :metadata '(:status "running" :dirty-count 0)) - (cl-letf (((symbol-function 'y-or-n-p) - (lambda (&rest _args) t)) - ((symbol-function 'pop-to-buffer) - (lambda (buffer &rest _args) - (setq dashboard-buffer buffer) - (get-buffer-window buffer)))) - (ai-code-session-dashboard) - (with-current-buffer dashboard-buffer - (ai-code-test-session-dashboard--goto-first-entry) - (ai-code-session-dashboard-kill-session)) - (should-not (ai-code-session-list)) - (should-not (buffer-live-p session-buffer)) - (should-not (process-live-p process)))) - (when (and process (process-live-p process)) - (delete-process process)) - (when (buffer-live-p session-buffer) - (kill-buffer session-buffer)) - (when (file-directory-p repo-root) - (delete-directory repo-root t)) - (when (buffer-live-p dashboard-buffer) - (kill-buffer dashboard-buffer)))))) - -(provide 'test_ai-code-session-dashboard) - -;;; test_ai-code-session-dashboard.el ends here diff --git a/test/test_ai-code-session.el b/test/test_ai-code-session.el index b379b938..6fa4d2ed 100644 --- a/test/test_ai-code-session.el +++ b/test/test_ai-code-session.el @@ -3,7 +3,7 @@ ;; SPDX-License-Identifier: Apache-2.0 ;;; Commentary: -;; Tests for the lightweight AI session registry. +;; Tests for the AI session registry and dashboard. ;;; Code: @@ -90,6 +90,171 @@ (should-not (ai-code-session-get nil)) (should-not (ai-code-session-get "missing-session-id")))) +(defun ai-code-test-session-dashboard--goto-first-entry () + "Move point to the first dashboard entry." + (goto-char (point-min)) + (re-search-forward "^S[0-9]+" nil t) + (beginning-of-line)) + +(ert-deftest ai-code-test-session-dashboard-renders-mvp-columns () + "Dashboard should render the MVP session information." + (ai-code-test-session--with-clean-registry + (let ((session-buffer (get-buffer-create "*codex[demo]*")) + (repo-root (make-temp-file "ai-code-dashboard-demo-" t)) + dashboard-buffer) + (unwind-protect + (progn + (ai-code-session-register + :buffer session-buffer + :backend "codex" + :repo-root repo-root + :task-file "/tmp/demo-repo/.ai.code.files/task_x.org" + :metadata '(:branch "feature/x" :status "running" :dirty-count 3)) + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'mock-process)) + ((symbol-function 'process-live-p) + (lambda (_process) t)) + ((symbol-function 'magit-get-current-branch) + (lambda () "feature/x")) + ((symbol-function 'magit-git-lines) + (lambda (&rest _args) '("a" "b" "c"))) + ((symbol-function 'pop-to-buffer) + (lambda (buffer &rest _args) + (setq dashboard-buffer buffer) + (get-buffer-window buffer)))) + (ai-code-session-dashboard)) + (with-current-buffer dashboard-buffer + (should (derived-mode-p 'ai-code-session-dashboard-mode)) + (should (equal (length tabulated-list-entries) 1)) + (let ((entry (cadar tabulated-list-entries))) + (should (equal (aref entry 1) + (file-name-nondirectory + (directory-file-name repo-root)))) + (should (equal (aref entry 2) "task_x.org")) + (should (equal (aref entry 3) "Codex")) + (should (equal (aref entry 4) "feature/x")) + (should (equal (aref entry 5) "running")) + (should (equal (aref entry 6) "3"))))) + (when (buffer-live-p session-buffer) + (kill-buffer session-buffer)) + (when (file-directory-p repo-root) + (delete-directory repo-root t)) + (when (buffer-live-p dashboard-buffer) + (kill-buffer dashboard-buffer)))))) + +(ert-deftest ai-code-test-session-dashboard-visit-switches-to-session-buffer () + "RET should jump to the selected session buffer." + (ai-code-test-session--with-clean-registry + (let ((session-buffer (get-buffer-create "*codex[visit]*")) + (repo-root (make-temp-file "ai-code-dashboard-visit-" t)) + dashboard-buffer + visited-buffer) + (unwind-protect + (progn + (ai-code-session-register + :buffer session-buffer + :backend "codex" + :repo-root repo-root + :metadata '(:status "running" :dirty-count 0)) + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'mock-process)) + ((symbol-function 'process-live-p) + (lambda (_process) t)) + ((symbol-function 'pop-to-buffer) + (lambda (buffer &rest _args) + (if (eq buffer session-buffer) + (setq visited-buffer buffer) + (setq dashboard-buffer buffer)) + (get-buffer-window buffer)))) + (ai-code-session-dashboard) + (with-current-buffer dashboard-buffer + (ai-code-test-session-dashboard--goto-first-entry) + (ai-code-session-dashboard-visit)) + (should (eq visited-buffer session-buffer)))) + (when (buffer-live-p session-buffer) + (kill-buffer session-buffer)) + (when (file-directory-p repo-root) + (delete-directory repo-root t)) + (when (buffer-live-p dashboard-buffer) + (kill-buffer dashboard-buffer)))))) + +(ert-deftest ai-code-test-session-dashboard-open-diff-uses-magit-status () + "D should open `magit-status' for the session repository." + (ai-code-test-session--with-clean-registry + (let ((session-buffer (get-buffer-create "*codex[diff]*")) + (repo-root (make-temp-file "ai-code-dashboard-diff-" t)) + dashboard-buffer + opened-repo) + (unwind-protect + (progn + (ai-code-session-register + :buffer session-buffer + :backend "codex" + :repo-root repo-root + :metadata '(:status "running" :dirty-count 0)) + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'mock-process)) + ((symbol-function 'process-live-p) + (lambda (_process) t)) + ((symbol-function 'pop-to-buffer) + (lambda (buffer &rest _args) + (setq dashboard-buffer buffer) + (get-buffer-window buffer))) + ((symbol-function 'magit-status-setup-buffer) + (lambda (directory) + (setq opened-repo directory)))) + (ai-code-session-dashboard) + (with-current-buffer dashboard-buffer + (ai-code-test-session-dashboard--goto-first-entry) + (ai-code-session-dashboard-open-diff)) + (should (equal opened-repo + (file-name-as-directory repo-root))))) + (when (buffer-live-p session-buffer) + (kill-buffer session-buffer)) + (when (file-directory-p repo-root) + (delete-directory repo-root t)) + (when (buffer-live-p dashboard-buffer) + (kill-buffer dashboard-buffer)))))) + +(ert-deftest ai-code-test-session-dashboard-kill-session-removes-registry-entry () + "Killing a running dashboard session should unregister it." + (ai-code-test-session--with-clean-registry + (let ((session-buffer (get-buffer-create "*codex[kill]*")) + (repo-root (make-temp-file "ai-code-dashboard-kill-" t)) + dashboard-buffer + process) + (unwind-protect + (progn + (setq process (start-process "ai-code-dashboard-kill" session-buffer + "sleep" "60")) + (set-process-query-on-exit-flag process nil) + (ai-code-session-register + :buffer session-buffer + :backend "codex" + :repo-root repo-root + :metadata '(:status "running" :dirty-count 0)) + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (&rest _args) t)) + ((symbol-function 'pop-to-buffer) + (lambda (buffer &rest _args) + (setq dashboard-buffer buffer) + (get-buffer-window buffer)))) + (ai-code-session-dashboard) + (with-current-buffer dashboard-buffer + (ai-code-test-session-dashboard--goto-first-entry) + (ai-code-session-dashboard-kill-session)) + (should-not (ai-code-session-list)) + (should-not (buffer-live-p session-buffer)) + (should-not (process-live-p process)))) + (when (and process (process-live-p process)) + (delete-process process)) + (when (buffer-live-p session-buffer) + (kill-buffer session-buffer)) + (when (file-directory-p repo-root) + (delete-directory repo-root t)) + (when (buffer-live-p dashboard-buffer) + (kill-buffer dashboard-buffer)))))) + (provide 'test_ai-code-session) ;;; test_ai-code-session.el ends here From ad7ec1c5dafcc64f9b5ad135f53e96ca7636da55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 00:12:58 +0000 Subject: [PATCH 06/10] Polish merged session tests Agent-Logs-Url: https://github.com/tninja/ai-code-interface.el/sessions/b779b762-86e3-4b01-a210-778c5171230d Co-authored-by: tninja <714625+tninja@users.noreply.github.com> --- test/test_ai-code-session.el | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/test_ai-code-session.el b/test/test_ai-code-session.el index 6fa4d2ed..12941776 100644 --- a/test/test_ai-code-session.el +++ b/test/test_ai-code-session.el @@ -90,7 +90,7 @@ (should-not (ai-code-session-get nil)) (should-not (ai-code-session-get "missing-session-id")))) -(defun ai-code-test-session-dashboard--goto-first-entry () +(defun ai-code-test-session-dashboard-goto-first-entry () "Move point to the first dashboard entry." (goto-char (point-min)) (re-search-forward "^S[0-9]+" nil t) @@ -168,7 +168,7 @@ (get-buffer-window buffer)))) (ai-code-session-dashboard) (with-current-buffer dashboard-buffer - (ai-code-test-session-dashboard--goto-first-entry) + (ai-code-test-session-dashboard-goto-first-entry) (ai-code-session-dashboard-visit)) (should (eq visited-buffer session-buffer)))) (when (buffer-live-p session-buffer) @@ -205,8 +205,8 @@ (setq opened-repo directory)))) (ai-code-session-dashboard) (with-current-buffer dashboard-buffer - (ai-code-test-session-dashboard--goto-first-entry) - (ai-code-session-dashboard-open-diff)) + (ai-code-test-session-dashboard-goto-first-entry) + (ai-code-session-dashboard-open-diff)) (should (equal opened-repo (file-name-as-directory repo-root))))) (when (buffer-live-p session-buffer) @@ -241,8 +241,8 @@ (get-buffer-window buffer)))) (ai-code-session-dashboard) (with-current-buffer dashboard-buffer - (ai-code-test-session-dashboard--goto-first-entry) - (ai-code-session-dashboard-kill-session)) + (ai-code-test-session-dashboard-goto-first-entry) + (ai-code-session-dashboard-kill-session)) (should-not (ai-code-session-list)) (should-not (buffer-live-p session-buffer)) (should-not (process-live-p process)))) From e5df4a5b7d1fe8afab2dd382b9e42295a726f745 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 00:44:36 +0000 Subject: [PATCH 07/10] Add dashboard footer shortcuts Agent-Logs-Url: https://github.com/tninja/ai-code-interface.el/sessions/7c058b64-8fc6-4ad2-bbdb-a9babc44b4ef Co-authored-by: tninja <714625+tninja@users.noreply.github.com> --- ai-code-session.el | 7 +++++++ test/test_ai-code-session.el | 20 +++++++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/ai-code-session.el b/ai-code-session.el index b98c2db5..7d9d7a3a 100644 --- a/ai-code-session.el +++ b/ai-code-session.el @@ -190,6 +190,10 @@ describe the session. ID is optional and mainly useful when restoring state." (defconst ai-code-session-dashboard-buffer-name "*AI Code Sessions*" "Buffer name used by the AI session dashboard.") +(defconst ai-code-session-dashboard-footer + "Keys: RET visit session r/g refresh k kill session D magit status" + "Footer help shown at the bottom of the AI session dashboard.") + (defun ai-code-session-dashboard--repo-name (session) "Return a short repository name for SESSION." (when-let ((repo-root (ai-code-session-repo-root session))) @@ -298,6 +302,9 @@ describe the session. ID is optional and mainly useful when restoring state." ("Status" 12 t) ("Dirty files" 11 t)]) (setq tabulated-list-padding 2) + (setq-local footer-line-format + (propertize ai-code-session-dashboard-footer + 'face 'mode-line-inactive)) (add-hook 'tabulated-list-revert-hook #'ai-code-session-dashboard-refresh nil t) (tabulated-list-init-header)) diff --git a/test/test_ai-code-session.el b/test/test_ai-code-session.el index 12941776..50b2b02f 100644 --- a/test/test_ai-code-session.el +++ b/test/test_ai-code-session.el @@ -11,6 +11,8 @@ (require 'cl-lib) (require 'ai-code-session) +(defvar footer-line-format) + (defmacro ai-code-test-session--with-clean-registry (&rest body) "Run BODY with a fresh session registry." `(let ((ai-code-session--sessions (make-hash-table :test 'equal)) @@ -123,13 +125,17 @@ (setq dashboard-buffer buffer) (get-buffer-window buffer)))) (ai-code-session-dashboard)) - (with-current-buffer dashboard-buffer - (should (derived-mode-p 'ai-code-session-dashboard-mode)) - (should (equal (length tabulated-list-entries) 1)) - (let ((entry (cadar tabulated-list-entries))) - (should (equal (aref entry 1) - (file-name-nondirectory - (directory-file-name repo-root)))) + (with-current-buffer dashboard-buffer + (should (derived-mode-p 'ai-code-session-dashboard-mode)) + (should (equal (length tabulated-list-entries) 1)) + (should (equal (substring-no-properties + (buffer-local-value 'footer-line-format + dashboard-buffer)) + ai-code-session-dashboard-footer)) + (let ((entry (cadar tabulated-list-entries))) + (should (equal (aref entry 1) + (file-name-nondirectory + (directory-file-name repo-root)))) (should (equal (aref entry 2) "task_x.org")) (should (equal (aref entry 3) "Codex")) (should (equal (aref entry 4) "feature/x")) From b6a67e61b7402e47ae26747e9e5898a4bde2eaf9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 00:47:51 +0000 Subject: [PATCH 08/10] Refine dashboard shortcut hints Agent-Logs-Url: https://github.com/tninja/ai-code-interface.el/sessions/7c058b64-8fc6-4ad2-bbdb-a9babc44b4ef Co-authored-by: tninja <714625+tninja@users.noreply.github.com> --- ai-code-session.el | 11 ++++++----- test/test_ai-code-session.el | 12 ++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/ai-code-session.el b/ai-code-session.el index 7d9d7a3a..e0dfb85a 100644 --- a/ai-code-session.el +++ b/ai-code-session.el @@ -190,9 +190,9 @@ describe the session. ID is optional and mainly useful when restoring state." (defconst ai-code-session-dashboard-buffer-name "*AI Code Sessions*" "Buffer name used by the AI session dashboard.") -(defconst ai-code-session-dashboard-footer +(defconst ai-code-session-dashboard-shortcuts-hint "Keys: RET visit session r/g refresh k kill session D magit status" - "Footer help shown at the bottom of the AI session dashboard.") + "Shortcut hint shown in the dashboard mode line.") (defun ai-code-session-dashboard--repo-name (session) "Return a short repository name for SESSION." @@ -302,9 +302,10 @@ describe the session. ID is optional and mainly useful when restoring state." ("Status" 12 t) ("Dirty files" 11 t)]) (setq tabulated-list-padding 2) - (setq-local footer-line-format - (propertize ai-code-session-dashboard-footer - 'face 'mode-line-inactive)) + ;; Keep shortcut hints visible at the bottom of the dashboard window. + (setq-local mode-line-format + (list " " (propertize ai-code-session-dashboard-shortcuts-hint + 'face 'mode-line))) (add-hook 'tabulated-list-revert-hook #'ai-code-session-dashboard-refresh nil t) (tabulated-list-init-header)) diff --git a/test/test_ai-code-session.el b/test/test_ai-code-session.el index 50b2b02f..c869461e 100644 --- a/test/test_ai-code-session.el +++ b/test/test_ai-code-session.el @@ -11,8 +11,6 @@ (require 'cl-lib) (require 'ai-code-session) -(defvar footer-line-format) - (defmacro ai-code-test-session--with-clean-registry (&rest body) "Run BODY with a fresh session registry." `(let ((ai-code-session--sessions (make-hash-table :test 'equal)) @@ -128,10 +126,12 @@ (with-current-buffer dashboard-buffer (should (derived-mode-p 'ai-code-session-dashboard-mode)) (should (equal (length tabulated-list-entries) 1)) - (should (equal (substring-no-properties - (buffer-local-value 'footer-line-format - dashboard-buffer)) - ai-code-session-dashboard-footer)) + (should + (equal + (substring-no-properties + (car (last (buffer-local-value 'mode-line-format + dashboard-buffer)))) + ai-code-session-dashboard-shortcuts-hint)) (let ((entry (cadar tabulated-list-entries))) (should (equal (aref entry 1) (file-name-nondirectory From f34a48b72f8e0b6ef235aa396a6012ceaab01271 Mon Sep 17 00:00:00 2001 From: tninja Date: Sat, 16 May 2026 18:11:08 -0700 Subject: [PATCH 09/10] Insert dashboard footer into session buffer --- ai-code-session.el | 16 ++++++++++++---- ai-code.el | 4 ++-- test/test_ai-code-session.el | 9 ++++----- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/ai-code-session.el b/ai-code-session.el index 7d9d7a3a..65eeb47b 100644 --- a/ai-code-session.el +++ b/ai-code-session.el @@ -194,6 +194,16 @@ describe the session. ID is optional and mainly useful when restoring state." "Keys: RET visit session r/g refresh k kill session D magit status" "Footer help shown at the bottom of the AI session dashboard.") +(defun ai-code-session-dashboard--insert-footer () + "Insert dashboard help below the session list." + (let ((inhibit-read-only t)) + (goto-char (point-max)) + (unless (bolp) + (insert "\n")) + (insert (propertize ai-code-session-dashboard-footer + 'face 'mode-line-inactive)) + (insert "\n"))) + (defun ai-code-session-dashboard--repo-name (session) "Return a short repository name for SESSION." (when-let ((repo-root (ai-code-session-repo-root session))) @@ -242,7 +252,8 @@ describe the session. ID is optional and mainly useful when restoring state." "Refresh the AI session dashboard." (interactive) (setq tabulated-list-entries (ai-code-session-dashboard--entries)) - (tabulated-list-print t)) + (tabulated-list-print t) + (ai-code-session-dashboard--insert-footer)) (defun ai-code-session-dashboard-visit () "Visit the session buffer on the current line." @@ -302,9 +313,6 @@ describe the session. ID is optional and mainly useful when restoring state." ("Status" 12 t) ("Dirty files" 11 t)]) (setq tabulated-list-padding 2) - (setq-local footer-line-format - (propertize ai-code-session-dashboard-footer - 'face 'mode-line-inactive)) (add-hook 'tabulated-list-revert-hook #'ai-code-session-dashboard-refresh nil t) (tabulated-list-init-header)) diff --git a/ai-code.el b/ai-code.el index 2c63adba..d9e74777 100644 --- a/ai-code.el +++ b/ai-code.el @@ -393,8 +393,7 @@ Shows the current backend label to the right." ("S" "(Un)Install skills for backend" ai-code-install-backend-skills) ("g" "Open backend config (eg. add mcp)" ai-code-open-backend-config) ("G" "Open backend repo agent file" ai-code-open-backend-agent-file) - ("l" ai-code-select-terminal :description ai-code--select-terminal-description) - ("|" "Apply prompt on file" ai-code-apply-prompt-on-current-file)) + ("l" ai-code-select-terminal :description ai-code--select-terminal-description)) (transient-define-group ai-code--menu-actions-with-context (ai-code--infix-toggle-suffix) @@ -430,6 +429,7 @@ Shows the current backend label to the right." ("m" "Debug python MCP server" ai-code-debug-mcp) ;; ("N" "Toggle notifications" ai-code-notifications-toggle) ("d" "Debug Emacs runtime" ai-code-debug-emacs-runtime) + ("|" "Apply prompt on file" ai-code-apply-prompt-on-current-file) ("h" "Help / Quick Start" ai-code-onboarding-open-quickstart)) (transient-define-prefix ai-code-menu-default () diff --git a/test/test_ai-code-session.el b/test/test_ai-code-session.el index 50b2b02f..b8dd32d4 100644 --- a/test/test_ai-code-session.el +++ b/test/test_ai-code-session.el @@ -124,14 +124,13 @@ (lambda (buffer &rest _args) (setq dashboard-buffer buffer) (get-buffer-window buffer)))) - (ai-code-session-dashboard)) + (ai-code-session-dashboard)) (with-current-buffer dashboard-buffer (should (derived-mode-p 'ai-code-session-dashboard-mode)) (should (equal (length tabulated-list-entries) 1)) - (should (equal (substring-no-properties - (buffer-local-value 'footer-line-format - dashboard-buffer)) - ai-code-session-dashboard-footer)) + (should (string-match-p + (regexp-quote ai-code-session-dashboard-footer) + (buffer-substring-no-properties (point-min) (point-max)))) (let ((entry (cadar tabulated-list-entries))) (should (equal (aref entry 1) (file-name-nondirectory From 0bb77d8ec1ba546d52bffcf57e73e87a37e86441 Mon Sep 17 00:00:00 2001 From: tninja Date: Sat, 16 May 2026 20:28:16 -0700 Subject: [PATCH 10/10] address feedbacks --- ai-code-session.el | 9 +- test/test_ai-code-backends-infra.el | 132 +++++++++++++++++++++++++++- test/test_ai-code-session.el | 86 +++++++++++++++++- 3 files changed, 219 insertions(+), 8 deletions(-) diff --git a/ai-code-session.el b/ai-code-session.el index 4cb5f6dc..991c7ffd 100644 --- a/ai-code-session.el +++ b/ai-code-session.el @@ -15,7 +15,7 @@ (declare-function magit-get-current-branch "magit-git" ()) (declare-function magit-git-lines "magit-git" (&rest args)) -(declare-function magit-status-setup-buffer "magit-status" (directory)) +(declare-function magit-status "magit-status" (directory)) (cl-defstruct ai-code-session id @@ -253,7 +253,8 @@ describe the session. ID is optional and mainly useful when restoring state." (interactive) (setq tabulated-list-entries (ai-code-session-dashboard--entries)) (tabulated-list-print t) - (ai-code-session-dashboard--insert-footer)) + (save-excursion + (ai-code-session-dashboard--insert-footer))) (defun ai-code-session-dashboard-visit () "Visit the session buffer on the current line." @@ -278,9 +279,9 @@ describe the session. ID is optional and mainly useful when restoring state." (user-error "Session kill canceled")) (when (and process (process-live-p process)) (delete-process process)) + (ai-code-session-unregister (ai-code-session-id session)) (when (buffer-live-p buffer) (kill-buffer buffer)) - (ai-code-session-unregister session) (ai-code-session-dashboard-refresh))) (defun ai-code-session-dashboard-open-diff () @@ -288,7 +289,7 @@ describe the session. ID is optional and mainly useful when restoring state." (interactive) (if-let* ((session (ai-code-session-dashboard--session-at-point)) (repo-root (ai-code-session-repo-root session))) - (magit-status-setup-buffer repo-root) + (magit-status repo-root) (user-error "No repository is associated with this session"))) (defvar ai-code-session-dashboard-mode-map diff --git a/test/test_ai-code-backends-infra.el b/test/test_ai-code-backends-infra.el index b6ebc308..66d930aa 100644 --- a/test/test_ai-code-backends-infra.el +++ b/test/test_ai-code-backends-infra.el @@ -490,11 +490,11 @@ The result is a cons of whether SYMBOL is bound and its default value." (,process (,window) 18 80))))) (should (equal (nreverse rendered) `((,process "old-redraw-1" nil) - (,process "old-redraw-2" nil)))) + (,process "old-redraw-2" nil))))) (when (process-live-p process) (delete-process process)) (when (buffer-live-p buffer) - (kill-buffer buffer))))) + (kill-buffer buffer)))) (ert-deftest test-ai-code-backends-infra-sync-terminal-dimensions-vterm-width-change () "Vterm width changes should go through the native resize handler." @@ -1376,6 +1376,39 @@ The result is a cons of whether SYMBOL is bound and its default value." (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest test-ai-code-backends-infra-reuse-session-window-syncs-session-registry () + "Reusing a hidden session should refresh the shared session registry." + (let* ((working-dir "/tmp/ai-code-reuse-hidden/") + (prefix "codex") + (task-file "/tmp/ai-code-reuse-hidden/.ai.code.files/task.org") + (buffer (get-buffer-create "*codex[reuse-hidden-sync]*")) + (sync-call nil)) + (unwind-protect + (cl-letf (((symbol-function 'get-buffer-window) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--set-session-directory) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--configure-session-buffer) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--remember-session-buffer) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--sync-session-registry) + (lambda (target-buffer directory target-prefix &optional target-task-file) + (setq sync-call + (list target-buffer directory target-prefix target-task-file)))) + ((symbol-function 'ai-code-backends-infra--display-buffer-in-side-window) + (lambda (&rest _args) nil))) + (ai-code-backends-infra--reuse-session-window + buffer + working-dir + prefix + "\\\r\n" + task-file) + (should (equal sync-call + (list buffer working-dir prefix task-file)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + (ert-deftest test-ai-code-backends-infra-resolve-session-target-prefers-explicit-instance () "Explicit INSTANCE-NAME should bypass prompting and produce stable target info." (let* ((working-dir "/tmp/ai-code-session-target/") @@ -1476,6 +1509,23 @@ The result is a cons of whether SYMBOL is bound and its default value." (should-not (get-buffer buf-name)) (ignore buf))) +(ert-deftest test-ai-code-backends-infra-cleanup-session-unregisters-buffer () + "Normal cleanup should unregister the session buffer from the shared registry." + (let* ((table (make-hash-table :test 'equal)) + (dir "/tmp/test-cleanup/") + (buf-name "*test-cleanup-unregister*") + (buf (get-buffer-create buf-name)) + (unregistered nil)) + (unwind-protect + (cl-letf (((symbol-function 'ai-code-session-unregister) + (lambda (target) + (setq unregistered target)))) + (puthash (cons dir "default") t table) + (ai-code-backends-infra--cleanup-session dir buf-name table nil nil "finished\n") + (should (eq unregistered buf))) + (when (buffer-live-p buf) + (kill-buffer buf))))) + (ert-deftest test-ai-code-backends-infra-cleanup-session-preserves-buffer-on-abnormal-exit () "Buffer is preserved when the process exits abnormally." (let* ((table (make-hash-table :test 'equal)) @@ -2453,6 +2503,84 @@ The result is a cons of whether SYMBOL is bound and its default value." (when (buffer-live-p buffer) (kill-buffer buffer))))) +(ert-deftest test-ai-code-backends-infra-finalize-started-session-syncs-session-registry () + "Successful startup finalization should register the session in shared state." + (let* ((working-dir "/tmp/ai-code-finalize-start/") + (prefix "codex") + (buffer-name "*codex[finalize-start-sync]*") + (task-file "/tmp/ai-code-finalize-start/.ai.code.files/task.org") + (buffer (get-buffer-create buffer-name)) + (process 'mock-process) + (sync-call nil)) + (unwind-protect + (cl-letf (((symbol-function 'set-process-sentinel) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--configure-session-buffer) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--remember-session-buffer) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--sync-session-registry) + (lambda (target-buffer directory target-prefix &optional target-task-file) + (setq sync-call + (list target-buffer directory target-prefix target-task-file)))) + ((symbol-function 'ai-code-backends-infra--display-buffer-in-side-window) + (lambda (&rest _args) nil))) + (ai-code-backends-infra--finalize-started-session + buffer + process + working-dir + buffer-name + (make-hash-table :test 'equal) + "default" + prefix + nil + nil + nil + nil + task-file) + (should (equal sync-call + (list buffer working-dir prefix task-file)))) + (when (buffer-live-p buffer) + (kill-buffer buffer))))) + +(ert-deftest test-ai-code-backends-infra-finalize-started-session-kill-buffer-hook-unregisters () + "Successful startup finalization should unregister sessions when buffers die." + (let* ((working-dir "/tmp/ai-code-finalize-kill-hook/") + (prefix "codex") + (buffer-name "*codex[finalize-kill-hook]*") + (buffer (get-buffer-create buffer-name)) + (process 'mock-process) + (unregistered nil)) + (cl-letf (((symbol-function 'set-process-sentinel) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--configure-session-buffer) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--remember-session-buffer) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--sync-session-registry) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-backends-infra--display-buffer-in-side-window) + (lambda (&rest _args) nil)) + ((symbol-function 'ai-code-session-unregister) + (lambda (target) + (setq unregistered target)))) + (ai-code-backends-infra--finalize-started-session + buffer + process + working-dir + buffer-name + (make-hash-table :test 'equal) + "default" + prefix + nil + nil + nil + nil) + (kill-buffer buffer) + (should (eq unregistered buffer))) + (when (buffer-live-p buffer) + (kill-buffer buffer)))) + (ert-deftest test-ai-code-backends-infra-finalize-started-session-chains-ghostel-sentinel () "Successful startup finalization should run Ghostel's native sentinel first." (let* ((working-dir "/tmp/ai-code-finalize-ghostel-sentinel/") diff --git a/test/test_ai-code-session.el b/test/test_ai-code-session.el index 94fe5421..8947caae 100644 --- a/test/test_ai-code-session.el +++ b/test/test_ai-code-session.el @@ -184,6 +184,42 @@ (when (buffer-live-p dashboard-buffer) (kill-buffer dashboard-buffer)))))) +(ert-deftest ai-code-test-session-dashboard-refresh-preserves-selected-entry () + "Refreshing should keep point on the selected dashboard entry." + (ai-code-test-session--with-clean-registry + (let ((session-buffer (get-buffer-create "*codex[refresh-point]*")) + (repo-root (make-temp-file "ai-code-dashboard-refresh-point-" t)) + dashboard-buffer + session-id) + (unwind-protect + (progn + (setq session-id + (ai-code-session-id + (ai-code-session-register + :buffer session-buffer + :backend "codex" + :repo-root repo-root + :metadata '(:status "running" :dirty-count 0)))) + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'mock-process)) + ((symbol-function 'process-live-p) + (lambda (_process) t)) + ((symbol-function 'pop-to-buffer) + (lambda (buffer &rest _args) + (setq dashboard-buffer buffer) + (get-buffer-window buffer)))) + (ai-code-session-dashboard) + (with-current-buffer dashboard-buffer + (ai-code-test-session-dashboard-goto-first-entry) + (ai-code-session-dashboard-refresh) + (should (equal (tabulated-list-get-id) session-id))))) + (when (buffer-live-p session-buffer) + (kill-buffer session-buffer)) + (when (file-directory-p repo-root) + (delete-directory repo-root t)) + (when (buffer-live-p dashboard-buffer) + (kill-buffer dashboard-buffer)))))) + (ert-deftest ai-code-test-session-dashboard-open-diff-uses-magit-status () "D should open `magit-status' for the session repository." (ai-code-test-session--with-clean-registry @@ -206,9 +242,12 @@ (lambda (buffer &rest _args) (setq dashboard-buffer buffer) (get-buffer-window buffer))) - ((symbol-function 'magit-status-setup-buffer) + ((symbol-function 'magit-status) (lambda (directory) - (setq opened-repo directory)))) + (setq opened-repo directory))) + ((symbol-function 'magit-status-setup-buffer) + (lambda (&rest _args) + (ert-fail "dashboard should use public `magit-status'")))) (ai-code-session-dashboard) (with-current-buffer dashboard-buffer (ai-code-test-session-dashboard-goto-first-entry) @@ -222,6 +261,49 @@ (when (buffer-live-p dashboard-buffer) (kill-buffer dashboard-buffer)))))) +(ert-deftest ai-code-test-session-dashboard-kill-session-unregisters-live-buffer () + "Killing a session should unregister it even when the buffer stays live." + (ai-code-test-session--with-clean-registry + (let ((session-buffer (get-buffer-create "*codex[kill-live-buffer]*")) + (repo-root (make-temp-file "ai-code-dashboard-kill-live-buffer-" t)) + dashboard-buffer + process) + (unwind-protect + (progn + (setq process (start-process "ai-code-dashboard-kill-live-buffer" + session-buffer + "sleep" + "60")) + (set-process-query-on-exit-flag process nil) + (ai-code-session-register + :buffer session-buffer + :backend "codex" + :repo-root repo-root + :metadata '(:status "running" :dirty-count 0)) + (cl-letf (((symbol-function 'y-or-n-p) + (lambda (&rest _args) t)) + ((symbol-function 'kill-buffer) + (lambda (&rest _args) nil)) + ((symbol-function 'pop-to-buffer) + (lambda (buffer &rest _args) + (setq dashboard-buffer buffer) + (get-buffer-window buffer)))) + (ai-code-session-dashboard) + (with-current-buffer dashboard-buffer + (ai-code-test-session-dashboard-goto-first-entry) + (ai-code-session-dashboard-kill-session)) + (should (buffer-live-p session-buffer)) + (should-not (ai-code-session-get session-buffer)) + (should-not (process-live-p process)))) + (when (and process (process-live-p process)) + (delete-process process)) + (when (buffer-live-p session-buffer) + (kill-buffer session-buffer)) + (when (file-directory-p repo-root) + (delete-directory repo-root t)) + (when (buffer-live-p dashboard-buffer) + (kill-buffer dashboard-buffer)))))) + (ert-deftest ai-code-test-session-dashboard-kill-session-removes-registry-entry () "Killing a running dashboard session should unregister it." (ai-code-test-session--with-clean-registry