Skip to content

Commit 8427efa

Browse files
authored
Merge pull request #224 from dnouri/issue-215-open-session-file
Open pi session files directly Thanks @etern
2 parents 9b47621 + ab245d8 commit 8427efa

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)