Skip to content

Commit c64c72a

Browse files
dnouriConor Nash
andauthored
Merge pull request #223 from dnouri/activity-phase-hooks
Let users react to activity phase changes Co-authored-by: Conor Nash <conor@nbs.consulting>
2 parents 8427efa + 6b571a8 commit c64c72a

5 files changed

Lines changed: 312 additions & 9 deletions

File tree

README.org

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,8 @@ code spans, fenced blocks) while keeping the typed markup characters visible.
384384
Set =pi-coding-agent-input-markdown-highlighting= to =nil= for plain text input
385385
buffers in new sessions.
386386

387+
For activity-driven UI customizations, see [[*Activity phase hooks][Activity phase hooks]].
388+
387389
** Markdown tables
388390

389391
Pipe tables in the chat buffer are beautified as a display-only view. The
@@ -405,6 +407,83 @@ two-window UI, where each session shows one chat window and one input window.
405407
If you deliberately show the same chat buffer in multiple windows with
406408
different widths, one of those widths wins until the next refresh.
407409

410+
* Advanced
411+
412+
** Activity phase hooks
413+
414+
For custom UI changes tied to activity, add functions to
415+
=pi-coding-agent-activity-phase-functions=. Each function receives:
416+
417+
#+begin_src emacs-lisp
418+
(CHAT-BUFFER INPUT-BUFFER OLD-PHASE NEW-PHASE REASON)
419+
#+end_src
420+
421+
=NEW-PHASE= is one of ="thinking"=, ="replying"=, ="running"=,
422+
="compact"=, or ="idle"=. =REASON= explains why the phase was applied:
423+
424+
| Reason | Meaning |
425+
|----------------+----------------------------------------------|
426+
| =phase-change= | The session activity phase changed. |
427+
| =reset= | A session reset forced ="idle"=. |
428+
| =teardown= | Session teardown forced ="idle"=. |
429+
| =input-link= | A newly linked input should apply the phase. |
430+
| =input-unlink= | An old input should clean up local UI state. |
431+
432+
Handlers should be idempotent. pi-coding-agent may reapply the same phase
433+
when buffers are relinked, reset, or torn down. =INPUT-BUFFER= may also be
434+
nil or dead during teardown.
435+
436+
Tint the input buffer while the session is busy:
437+
438+
#+begin_src emacs-lisp
439+
(defvar-local my-pi-input-tint-cookie nil)
440+
441+
(defun my-pi-tint-input-while-busy (_chat input _old new _reason)
442+
(when (buffer-live-p input)
443+
(with-current-buffer input
444+
(when my-pi-input-tint-cookie
445+
(face-remap-remove-relative my-pi-input-tint-cookie)
446+
(setq my-pi-input-tint-cookie nil))
447+
(unless (string= new "idle")
448+
(setq my-pi-input-tint-cookie
449+
(face-remap-add-relative
450+
'default :background "gray20"))))))
451+
452+
(add-hook 'pi-coding-agent-activity-phase-functions
453+
#'my-pi-tint-input-while-busy)
454+
#+end_src
455+
456+
Notify when a real session turn finishes. The =REASON= check matters: input
457+
relink cleanup also applies ="idle"= to the old input, but that does not mean
458+
Pi finished working.
459+
460+
#+begin_src emacs-lisp
461+
(defun my-pi-message-when-done (chat _input old new reason)
462+
(when (and (eq reason 'phase-change)
463+
(not (string= old "idle"))
464+
(string= new "idle"))
465+
(message "Pi finished in %s" (buffer-name chat))))
466+
467+
(add-hook 'pi-coding-agent-activity-phase-functions
468+
#'my-pi-message-when-done)
469+
#+end_src
470+
471+
Track busy sessions for your own mode-line or tab display:
472+
473+
#+begin_src emacs-lisp
474+
(defvar my-pi-busy-sessions nil)
475+
476+
(defun my-pi-track-busy-sessions (chat _input _old new reason)
477+
(when (memq reason '(phase-change reset teardown))
478+
(setq my-pi-busy-sessions (delq chat my-pi-busy-sessions))
479+
(unless (string= new "idle")
480+
(push chat my-pi-busy-sessions))
481+
(force-mode-line-update t)))
482+
483+
(add-hook 'pi-coding-agent-activity-phase-functions
484+
#'my-pi-track-busy-sessions)
485+
#+end_src
486+
408487
* Development
409488

410489
Most users can skip this section. It is for contributors and local

pi-coding-agent-menu.el

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,8 +363,8 @@ Call this when starting a new session to ensure no stale state persists."
363363
pi-coding-agent--line-parse-state 'line-start
364364
pi-coding-agent--pending-tool-overlay nil
365365
pi-coding-agent--tool-block-order-counter 0
366-
pi-coding-agent--thinking-block-order-counter 0
367-
pi-coding-agent--activity-phase "idle")
366+
pi-coding-agent--thinking-block-order-counter 0)
367+
(pi-coding-agent--set-activity-phase "idle" 'reset t)
368368
(pi-coding-agent--clear-unsupported-extension-ui-warnings)
369369
(pi-coding-agent--invalidate-history-loads)
370370
;; Use accessors for cross-module state

pi-coding-agent-render.el

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,7 @@ which asks upfront before any buffers are touched."
10171017
(when (derived-mode-p 'pi-coding-agent-chat-mode)
10181018
(pi-coding-agent--cancel-followup-drain-timer)
10191019
(pi-coding-agent--invalidate-prompt-start-wait)
1020+
(pi-coding-agent--set-activity-phase "idle" 'teardown t)
10201021
(when pi-coding-agent--process
10211022
(pi-coding-agent--unregister-display-handler pi-coding-agent--process)
10221023
(when (process-live-p pi-coding-agent--process)

pi-coding-agent-ui.el

Lines changed: 69 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,27 @@ total window height, e.g. 0.3 means 30% for input."
111111
(float :tag "Fraction (0.0–1.0)"))
112112
:group 'pi-coding-agent)
113113

114+
(defcustom pi-coding-agent-activity-phase-functions nil
115+
"Functions called after a session activity phase is applied.
116+
Each function is called with five arguments:
117+
118+
CHAT-BUFFER INPUT-BUFFER OLD-PHASE NEW-PHASE REASON
119+
120+
NEW-PHASE is one of \"thinking\", \"replying\", \"running\",
121+
\"compact\", or \"idle\". INPUT-BUFFER may be nil or dead during
122+
session teardown.
123+
124+
REASON is one of `phase-change', `reset', `teardown',
125+
`input-link', or `input-unlink'. This lets handlers distinguish a
126+
real session phase change from buffer lifecycle events that merely
127+
reapply or clean up buffer-local UI.
128+
129+
This is an abnormal hook. Functions should be idempotent because
130+
pi-coding-agent may call them again with the same OLD-PHASE and
131+
NEW-PHASE when session buffers are relinked or reset."
132+
:type 'hook
133+
:group 'pi-coding-agent)
134+
114135
(defcustom pi-coding-agent-separator-width 72
115136
"Total width of section separators in chat buffer."
116137
:type 'natnum
@@ -908,9 +929,19 @@ so built-in other-window scrolling commands target the linked chat."
908929
(defvar-local pi-coding-agent--input-buffer nil
909930
"Reference to the input buffer for this session.")
910931

932+
(defvar pi-coding-agent--activity-phase)
933+
911934
(defun pi-coding-agent--set-input-buffer (buffer)
912935
"Set the input BUFFER reference for this session."
913-
(setq pi-coding-agent--input-buffer buffer))
936+
(let ((old-buffer pi-coding-agent--input-buffer)
937+
(phase pi-coding-agent--activity-phase))
938+
(unless (eq old-buffer buffer)
939+
(when (and old-buffer (buffer-live-p old-buffer))
940+
(pi-coding-agent--run-activity-phase-functions
941+
(current-buffer) old-buffer phase "idle" 'input-unlink))
942+
(setq pi-coding-agent--input-buffer buffer)
943+
(when (and buffer (buffer-live-p buffer))
944+
(pi-coding-agent--set-activity-phase phase 'input-link t)))))
914945

915946
(defvar-local pi-coding-agent--thinking-display nil
916947
"Completed-thinking display mode for this chat buffer.
@@ -1034,15 +1065,46 @@ One of \"thinking\", \"replying\", \"running\",
10341065
\"compact\", or \"idle\".
10351066
Always populated and rendered in a fixed-width slot.")
10361067

1037-
(defun pi-coding-agent--set-activity-phase (phase)
1068+
(defun pi-coding-agent--run-activity-phase-functions
1069+
(chat-buf input-buf old-phase new-phase reason)
1070+
"Run activity phase functions for CHAT-BUF and INPUT-BUF.
1071+
OLD-PHASE is the previously applied phase. NEW-PHASE is the phase that
1072+
is now applied. REASON explains why the application happened. User functions
1073+
are isolated so a customization error cannot break rendering or state
1074+
transitions."
1075+
(dolist (fn pi-coding-agent-activity-phase-functions)
1076+
(condition-case-unless-debug err
1077+
(funcall fn chat-buf input-buf old-phase new-phase reason)
1078+
(error
1079+
(display-warning
1080+
'pi-coding-agent
1081+
(format "Activity phase function %S failed: %s"
1082+
fn (error-message-string err))
1083+
:error)))))
1084+
1085+
(defun pi-coding-agent--set-activity-phase (phase &optional reason force)
10381086
"Set activity PHASE for header-line display in current chat buffer.
10391087
PHASE should be one of \"thinking\", \"replying\",
1040-
\"running\", \"compact\", \"idle\".
1088+
\"running\", \"compact\", or \"idle\". REASON defaults to
1089+
`phase-change'. When FORCE is non-nil, rerun
1090+
`pi-coding-agent-activity-phase-functions' even if PHASE did not change.
10411091
Returns non-nil when the phase changed."
1042-
(unless (equal pi-coding-agent--activity-phase phase)
1043-
(setq pi-coding-agent--activity-phase phase)
1044-
(force-mode-line-update t)
1045-
t))
1092+
(let ((chat-buf (pi-coding-agent--get-chat-buffer))
1093+
(reason (or reason 'phase-change)))
1094+
(if (and chat-buf
1095+
(buffer-live-p chat-buf)
1096+
(not (eq chat-buf (current-buffer))))
1097+
(with-current-buffer chat-buf
1098+
(pi-coding-agent--set-activity-phase phase reason force))
1099+
(let* ((old-phase pi-coding-agent--activity-phase)
1100+
(changed (not (equal old-phase phase))))
1101+
(when (or changed force)
1102+
(setq pi-coding-agent--activity-phase phase)
1103+
(when changed
1104+
(force-mode-line-update t))
1105+
(pi-coding-agent--run-activity-phase-functions
1106+
(current-buffer) pi-coding-agent--input-buffer old-phase phase reason))
1107+
changed))))
10461108

10471109
(defvar-local pi-coding-agent--cached-stats nil
10481110
"Cached session statistics for header-line display.

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

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,9 @@ This ensures all files get code fences for consistent display."
335335

336336
;;; Buffer Linkage
337337

338+
(defvar-local pi-coding-agent-test--activity-marker nil
339+
"Buffer-local marker used by activity-phase hook tests.")
340+
338341
(ert-deftest pi-coding-agent-test-input-buffer-finds-chat ()
339342
"Input buffer can find associated chat buffer."
340343
(pi-coding-agent-test-with-mock-session "/tmp/pi-coding-agent-test-link1/"
@@ -349,6 +352,164 @@ This ensures all files get code fences for consistent display."
349352
(should (eq (pi-coding-agent--get-input-buffer)
350353
(get-buffer "*pi-coding-agent-input:/tmp/pi-coding-agent-test-link2/*"))))))
351354

355+
(ert-deftest pi-coding-agent-test-activity-phase-functions-receive-session-buffers ()
356+
"Activity phase functions receive buffers, phases, and reason."
357+
(let ((calls nil)
358+
(dir "/tmp/pi-coding-agent-test-activity-hook/"))
359+
(pi-coding-agent-test-with-mock-session dir
360+
(let ((chat (get-buffer (pi-coding-agent--buffer-name :chat dir)))
361+
(input (get-buffer (pi-coding-agent--buffer-name :input dir)))
362+
(pi-coding-agent-activity-phase-functions
363+
(list (lambda (chat-buf input-buf old-phase new-phase reason)
364+
(push (list chat-buf input-buf old-phase new-phase reason)
365+
calls)))))
366+
(with-current-buffer chat
367+
(pi-coding-agent--set-activity-phase "thinking")
368+
(pi-coding-agent--set-activity-phase "thinking"))
369+
(should (= (length calls) 1))
370+
(pcase-let ((`(,seen-chat ,seen-input ,old-phase ,new-phase ,reason)
371+
(car calls)))
372+
(should (eq seen-chat chat))
373+
(should (eq seen-input input))
374+
(should (equal old-phase "idle"))
375+
(should (equal new-phase "thinking"))
376+
(should (eq reason 'phase-change)))))))
377+
378+
(ert-deftest pi-coding-agent-test-reset-session-state-forces-idle-activity-phase ()
379+
"Session reset applies idle even when user display state needs resync."
380+
(let ((calls nil)
381+
(dir "/tmp/pi-coding-agent-test-activity-reset/"))
382+
(pi-coding-agent-test-with-mock-session dir
383+
(let ((chat (get-buffer (pi-coding-agent--buffer-name :chat dir)))
384+
(input (get-buffer (pi-coding-agent--buffer-name :input dir)))
385+
(pi-coding-agent-activity-phase-functions
386+
(list (lambda (chat-buf input-buf old-phase new-phase reason)
387+
(push (list chat-buf input-buf old-phase new-phase reason)
388+
calls)))))
389+
(with-current-buffer chat
390+
(pi-coding-agent--set-activity-phase "running")
391+
(setq calls nil)
392+
(pi-coding-agent--reset-session-state))
393+
(should (= (length calls) 1))
394+
(pcase-let ((`(,seen-chat ,seen-input ,old-phase ,new-phase ,reason)
395+
(car calls)))
396+
(should (eq seen-chat chat))
397+
(should (eq seen-input input))
398+
(should (equal old-phase "running"))
399+
(should (equal new-phase "idle"))
400+
(should (eq reason 'reset)))))))
401+
402+
(ert-deftest pi-coding-agent-test-set-input-buffer-resyncs-activity-phase ()
403+
"Relinking an input buffer reapplies the current activity phase."
404+
(let ((calls nil)
405+
(dir "/tmp/pi-coding-agent-test-activity-relink/")
406+
(new-input (generate-new-buffer " *pi-activity-relink-input*")))
407+
(unwind-protect
408+
(pi-coding-agent-test-with-mock-session dir
409+
(let ((chat (get-buffer (pi-coding-agent--buffer-name :chat dir)))
410+
(pi-coding-agent-activity-phase-functions
411+
(list (lambda (chat-buf input-buf old-phase new-phase reason)
412+
(push (list chat-buf input-buf old-phase new-phase reason)
413+
calls)))))
414+
(with-current-buffer chat
415+
(pi-coding-agent--set-activity-phase "running")
416+
(setq calls nil)
417+
(pi-coding-agent--set-input-buffer new-input))
418+
(pcase-let ((`(,seen-chat ,seen-input ,old-phase ,new-phase ,reason)
419+
(cl-find-if (lambda (call)
420+
(eq (cadr call) new-input))
421+
calls)))
422+
(should (eq seen-chat chat))
423+
(should (eq seen-input new-input))
424+
(should (equal old-phase "running"))
425+
(should (equal new-phase "running"))
426+
(should (eq reason 'input-link)))))
427+
(when (buffer-live-p new-input)
428+
(kill-buffer new-input)))))
429+
430+
(ert-deftest pi-coding-agent-test-set-input-buffer-clears-old-input-activity ()
431+
"Relinking input buffers lets hooks clean state from the old input."
432+
(let ((dir "/tmp/pi-coding-agent-test-activity-relink-cleanup/")
433+
(new-input (generate-new-buffer " *pi-activity-relink-cleanup-input*")))
434+
(unwind-protect
435+
(pi-coding-agent-test-with-mock-session dir
436+
(let ((chat (get-buffer (pi-coding-agent--buffer-name :chat dir)))
437+
(old-input (get-buffer (pi-coding-agent--buffer-name :input dir)))
438+
(pi-coding-agent-activity-phase-functions
439+
(list (lambda (_chat-buf input-buf _old-phase new-phase reason)
440+
(when (buffer-live-p input-buf)
441+
(with-current-buffer input-buf
442+
(cond
443+
((eq reason 'input-unlink)
444+
(setq pi-coding-agent-test--activity-marker nil))
445+
((not (equal new-phase "idle"))
446+
(setq pi-coding-agent-test--activity-marker t)))))))))
447+
(with-current-buffer chat
448+
(pi-coding-agent--set-activity-phase "running"))
449+
(with-current-buffer old-input
450+
(should pi-coding-agent-test--activity-marker))
451+
(with-current-buffer chat
452+
(pi-coding-agent--set-input-buffer new-input))
453+
(with-current-buffer old-input
454+
(should-not pi-coding-agent-test--activity-marker))
455+
(with-current-buffer new-input
456+
(should pi-coding-agent-test--activity-marker))))
457+
(when (buffer-live-p new-input)
458+
(kill-buffer new-input)))))
459+
460+
(ert-deftest pi-coding-agent-test-activity-phase-reason-distinguishes-relink-from-idle ()
461+
"Relinking input buffers does not look like a real idle transition."
462+
(let ((finished-notifications 0)
463+
(dir "/tmp/pi-coding-agent-test-activity-relink-reason/")
464+
(new-input (generate-new-buffer " *pi-activity-relink-reason-input*")))
465+
(unwind-protect
466+
(pi-coding-agent-test-with-mock-session dir
467+
(let ((chat (get-buffer (pi-coding-agent--buffer-name :chat dir)))
468+
(pi-coding-agent-activity-phase-functions
469+
(list (lambda (_chat-buf _input-buf old-phase new-phase reason)
470+
(when (and (eq reason 'phase-change)
471+
(not (equal old-phase "idle"))
472+
(equal new-phase "idle"))
473+
(setq finished-notifications
474+
(1+ finished-notifications)))))))
475+
(with-current-buffer chat
476+
(pi-coding-agent--set-activity-phase "running")
477+
(pi-coding-agent--set-input-buffer new-input))
478+
(should (= finished-notifications 0))
479+
(with-current-buffer chat
480+
(pi-coding-agent--set-activity-phase "idle"))
481+
(should (= finished-notifications 1))))
482+
(when (buffer-live-p new-input)
483+
(kill-buffer new-input)))))
484+
485+
(ert-deftest pi-coding-agent-test-chat-buffer-kill-forces-teardown-activity-phase ()
486+
"Killing a chat buffer applies idle with teardown as the reason."
487+
(let ((calls nil)
488+
(dir "/tmp/pi-coding-agent-test-activity-teardown/"))
489+
(pi-coding-agent-test-with-mock-session dir
490+
(let ((chat (get-buffer (pi-coding-agent--buffer-name :chat dir)))
491+
(input (get-buffer (pi-coding-agent--buffer-name :input dir)))
492+
(pi-coding-agent-activity-phase-functions
493+
(list (lambda (chat-buf input-buf old-phase new-phase reason)
494+
(push (list chat-buf input-buf old-phase new-phase reason)
495+
calls)))))
496+
(with-current-buffer chat
497+
(pi-coding-agent--set-activity-phase "running"))
498+
(setq calls nil)
499+
(kill-buffer chat)
500+
(pcase-let ((`(,seen-chat ,seen-input ,old-phase ,new-phase ,reason)
501+
(cl-find-if (lambda (call)
502+
(and (eq (nth 4 call) 'teardown)
503+
(equal (nth 2 call) "running")
504+
(equal (nth 3 call) "idle")))
505+
calls)))
506+
(should (eq seen-chat chat))
507+
(should (or (null seen-input)
508+
(eq seen-input input)))
509+
(should (equal old-phase "running"))
510+
(should (equal new-phase "idle"))
511+
(should (eq reason 'teardown)))))))
512+
352513
(ert-deftest pi-coding-agent-test-get-process-from-chat ()
353514
"Can get process from chat buffer."
354515
(let ((default-directory "/tmp/pi-coding-agent-test-proc1/")

0 commit comments

Comments
 (0)