Skip to content

Commit f5af70c

Browse files
authored
feat: fork from point in chat buffer (f key) (#124)
Press f on any user turn in the chat buffer to fork the conversation from that point. Uses the get_tree RPC to map visible buffer position to entry ID, handling compaction (compacted-away turns aren't rendered, so the last-N visible headings are aligned to the active branch). Also fixes chat navigation (n/p): the old pattern searched for the never-present "You:" instead of setext headings, and now recenters the heading to the top of the window. Changes: - Setext heading detection: regex + underline predicate, shared across navigation and fork-at-point - Turn detection: collect headings, map point to 0-based turn index - Tree helpers: flatten-tree (hash index) and active-branch-user-ids (leaf-to-root walk) - execute-fork: extracted shared fork logic (RPC + state refresh + history reload + input prefill), used by both fork-at-point and the menu fork selector - resolve-fork-entry: tree-to-entryId mapping, independently testable - build-tree test helper: flat specs replace deeply nested tree construction in tests 49 new tests covering heading detection, navigation, turn indexing, tree walk scenarios, and fork-at-point integration.
1 parent 17536e4 commit f5af70c

6 files changed

Lines changed: 686 additions & 35 deletions

README.org

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ An Emacs frontend for the [[https://shittycodingagent.ai/][pi coding agent]].
1515

1616
- Compose prompts in a full Emacs buffer: multi-line, copy/paste, macros, support for Vi bindings
1717
- Chat history as a markdown buffer: copy, save, search, navigate
18+
- Fork the converstaion at any point in the chat buffer (f)
1819
- Live streaming output as bash commands and tool operations run
1920
- Syntax-highlighted code blocks and diffs
2021
- Collapsible tool output with smart preview (expand with TAB)
@@ -120,6 +121,7 @@ For multiple sessions in the same directory, use =C-u M-x pi= to create a named
120121
| =TAB= | chat | Toggle section |
121122
| =S-TAB= | chat | Cycle all folds |
122123
| =RET= | chat | Visit file at point (other window)|
124+
| =f= | chat | Fork from point |
123125
| =q= | chat | Quit session |
124126

125127
Press =C-c C-p= to access the full menu with model selection, thinking level,
@@ -185,8 +187,9 @@ When a conversation gets long, the AI's context window fills up. The menu
185187
preserving key information. Use when context is filling up. If you don't
186188
compact manually, pi does it automatically when needed.
187189

188-
- *Fork* (=f=): Creates a new session starting from a previous message.
189-
Go back to any point and take the conversation in a new direction.
190+
- *Fork* (=f=): Branches the conversation from any earlier turn.
191+
Press =f= on any turn in the chat buffer, or use the menu to pick
192+
from a list.
190193

191194
- *Export* (=e=): Saves the conversation as an HTML file for sharing or
192195
archiving.

pi-coding-agent-menu.el

Lines changed: 115 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,38 @@ Optional CUSTOM-INSTRUCTIONS provide guidance for the compaction summary."
567567

568568
;;;; Fork
569569

570+
(defun pi-coding-agent--flatten-tree (nodes)
571+
"Flatten tree NODES into a hash table mapping id to node plist.
572+
NODES is a vector of tree node plists, each with `:children' vector.
573+
Returns a hash table for O(1) lookup by id."
574+
(let ((index (make-hash-table :test 'equal)))
575+
(cl-labels ((walk (ns)
576+
(seq-doseq (node ns)
577+
(puthash (plist-get node :id) node index)
578+
(let ((children (plist-get node :children)))
579+
(when (and children (> (length children) 0))
580+
(walk children))))))
581+
(walk nodes))
582+
index))
583+
584+
(defun pi-coding-agent--active-branch-user-ids (index leaf-id)
585+
"Return chronological list of user message IDs on the active branch.
586+
INDEX is a hash table from `pi-coding-agent--flatten-tree'.
587+
LEAF-ID is the current leaf node ID. Walk from leaf to root via
588+
`:parentId', collecting IDs of nodes with type \"message\" and role
589+
\"user\". Returns list in root-to-leaf (chronological) order."
590+
(when leaf-id
591+
(let ((user-ids nil)
592+
(current-id leaf-id))
593+
(while current-id
594+
(let ((node (gethash current-id index)))
595+
(when (and node
596+
(equal (plist-get node :type) "message")
597+
(equal (plist-get node :role) "user"))
598+
(push (plist-get node :id) user-ids))
599+
(setq current-id (and node (plist-get node :parentId)))))
600+
user-ids)))
601+
570602
(defun pi-coding-agent--format-fork-message (msg &optional index)
571603
"Format MSG for display in fork selector.
572604
MSG is a plist with :entryId and :text.
@@ -593,6 +625,87 @@ Shows a selector of user messages and creates a fork from the selected one."
593625
(pi-coding-agent--show-fork-selector proc messages)))
594626
(message "Pi: Failed to get fork messages"))))))
595627

628+
(defun pi-coding-agent--resolve-fork-entry (response ordinal heading-count)
629+
"Resolve a fork entry ID from get_tree RESPONSE.
630+
ORDINAL is the 0-based user turn index. HEADING-COUNT is the number
631+
of visible You headings in the buffer. Returns (ENTRY-ID . PREVIEW)
632+
or nil if the ordinal could not be mapped."
633+
(when (plist-get response :success)
634+
(let* ((data (plist-get response :data))
635+
(tree (plist-get data :tree))
636+
(leaf-id (plist-get data :leafId))
637+
(index (pi-coding-agent--flatten-tree tree))
638+
(all-user-ids (pi-coding-agent--active-branch-user-ids index leaf-id))
639+
;; Take last N to handle compaction (compacted-away
640+
;; user messages at start of path aren't rendered)
641+
(visible-ids (last all-user-ids heading-count))
642+
(entry-id (nth ordinal visible-ids))
643+
(node (and entry-id (gethash entry-id index))))
644+
(when entry-id
645+
(cons entry-id (plist-get node :preview))))))
646+
647+
(defun pi-coding-agent-fork-at-point ()
648+
"Fork conversation from the user turn at point.
649+
Determines which user message point is in (or after), confirms with
650+
a preview, then forks. Only works when the session is idle."
651+
(interactive)
652+
(let ((chat-buf (pi-coding-agent--get-chat-buffer)))
653+
(unless chat-buf
654+
(user-error "Pi: No chat buffer"))
655+
(with-current-buffer chat-buf
656+
(let* ((headings (pi-coding-agent--collect-you-headings))
657+
(ordinal (pi-coding-agent--user-turn-index-at-point headings)))
658+
(cond
659+
((not (eq pi-coding-agent--status 'idle))
660+
(message "Pi: Cannot fork while streaming"))
661+
((not ordinal)
662+
(message "Pi: No user message at point"))
663+
(t
664+
(let ((heading-count (length headings))
665+
(proc (pi-coding-agent--get-process)))
666+
(unless proc
667+
(user-error "Pi: No active process"))
668+
(pi-coding-agent--rpc-async proc '(:type "get_tree")
669+
(lambda (response)
670+
(let ((result (pi-coding-agent--resolve-fork-entry
671+
response ordinal heading-count)))
672+
(cond
673+
((not result)
674+
(message "Pi: Could not map turn to entry ID"))
675+
((with-current-buffer chat-buf
676+
(y-or-n-p (format "Fork from: %s? " (or (cdr result) "?"))))
677+
(with-current-buffer chat-buf
678+
(pi-coding-agent--execute-fork proc (car result)))))))))))))))
679+
680+
(defun pi-coding-agent--execute-fork (proc entry-id)
681+
"Execute fork to ENTRY-ID via PROC.
682+
Sends the fork RPC, then on success: refreshes state, reloads history,
683+
and pre-fills the input buffer with the forked message text.
684+
Captures chat and input buffers at call time (before the async RPC)."
685+
(let ((chat-buf (pi-coding-agent--get-chat-buffer))
686+
(input-buf (pi-coding-agent--get-input-buffer)))
687+
(pi-coding-agent--rpc-async proc (list :type "fork" :entryId entry-id)
688+
(lambda (response)
689+
(if (plist-get response :success)
690+
(let* ((data (plist-get response :data))
691+
(text (plist-get data :text)))
692+
;; Refresh state to get new session-file
693+
(pi-coding-agent--rpc-async proc '(:type "get_state")
694+
(lambda (resp)
695+
(pi-coding-agent--apply-state-response chat-buf resp)))
696+
;; Reload and display the forked session
697+
(pi-coding-agent--load-session-history
698+
proc
699+
(lambda (count)
700+
(message "Pi: Branched to new session (%d messages)" count))
701+
chat-buf)
702+
;; Pre-fill input with the forked message text
703+
(when (buffer-live-p input-buf)
704+
(with-current-buffer input-buf
705+
(erase-buffer)
706+
(when text (insert text)))))
707+
(message "Pi: Branch failed"))))))
708+
596709
(defun pi-coding-agent--show-fork-selector (proc messages)
597710
"Show selector for MESSAGES and fork on selection.
598711
PROC is the pi process.
@@ -613,34 +726,9 @@ MESSAGES is a vector of plists from get_fork_messages."
613726
'(metadata (display-sort-function . identity))
614727
(complete-with-action action choice-strings string pred)))
615728
nil t))
616-
(selected (cdr (assoc choice formatted)))
617-
;; Capture buffers before async call (callback runs in arbitrary context)
618-
(chat-buf (pi-coding-agent--get-chat-buffer))
619-
(input-buf (pi-coding-agent--get-input-buffer)))
729+
(selected (cdr (assoc choice formatted))))
620730
(when selected
621-
(let ((entry-id (plist-get selected :entryId)))
622-
(pi-coding-agent--rpc-async proc (list :type "fork" :entryId entry-id)
623-
(lambda (response)
624-
(if (plist-get response :success)
625-
(let* ((data (plist-get response :data))
626-
(text (plist-get data :text)))
627-
;; Refresh state to get new session-file
628-
(pi-coding-agent--rpc-async proc '(:type "get_state")
629-
(lambda (resp)
630-
(pi-coding-agent--apply-state-response chat-buf resp)))
631-
;; Reload and display the forked session
632-
(pi-coding-agent--load-session-history
633-
proc
634-
(lambda (count)
635-
(message "Pi: Branched to new session (%d messages)" count))
636-
chat-buf)
637-
;; Pre-fill input with the selected message text
638-
(when (buffer-live-p input-buf)
639-
(with-current-buffer input-buf
640-
(erase-buffer)
641-
;; text may be nil if RPC returns null
642-
(when text (insert text)))))
643-
(message "Pi: Branch failed"))))))))
731+
(pi-coding-agent--execute-fork proc (plist-get selected :entryId)))))
644732

645733
;;;; Custom Commands
646734

pi-coding-agent-ui.el

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
(declare-function pi-coding-agent-resume-session "pi-coding-agent-menu")
7272
(declare-function pi-coding-agent-select-model "pi-coding-agent-menu")
7373
(declare-function pi-coding-agent-cycle-thinking "pi-coding-agent-menu")
74+
(declare-function pi-coding-agent-fork-at-point "pi-coding-agent-menu")
7475

7576
;; Optional: phscroll for horizontal table scrolling
7677
(require 'phscroll nil t)
@@ -333,33 +334,105 @@ Returns \"text\" for unrecognized extensions to ensure consistent fencing."
333334
(define-key map (kbd "C-c C-p") #'pi-coding-agent-menu)
334335
(define-key map (kbd "n") #'pi-coding-agent-next-message)
335336
(define-key map (kbd "p") #'pi-coding-agent-previous-message)
337+
(define-key map (kbd "f") #'pi-coding-agent-fork-at-point)
336338
(define-key map (kbd "TAB") #'pi-coding-agent-toggle-tool-section)
337339
(define-key map (kbd "<tab>") #'pi-coding-agent-toggle-tool-section)
338340
(define-key map (kbd "RET") #'pi-coding-agent-visit-file)
339341
(define-key map (kbd "<return>") #'pi-coding-agent-visit-file)
340342
map)
341343
"Keymap for `pi-coding-agent-chat-mode'.")
342344

345+
;;;; You Heading Detection
346+
347+
(defconst pi-coding-agent--you-heading-re
348+
"^You\\( · .*\\)?$"
349+
"Regex matching the first line of a user turn setext heading.
350+
Matches `You' at line start, optionally followed by ` · <timestamp>'.
351+
Must be verified with `pi-coding-agent--at-you-heading-p' to confirm
352+
the next line is a setext underline (===), avoiding false matches on
353+
user message text starting with \"You\".")
354+
355+
(defun pi-coding-agent--at-you-heading-p ()
356+
"Return non-nil if current line is a You setext heading.
357+
Checks that the current line matches `pi-coding-agent--you-heading-re'
358+
and the next line is a setext underline (three or more `=' characters)."
359+
(and (save-excursion
360+
(beginning-of-line)
361+
(looking-at pi-coding-agent--you-heading-re))
362+
(save-excursion
363+
(forward-line 1)
364+
(looking-at "^=\\{3,\\}$"))))
365+
366+
;;;; Turn Detection
367+
368+
(defun pi-coding-agent--collect-you-headings ()
369+
"Return list of buffer positions of all You setext headings.
370+
Scans from `point-min', returns positions in chronological order."
371+
(let (headings)
372+
(save-excursion
373+
(goto-char (point-min))
374+
(while (re-search-forward pi-coding-agent--you-heading-re nil t)
375+
(let ((pos (match-beginning 0)))
376+
(save-excursion
377+
(goto-char pos)
378+
(when (pi-coding-agent--at-you-heading-p)
379+
(push pos headings))))))
380+
(nreverse headings)))
381+
382+
(defun pi-coding-agent--user-turn-index-at-point (&optional headings)
383+
"Return 0-based index of the user turn at or before point.
384+
HEADINGS is an optional pre-computed list from
385+
`pi-coding-agent--collect-you-headings'; when nil, the buffer is scanned.
386+
Returns nil if point is before the first You heading."
387+
(let ((headings (or headings (pi-coding-agent--collect-you-headings)))
388+
(limit (point))
389+
(index 0)
390+
(result nil))
391+
(dolist (h headings)
392+
(when (<= h limit)
393+
(setq result index))
394+
(setq index (1+ index)))
395+
result))
396+
397+
;;;; Chat Navigation
398+
399+
(defun pi-coding-agent--find-you-heading (search-fn)
400+
"Find the next You setext heading using SEARCH-FN.
401+
SEARCH-FN is `re-search-forward' or `re-search-backward'.
402+
Returns the position of the heading line start, or nil if not found."
403+
(save-excursion
404+
(let ((found nil))
405+
(while (and (not found)
406+
(funcall search-fn pi-coding-agent--you-heading-re nil t))
407+
(let ((candidate (match-beginning 0)))
408+
(save-excursion
409+
(goto-char candidate)
410+
(when (pi-coding-agent--at-you-heading-p)
411+
(setq found candidate)))))
412+
found)))
413+
343414
(defun pi-coding-agent-next-message ()
344415
"Move to the next user message in the chat buffer."
345416
(interactive)
346417
(let ((pos (save-excursion
347418
(forward-line 1)
348-
(re-search-forward "^You:" nil t))))
419+
(pi-coding-agent--find-you-heading #'re-search-forward))))
349420
(if pos
350421
(progn
351422
(goto-char pos)
352-
(beginning-of-line))
423+
(when (get-buffer-window) (recenter 0)))
353424
(message "No more messages"))))
354425

355426
(defun pi-coding-agent-previous-message ()
356427
"Move to the previous user message in the chat buffer."
357428
(interactive)
358429
(let ((pos (save-excursion
359430
(beginning-of-line)
360-
(re-search-backward "^You:" nil t))))
431+
(pi-coding-agent--find-you-heading #'re-search-backward))))
361432
(if pos
362-
(goto-char pos)
433+
(progn
434+
(goto-char pos)
435+
(when (get-buffer-window) (recenter 0)))
363436
(message "No previous message"))))
364437

365438
(defconst pi-coding-agent--blockquote-wrap-prefix

0 commit comments

Comments
 (0)