Skip to content

Commit ab245d8

Browse files
committed
Open pi session files directly
Add M-x pi-coding-agent-open-session-file for opening an existing pi JSONL session as a live chat/input session. The command validates the session header cwd before setup, requires a readable pi session with a non-empty absolute cwd that still names a directory, and fails clearly without guessing or asking for a replacement cwd. Make interactive use convenient without changing plain M-x pi-coding-agent: the file prompt defaults to Dired's regular file at point, otherwise to the current buffer's local readable .jsonl file, while keeping the selected file visible in the minibuffer. Document the behavior and cover validation, resume, Dired, visited-file, and rejection edge cases.
1 parent 9b47621 commit ab245d8

8 files changed

Lines changed: 915 additions & 25 deletions

README.org

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,14 @@ named session.
318318
Resume (=C-c C-r=) and fork (=C-c C-p f=) present a selection menu:
319319
pick from previous sessions or conversation messages to start from.
320320

321+
To open an existing pi JSONL session file directly, run
322+
=M-x pi-coding-agent-open-session-file=. This opens the file as a live pi
323+
session in the normal chat/input UI, not as a static viewer. The session
324+
header must contain a non-empty absolute =cwd= that still names an existing
325+
directory; the command does not guess or ask for a replacement cwd. In Dired,
326+
it defaults the prompt to the regular file at point; otherwise, from a buffer
327+
visiting a local regular readable =.jsonl= file, it defaults to that file.
328+
321329
You can save the chat buffer like any other buffer to keep a Markdown
322330
transcript on disk. Saving does not interrupt or replace the live pi session.
323331

pi-coding-agent-menu.el

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,9 @@ When cached state has no session file, fetch fresh state from PROC first."
222222

223223
(defun pi-coding-agent--session-metadata (path)
224224
"Extract metadata from session file PATH.
225-
Returns plist with :modified-time, :first-message, :message-count, and
226-
:session-name, or nil on error. Session name comes from the most recent
227-
session_info entry if present."
225+
Returns plist with :modified-time, :first-message, :message-count,
226+
:session-name, and :cwd, or nil on error. Session name comes from the
227+
most recent session_info entry if present."
228228
(condition-case nil
229229
(let* ((attrs (file-attributes path))
230230
(modified-time (file-attribute-modification-time attrs)))
@@ -233,17 +233,21 @@ session_info entry if present."
233233
(let ((first-message nil)
234234
(message-count 0)
235235
(session-name nil)
236+
(session-cwd nil)
236237
(has-session-header nil))
237238
(goto-char (point-min))
238-
;; Scan lines to find session header, first message, count messages, and session name
239+
;; Scan lines to find session header cwd, first message, count, and session name
239240
(while (not (eobp))
240241
(let* ((line (buffer-substring-no-properties
241242
(point) (line-end-position))))
242243
(when (and line (not (string-empty-p line)))
243244
(let* ((data (json-parse-string line :object-type 'plist))
244245
(type (plist-get data :type)))
245246
(when (equal type "session")
246-
(setq has-session-header t))
247+
(setq has-session-header t
248+
session-cwd
249+
(pi-coding-agent--normalize-string-or-null
250+
(plist-get data :cwd))))
247251
(when (equal type "message")
248252
(setq message-count (1+ message-count))
249253
;; Extract text from first message only
@@ -263,9 +267,33 @@ session_info entry if present."
263267
(list :modified-time modified-time
264268
:first-message first-message
265269
:message-count message-count
266-
:session-name session-name)))))
270+
:session-name session-name
271+
:cwd session-cwd)))))
267272
(error nil)))
268273

274+
(defun pi-coding-agent--session-file-cwd-or-error (path)
275+
"Return the recorded cwd from session file PATH, or signal `user-error'.
276+
The returned directory is expanded and has a trailing slash. PATH must be a
277+
readable pi session file whose session header contains a non-empty absolute cwd
278+
that names an existing directory."
279+
(let ((session-file (expand-file-name path)))
280+
(unless (file-readable-p session-file)
281+
(user-error "Session file is not readable: %s" session-file))
282+
(let ((metadata (pi-coding-agent--session-metadata session-file)))
283+
(unless metadata
284+
(user-error "Not a pi session file: %s" session-file))
285+
(let ((cwd (plist-get metadata :cwd)))
286+
(unless (and (stringp cwd) (not (string-empty-p cwd)))
287+
(user-error "Session file has no usable cwd: %s" session-file))
288+
(unless (file-name-absolute-p cwd)
289+
(user-error "Session file cwd is not absolute: %s\nSession file: %s"
290+
cwd session-file))
291+
(let ((expanded-cwd (file-name-as-directory (expand-file-name cwd))))
292+
(unless (file-directory-p expanded-cwd)
293+
(user-error "Stored session cwd is not an existing directory: %s\nSession file: %s"
294+
expanded-cwd session-file))
295+
expanded-cwd)))))
296+
269297
(defun pi-coding-agent--update-session-name-from-file (session-file)
270298
"Update `pi-coding-agent--session-name' from SESSION-FILE metadata.
271299
Call this from the chat buffer after switching or loading a session."

pi-coding-agent.el

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@
4141
;; loading it does not change global Markdown file associations.
4242
;;
4343
;; Usage:
44-
;; M-x pi-coding-agent Start or focus session in current project
45-
;; C-u M-x pi-coding-agent Start a named session
46-
;; M-x pi-coding-agent-toggle Hide/show session windows in current frame
44+
;; M-x pi-coding-agent Start or focus session in current project
45+
;; C-u M-x pi-coding-agent Start a named session
46+
;; M-x pi-coding-agent-open-session-file Open a JSONL session file as live session
47+
;; M-x pi-coding-agent-toggle Hide/show session windows in current frame
4748
;;
4849
;; Many users define an alias: (defalias 'pi 'pi-coding-agent)
4950
;;
@@ -83,6 +84,8 @@
8384
(require 'pi-coding-agent-menu)
8485
(require 'pi-coding-agent-input)
8586

87+
(declare-function dired-get-filename "dired" (&optional localp no-error-if-not-filep))
88+
8689
;;;; Main Entry Point
8790

8891
(defun pi-coding-agent--setup-session (dir &optional session)
@@ -138,6 +141,57 @@ Returns the chat buffer."
138141
(pi-coding-agent--set-chat-buffer chat-buf))
139142
chat-buf))
140143

144+
(defun pi-coding-agent--show-session-buffers (chat-buf input-buf)
145+
"Show CHAT-BUF and INPUT-BUF, focusing input when both are visible."
146+
(if (and (get-buffer-window-list chat-buf nil)
147+
(get-buffer-window-list input-buf nil))
148+
(pi-coding-agent--focus-input-window chat-buf input-buf)
149+
(pi-coding-agent--display-buffers chat-buf input-buf)))
150+
151+
(defun pi-coding-agent--dired-regular-file-at-point ()
152+
"Return Dired's regular file at point, or nil."
153+
(when (derived-mode-p 'dired-mode)
154+
(when-let* ((file (dired-get-filename nil t)))
155+
(and (file-regular-p file)
156+
(expand-file-name file)))))
157+
158+
(defun pi-coding-agent--regular-jsonl-file-p (file)
159+
"Return non-nil if FILE is a cheap local JSONL file candidate."
160+
(when (stringp file)
161+
(let ((path (expand-file-name file)))
162+
(and (string-suffix-p ".jsonl" path)
163+
(not (file-remote-p path))
164+
(ignore-errors
165+
(and (file-regular-p path)
166+
(file-readable-p path)))))))
167+
168+
(defun pi-coding-agent--visited-jsonl-file-prompt-default ()
169+
"Return the current buffer's visited JSONL file for the prompt, or nil."
170+
(when-let* ((file buffer-file-name)
171+
(path (expand-file-name file)))
172+
(and (pi-coding-agent--regular-jsonl-file-p path)
173+
path)))
174+
175+
(defun pi-coding-agent--session-file-prompt-default ()
176+
"Return an explicit default file for the session-file prompt, or nil."
177+
(if (derived-mode-p 'dired-mode)
178+
(pi-coding-agent--dired-regular-file-at-point)
179+
(pi-coding-agent--visited-jsonl-file-prompt-default)))
180+
181+
(defun pi-coding-agent--read-session-file-name ()
182+
"Read an existing pi session file name from the minibuffer."
183+
(let* ((default-file (pi-coding-agent--session-file-prompt-default))
184+
(default-dir (and default-file (file-name-directory default-file)))
185+
(initial (and default-file (file-name-nondirectory default-file)))
186+
;; `read-file-name' otherwise uses the current buffer's visited file
187+
;; as a hidden default when DEFAULT-FILENAME and INITIAL are nil.
188+
(buffer-file-name nil))
189+
(read-file-name "Pi session file: "
190+
default-dir
191+
default-file
192+
t
193+
initial)))
194+
141195
;;;###autoload
142196
(defun pi-coding-agent (&optional session)
143197
"Start or switch to pi coding agent session in current project.
@@ -159,12 +213,28 @@ frame, keeps layout unchanged and focuses the input window."
159213
(let ((dir (pi-coding-agent--session-directory)))
160214
(setq chat-buf (pi-coding-agent--setup-session dir session))
161215
(setq input-buf (buffer-local-value 'pi-coding-agent--input-buffer chat-buf))))
162-
;; When both windows are already visible in current frame, just focus
163-
;; the session input window. Otherwise restore/show the session layout.
164-
(if (and (get-buffer-window-list chat-buf nil)
165-
(get-buffer-window-list input-buf nil))
166-
(pi-coding-agent--focus-input-window chat-buf input-buf)
167-
(pi-coding-agent--display-buffers chat-buf input-buf))))
216+
(pi-coding-agent--show-session-buffers chat-buf input-buf)))
217+
218+
;;;###autoload
219+
(defun pi-coding-agent-open-session-file (session-file)
220+
"Open pi JSONL SESSION-FILE as a live session.
221+
This uses the normal chat/input UI and switches pi to SESSION-FILE; it is not a
222+
static viewer. The session header must record a non-empty absolute cwd that
223+
names an existing directory. Interactively, prompt for an existing file. In
224+
Dired, default to the regular file at point; otherwise, default to the current
225+
visited local regular readable .jsonl file when there is one."
226+
(interactive (list (pi-coding-agent--read-session-file-name)))
227+
(let* ((session-file (expand-file-name session-file))
228+
(dir (pi-coding-agent--session-file-cwd-or-error session-file)))
229+
(pi-coding-agent--check-dependencies)
230+
(let* ((chat-buf (pi-coding-agent--setup-session dir))
231+
(input-buf (buffer-local-value 'pi-coding-agent--input-buffer
232+
chat-buf))
233+
(proc (buffer-local-value 'pi-coding-agent--process chat-buf)))
234+
(pi-coding-agent--show-session-buffers chat-buf input-buf)
235+
(when (pi-coding-agent--session-transition-ready-p chat-buf "open")
236+
(pi-coding-agent--resume-selected-session proc chat-buf session-file))
237+
chat-buf)))
168238

169239
;;;###autoload
170240
(defun pi-coding-agent-toggle ()

test/pi-coding-agent-input-test.el

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2137,6 +2137,28 @@ This ensures history loads correctly when callback runs in arbitrary context."
21372137
(should (equal (plist-get metadata :message-count) 2))))
21382138
(delete-file temp-file))))
21392139

2140+
(ert-deftest pi-coding-agent-test-session-metadata-extracts-cwd ()
2141+
"pi-coding-agent--session-metadata extracts cwd from the session header."
2142+
(let ((temp-file (make-temp-file "pi-coding-agent-test-session" nil ".jsonl"))
2143+
(project-dir (pi-coding-agent-test--make-temp-directory
2144+
"pi-coding-agent-test-project-")))
2145+
(unwind-protect
2146+
(let ((cwd (directory-file-name project-dir)))
2147+
(with-temp-file temp-file
2148+
(insert (json-encode `(:type "session" :id "test" :cwd ,cwd)) "\n")
2149+
(insert (json-encode '(:type "message"
2150+
:message (:role "user"
2151+
:content [(:type "text"
2152+
:text "Hello")]))))
2153+
(insert "\n"))
2154+
(let ((metadata (pi-coding-agent--session-metadata temp-file)))
2155+
(should metadata)
2156+
(should (equal (plist-get metadata :cwd) cwd))
2157+
(should (equal (plist-get metadata :first-message) "Hello"))
2158+
(should (equal (plist-get metadata :message-count) 1))))
2159+
(delete-file temp-file)
2160+
(delete-directory project-dir t))))
2161+
21402162
(ert-deftest pi-coding-agent-test-session-metadata-returns-nil-for-empty-file ()
21412163
"pi-coding-agent--session-metadata returns nil for empty or invalid files."
21422164
(let ((temp-file (make-temp-file "pi-coding-agent-test-session" nil ".jsonl")))

test/pi-coding-agent-menu-test.el

Lines changed: 98 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,15 +1273,104 @@ replaced by the resumed or forked history."
12731273
(overlay-get ov 'pi-coding-agent-tool-block))
12741274
(overlays-in (point-min) (point-max))))))
12751275

1276-
(defun pi-coding-agent-test--write-session-file (path &optional text)
1277-
"Write a minimal pi session file to PATH with optional first message TEXT."
1278-
(with-temp-file path
1279-
(insert (json-encode '(:type "session" :id "test")) "\n")
1280-
(when text
1281-
(insert (json-encode `(:type "message"
1282-
:message (:role "user"
1283-
:content [(:type "text" :text ,text)])))
1284-
"\n"))))
1276+
(ert-deftest pi-coding-agent-test-session-file-cwd-or-error-returns-expanded-directory ()
1277+
"Session-file cwd validator returns an expanded directory name."
1278+
(let* ((project-dir (pi-coding-agent-test--make-temp-directory
1279+
"pi-coding-agent-test-project-"))
1280+
(session-dir (pi-coding-agent-test--make-temp-directory
1281+
"pi-coding-agent-test-sessions-"))
1282+
(session-file (expand-file-name "session.jsonl" session-dir)))
1283+
(unwind-protect
1284+
(let ((cwd (directory-file-name project-dir)))
1285+
(pi-coding-agent-test--write-session-file session-file "hello" cwd)
1286+
(should (equal (pi-coding-agent--session-file-cwd-or-error
1287+
session-file)
1288+
project-dir)))
1289+
(delete-directory project-dir t)
1290+
(delete-directory session-dir t))))
1291+
1292+
(ert-deftest pi-coding-agent-test-session-file-cwd-or-error-rejects-unreadable-file ()
1293+
"Session-file cwd validator rejects unreadable files."
1294+
(let* ((session-dir (pi-coding-agent-test--make-temp-directory
1295+
"pi-coding-agent-test-sessions-"))
1296+
(missing-file (expand-file-name "missing.jsonl" session-dir)))
1297+
(unwind-protect
1298+
(should-error (pi-coding-agent--session-file-cwd-or-error missing-file)
1299+
:type 'user-error)
1300+
(delete-directory session-dir t))))
1301+
1302+
(ert-deftest pi-coding-agent-test-session-file-cwd-or-error-rejects-invalid-session-metadata ()
1303+
"Session-file cwd validator rejects files without valid session metadata."
1304+
(let* ((session-dir (pi-coding-agent-test--make-temp-directory
1305+
"pi-coding-agent-test-sessions-"))
1306+
(session-file (expand-file-name "not-a-session.jsonl" session-dir)))
1307+
(unwind-protect
1308+
(progn
1309+
(with-temp-file session-file
1310+
(insert "{\"type\":\"message\"}\n"))
1311+
(should-error (pi-coding-agent--session-file-cwd-or-error session-file)
1312+
:type 'user-error))
1313+
(delete-directory session-dir t))))
1314+
1315+
(ert-deftest pi-coding-agent-test-session-file-cwd-or-error-rejects-unusable-cwd ()
1316+
"Session-file cwd validator rejects missing, non-string, and empty cwd values."
1317+
(let* ((session-dir (pi-coding-agent-test--make-temp-directory
1318+
"pi-coding-agent-test-sessions-"))
1319+
(cases '(("missing" . "{\"type\":\"session\",\"id\":\"test\"}")
1320+
("null" . "{\"type\":\"session\",\"id\":\"test\",\"cwd\":null}")
1321+
("number" . "{\"type\":\"session\",\"id\":\"test\",\"cwd\":123}")
1322+
("empty" . "{\"type\":\"session\",\"id\":\"test\",\"cwd\":\"\"}"))))
1323+
(unwind-protect
1324+
(dolist (case cases)
1325+
(let ((session-file (expand-file-name
1326+
(format "%s.jsonl" (car case))
1327+
session-dir)))
1328+
(with-temp-file session-file
1329+
(insert (cdr case) "\n"))
1330+
(ert-info ((format "cwd case: %s" (car case)))
1331+
(should-error (pi-coding-agent--session-file-cwd-or-error
1332+
session-file)
1333+
:type 'user-error))))
1334+
(delete-directory session-dir t))))
1335+
1336+
(ert-deftest pi-coding-agent-test-session-file-cwd-or-error-rejects-relative-cwd ()
1337+
"Session-file cwd validator rejects cwd values that depend on default-directory."
1338+
(let* ((session-dir (pi-coding-agent-test--make-temp-directory
1339+
"pi-coding-agent-test-sessions-"))
1340+
(session-file (expand-file-name "relative.jsonl" session-dir))
1341+
(relative-cwd "relative-project"))
1342+
(unwind-protect
1343+
(progn
1344+
(make-directory (expand-file-name relative-cwd session-dir))
1345+
(pi-coding-agent-test--write-session-file
1346+
session-file "hello" relative-cwd)
1347+
(let* ((default-directory session-dir)
1348+
(error-data
1349+
(should-error (pi-coding-agent--session-file-cwd-or-error
1350+
session-file)
1351+
:type 'user-error))
1352+
(message (cadr error-data)))
1353+
(should (string-match-p (regexp-quote relative-cwd) message))
1354+
(should (string-match-p (regexp-quote session-file) message))))
1355+
(delete-directory session-dir t))))
1356+
1357+
(ert-deftest pi-coding-agent-test-session-file-cwd-or-error-rejects-stale-cwd ()
1358+
"Session-file cwd validator rejects cwd values that do not name a directory."
1359+
(let* ((session-dir (pi-coding-agent-test--make-temp-directory
1360+
"pi-coding-agent-test-sessions-"))
1361+
(session-file (expand-file-name "stale.jsonl" session-dir))
1362+
(stale-cwd (expand-file-name "deleted-project" session-dir)))
1363+
(unwind-protect
1364+
(progn
1365+
(pi-coding-agent-test--write-session-file session-file "hello" stale-cwd)
1366+
(let* ((error-data
1367+
(should-error (pi-coding-agent--session-file-cwd-or-error
1368+
session-file)
1369+
:type 'user-error))
1370+
(message (cadr error-data)))
1371+
(should (string-match-p (regexp-quote stale-cwd) message))
1372+
(should (string-match-p (regexp-quote session-file) message))))
1373+
(delete-directory session-dir t))))
12851374

12861375
(ert-deftest pi-coding-agent-test-session-list-directory-uses-session-file-parent ()
12871376
"Session listing uses the current JSONL session file parent directory."

test/pi-coding-agent-render-test.el

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6110,7 +6110,7 @@ events where the header text hasn't changed."
61106110
"Dispatching /name without arg calls handler interactively."
61116111
(let (interactive-called)
61126112
(cl-letf (((symbol-function 'call-interactively)
6113-
(lambda (fn) (setq interactive-called fn))))
6113+
(lambda (fn &rest _args) (setq interactive-called fn))))
61146114
(should (pi-coding-agent--dispatch-builtin-command "/name"))
61156115
(should (eq interactive-called 'pi-coding-agent-set-session-name)))))
61166116

test/pi-coding-agent-test-common.el

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
(require 'cl-lib) ; for cl-letf in mock-session macro
1212
(require 'ert)
13+
(require 'json)
1314

1415
;;; Timeout Configuration
1516

@@ -271,6 +272,20 @@ PREFIX is forwarded to `make-temp-file'. The returned path always has a
271272
trailing slash so it behaves like `default-directory'."
272273
(file-name-as-directory (make-temp-file prefix t)))
273274

275+
(defun pi-coding-agent-test--write-session-file (path &optional text cwd)
276+
"Write a minimal pi session file to PATH.
277+
When TEXT is non-nil, include it as the first user message. When CWD is
278+
non-nil, include it in the session header."
279+
(with-temp-file path
280+
(insert (json-encode `(:type "session" :id "test"
281+
,@(when cwd (list :cwd cwd))))
282+
"\n")
283+
(when text
284+
(insert (json-encode `(:type "message"
285+
:message (:role "user"
286+
:content [(:type "text" :text ,text)])))
287+
"\n"))))
288+
274289
(defun pi-coding-agent-test--write-chat-buffer (chat prefix &optional appended-text)
275290
"Save CHAT to a temp markdown file and return the file name.
276291
PREFIX is forwarded to `make-temp-file'. When APPENDED-TEXT is non-nil,

0 commit comments

Comments
 (0)