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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 117 additions & 73 deletions pi-coding-agent-menu.el
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,36 @@ from either chat or input buffer."
(message "Pi: New session started"))
(message "Pi: New session cancelled")))))))

(defun pi-coding-agent--session-dir-name (dir)
"Convert DIR to session directory name.
Matches pi's encoding: --path-with-dashes--.
Note: Handles both Unix and Windows path separators."
(let* ((clean-dir (directory-file-name dir)) ; Remove trailing slash
(safe-path (replace-regexp-in-string "[/\\\\:]" "-"
(replace-regexp-in-string "^[/\\\\]" "" clean-dir))))
(concat "--" safe-path "--")))
(defun pi-coding-agent--session-list-directory (&optional chat-buf)
"Return the directory containing CHAT-BUF's current JSONL session file.
Return nil when the current state has no usable session file. Relative
session file names are resolved from the chat buffer's stable session
directory."
(let ((chat-buf (or chat-buf (pi-coding-agent--get-chat-buffer))))
(when (and chat-buf (buffer-live-p chat-buf))
(with-current-buffer chat-buf
(when-let* ((session-file (plist-get pi-coding-agent--state
:session-file))
((stringp session-file))
((not (string-empty-p session-file))))
(file-name-directory
(expand-file-name session-file
(pi-coding-agent--chat-session-directory
chat-buf))))))))

(defun pi-coding-agent--with-session-list-directory (proc chat-buf callback)
"Call CALLBACK with the session list directory for CHAT-BUF.
When cached state has no session file, fetch fresh state from PROC first."
(if-let* ((session-dir (pi-coding-agent--session-list-directory chat-buf)))
(funcall callback session-dir)
(pi-coding-agent--rpc-async proc '(:type "get_state")
(lambda (response)
(when (and (plist-get response :success)
(buffer-live-p chat-buf))
(pi-coding-agent--apply-state-response chat-buf response))
(when (buffer-live-p chat-buf)
(funcall callback
(pi-coding-agent--session-list-directory chat-buf)))))))

(defun pi-coding-agent--session-metadata (path)
"Extract metadata from session file PATH.
Expand Down Expand Up @@ -251,43 +273,43 @@ Call this from the chat buffer after switching or loading a session."
(let ((metadata (pi-coding-agent--session-metadata session-file)))
(setq pi-coding-agent--session-name (plist-get metadata :session-name)))))

(defun pi-coding-agent--list-sessions (dir)
"List available session files for project DIR.
Returns list of absolute paths to .jsonl files, sorted by modification
time with most recently used first."
(let* ((sessions-base (expand-file-name "~/.pi/agent/sessions/"))
(session-dir (expand-file-name (pi-coding-agent--session-dir-name dir) sessions-base)))
(when (file-directory-p session-dir)
;; Sort by modification time descending (most recently used first)
(sort (directory-files session-dir t "\\.jsonl$")
(defun pi-coding-agent--list-sessions (session-dir)
"List valid session files in SESSION-DIR.
Returns absolute paths to JSONL pi sessions, sorted by modification time with
most recently used first."
(when (and session-dir (file-directory-p session-dir))
(let ((sessions (delq nil
(mapcar (lambda (path)
(and (pi-coding-agent--session-metadata path)
path))
(directory-files session-dir t
"\\.jsonl\\'")))))
(sort sessions
(lambda (a b)
(time-less-p (file-attribute-modification-time (file-attributes b))
(file-attribute-modification-time (file-attributes a))))))))
(time-less-p (file-attribute-modification-time
(file-attributes b))
(file-attribute-modification-time
(file-attributes a))))))))

(defun pi-coding-agent--format-session-choice (path)
"Format session PATH for display in selector.
Returns (display-string . path) for `completing-read'.
Prefers session name over first message when available."
(let ((metadata (pi-coding-agent--session-metadata path)))
(if metadata
(let* ((modified-time (plist-get metadata :modified-time))
(session-name (plist-get metadata :session-name))
(first-msg (plist-get metadata :first-message))
(msg-count (plist-get metadata :message-count))
(relative-time (pi-coding-agent--format-relative-time modified-time))
;; Prefer session name, fall back to first message preview
(label (cond
(session-name (pi-coding-agent--truncate-string session-name 50))
(first-msg (pi-coding-agent--truncate-string first-msg 50))
(t nil)))
(display (if label
(format "%s · %s (%d msgs)"
label relative-time msg-count)
(format "[empty session] · %s" relative-time))))
(cons display path))
;; Fallback to filename if metadata extraction fails
(let ((filename (file-name-nondirectory path)))
(cons filename path)))))
Returns (display-string . path) for `completing-read', or nil when PATH is not
a valid pi session. Prefers session name over first message when available."
(when-let* ((metadata (pi-coding-agent--session-metadata path)))
(let* ((modified-time (plist-get metadata :modified-time))
(session-name (plist-get metadata :session-name))
(first-msg (plist-get metadata :first-message))
(msg-count (plist-get metadata :message-count))
(relative-time (pi-coding-agent--format-relative-time modified-time))
(label (cond
(session-name (pi-coding-agent--truncate-string session-name 50))
(first-msg (pi-coding-agent--truncate-string first-msg 50))
(t nil)))
(display (if label
(format "%s · %s (%d msgs)"
label relative-time msg-count)
(format "[empty session] · %s" relative-time))))
(cons display path))))

(defun pi-coding-agent--reset-session-state ()
"Reset all session-specific state for a new session.
Expand Down Expand Up @@ -465,43 +487,65 @@ chat buffer from session history."
(message "Pi: Failed to reload - %s"
(or (plist-get response :error) "unknown error"))))))))))))

(defun pi-coding-agent--resume-selected-session (proc chat-buf selected-path)
"Resume SELECTED-PATH using PROC and rebuild CHAT-BUF from its history."
(pi-coding-agent--rpc-async
proc
(list :type "switch_session" :sessionPath selected-path)
(lambda (response)
(let* ((data (plist-get response :data))
(cancelled (plist-get data :cancelled)))
(if (and (plist-get response :success)
(pi-coding-agent--json-false-p cancelled))
(progn
(pi-coding-agent--refresh-session-state proc chat-buf selected-path)
(pi-coding-agent--load-session-history
proc
(lambda (count)
(message "Pi: Resumed session (%d messages)" count))
chat-buf))
(message "Pi: Failed to resume session"))))))

(defun pi-coding-agent--resume-session-from-directory (proc chat-buf session-dir)
"Prompt for a session from SESSION-DIR, then resume it using PROC.
CHAT-BUF is rebuilt from the selected session history."
(let ((sessions (pi-coding-agent--list-sessions session-dir)))
(if (null sessions)
(message "Pi: No previous sessions found")
(let* ((choices (delq nil
(mapcar #'pi-coding-agent--format-session-choice
sessions)))
(choice-strings (mapcar #'car choices)))
(if (null choices)
(message "Pi: No previous sessions found")
(let* ((choice (completing-read
"Resume session: "
(lambda (string pred action)
(if (eq action 'metadata)
'(metadata (display-sort-function . identity))
(complete-with-action action choice-strings
string pred)))
nil t))
(selected-path (cdr (assoc choice choices))))
(when selected-path
(pi-coding-agent--resume-selected-session
proc chat-buf selected-path))))))))

(defun pi-coding-agent-resume-session ()
"Resume a previous pi session from the current project."
"Resume a previous pi session stored beside the current session file."
(interactive)
(when-let* ((proc (pi-coding-agent--get-process))
(dir (pi-coding-agent--session-directory))
(chat-buf (pi-coding-agent--get-chat-buffer)))
(when (pi-coding-agent--session-transition-ready-p chat-buf "resume")
(let ((sessions (pi-coding-agent--list-sessions dir)))
(if (null sessions)
(message "Pi: No previous sessions found")
(let* ((choices (mapcar #'pi-coding-agent--format-session-choice sessions))
(choice-strings (mapcar #'car choices))
(choice (completing-read "Resume session: "
(lambda (string pred action)
(if (eq action 'metadata)
'(metadata (display-sort-function . identity))
(complete-with-action action choice-strings string pred)))
nil t))
(selected-path (cdr (assoc choice choices))))
(when selected-path
(pi-coding-agent--rpc-async
proc
(list :type "switch_session" :sessionPath selected-path)
(lambda (response)
(let* ((data (plist-get response :data))
(cancelled (plist-get data :cancelled)))
(if (and (plist-get response :success)
(pi-coding-agent--json-false-p cancelled))
(progn
(pi-coding-agent--refresh-session-state
proc chat-buf selected-path)
(pi-coding-agent--load-session-history
proc
(lambda (count)
(message "Pi: Resumed session (%d messages)" count))
chat-buf))
(message "Pi: Failed to resume session"))))))))))))
(pi-coding-agent--with-session-list-directory
proc chat-buf
(lambda (session-dir)
(cond
((not session-dir)
(message "Pi: Session file not available"))
((pi-coding-agent--session-transition-ready-p chat-buf "resume")
(pi-coding-agent--resume-session-from-directory
proc chat-buf session-dir))))))))

;;;; Model and Thinking

Expand Down
42 changes: 0 additions & 42 deletions test/pi-coding-agent-input-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -1367,48 +1367,6 @@ This ensures history loads correctly when callback runs in arbitrary context."
(when (buffer-live-p chat-buf)
(kill-buffer chat-buf)))))

(ert-deftest pi-coding-agent-test-session-dir-name ()
"Session directory name derived from project path."
(should (equal (pi-coding-agent--session-dir-name "/home/daniel/co/pi-coding-agent")
"--home-daniel-co-pi-coding-agent--"))
(should (equal (pi-coding-agent--session-dir-name "/tmp/test")
"--tmp-test--")))

(ert-deftest pi-coding-agent-test-list-sessions-sorted-by-mtime ()
"Sessions are sorted by modification time, most recent first.
Regression test for #25: sessions were sorted by filename (creation time)
and then re-sorted alphabetically by completing-read."
(let* ((temp-base (make-temp-file "pi-coding-agent-sessions-" t))
(session-dir (expand-file-name "--test-project--" temp-base))
;; Create files with names that would sort differently alphabetically
(old-file (expand-file-name "2024-01-01_10-00-00.jsonl" session-dir))
(new-file (expand-file-name "2024-01-01_09-00-00.jsonl" session-dir)))
(unwind-protect
(progn
(make-directory session-dir t)
(let* ((now (current-time))
(old-time (time-subtract now (seconds-to-time 10)))
(new-time (time-subtract now (seconds-to-time 5))))
;; Create "old" file first
(with-temp-file old-file (insert "{}"))
(set-file-times old-file old-time)
;; Create "new" file second (more recent mtime despite earlier filename)
(with-temp-file new-file (insert "{}"))
(set-file-times new-file new-time))
;; Directly call directory-files and sort logic to test sorting
(let* ((files (directory-files session-dir t "\\.jsonl$"))
(sorted (sort files
(lambda (a b)
(time-less-p
(file-attribute-modification-time (file-attributes b))
(file-attribute-modification-time (file-attributes a)))))))
;; new-file should be first (most recent mtime)
;; even though "09-00-00" < "10-00-00" alphabetically
(should (equal (length sorted) 2))
(should (string-suffix-p "09-00-00.jsonl" (car sorted)))))
;; Cleanup
(delete-directory temp-base t))))

(ert-deftest pi-coding-agent-test-session-metadata-extracts-first-message ()
"pi-coding-agent--session-metadata extracts first user message text."
(let ((temp-file (make-temp-file "pi-coding-agent-test-session" nil ".jsonl")))
Expand Down
Loading
Loading