diff --git a/pi-coding-agent-menu.el b/pi-coding-agent-menu.el index 81e457d..7a312fd 100644 --- a/pi-coding-agent-menu.el +++ b/pi-coding-agent-menu.el @@ -215,7 +215,8 @@ Call this when starting a new session to ensure no stale state persists." pi-coding-agent--in-code-block nil pi-coding-agent--in-thinking-block nil pi-coding-agent--line-parse-state 'line-start - pi-coding-agent--pending-tool-overlay nil) + pi-coding-agent--pending-tool-overlay nil + pi-coding-agent--activity-phase "idle") ;; Use accessors for cross-module state (pi-coding-agent--set-last-usage nil) (pi-coding-agent--clear-followup-queue) @@ -510,14 +511,16 @@ Optional CUSTOM-INSTRUCTIONS provide guidance for the compaction summary." (when-let ((proc (pi-coding-agent--get-process)) (chat-buf (pi-coding-agent--get-chat-buffer))) (message "Pi: Compacting...") - (pi-coding-agent--spinner-start) + (with-current-buffer chat-buf + (pi-coding-agent--set-activity-phase "compact")) (pi-coding-agent--rpc-async proc (if custom-instructions (list :type "compact" :customInstructions custom-instructions) '(:type "compact")) (lambda (response) - ;; Pass chat-buf explicitly (callback may run in arbitrary context) - (pi-coding-agent--spinner-stop chat-buf) + (when (buffer-live-p chat-buf) + (with-current-buffer chat-buf + (pi-coding-agent--set-activity-phase "idle"))) (if (plist-get response :success) (when (buffer-live-p chat-buf) (with-current-buffer chat-buf diff --git a/pi-coding-agent-render.el b/pi-coding-agent-render.el index 5417cd6..0e98719 100644 --- a/pi-coding-agent-render.el +++ b/pi-coding-agent-render.el @@ -81,9 +81,8 @@ visual spacing when `markdown-hide-markup' is enabled." (setq pi-coding-agent--line-parse-state 'line-start) (setq pi-coding-agent--in-code-block nil) (setq pi-coding-agent--in-thinking-block nil) - (pi-coding-agent--spinner-start) - (pi-coding-agent--fontify-timer-start) - (force-mode-line-update)) + (pi-coding-agent--set-activity-phase "thinking") + (pi-coding-agent--fontify-timer-start)) (defun pi-coding-agent--process-streaming-char (char state in-block) "Process CHAR with current STATE and IN-BLOCK flag. @@ -250,7 +249,7 @@ Note: status is set to `idle' by the event handler." (skip-chars-backward "\n") (delete-region (point) (point-max)) (insert "\n")))) - (pi-coding-agent--spinner-stop) + (pi-coding-agent--set-activity-phase "idle") (pi-coding-agent--fontify-timer-stop) (pi-coding-agent--refresh-header) ;; Check follow-up queue and send next message if any (unless aborted) @@ -566,6 +565,7 @@ Updates buffer-local state and renders display updates." (event-type (plist-get msg-event :type))) (pcase event-type ("text_delta" + (pi-coding-agent--set-activity-phase "replying") (pi-coding-agent--display-message-delta (plist-get msg-event :delta))) ("thinking_start" (pi-coding-agent--display-thinking-start)) @@ -627,6 +627,7 @@ Updates buffer-local state and renders display updates." (pi-coding-agent--set-last-usage (plist-get message :usage)))) (pi-coding-agent--render-complete-message)) ("tool_execution_start" + (pi-coding-agent--set-activity-phase "running") (let ((tool-call-id (plist-get event :toolCallId)) (args (plist-get event :args))) ;; Cache args for tool_execution_end (which doesn't include args) @@ -647,6 +648,7 @@ Updates buffer-local state and renders display updates." (overlay-put ov 'pi-coding-agent-tool-path path))) (pi-coding-agent--display-tool-start (plist-get event :toolName) args)))) ("tool_execution_end" + (pi-coding-agent--set-activity-phase "thinking") (let* ((tool-call-id (plist-get event :toolCallId)) (result (plist-get event :result)) ;; Retrieve cached args since tool_execution_end doesn't include them @@ -662,13 +664,13 @@ Updates buffer-local state and renders display updates." (pi-coding-agent--display-tool-update (plist-get event :partialResult))) ("auto_compaction_start" (setq pi-coding-agent--status 'compacting) - (pi-coding-agent--spinner-start) + (pi-coding-agent--set-activity-phase "compact") (let ((reason (plist-get event :reason))) (message "Pi: %sAuto-compacting... (C-c C-k to cancel)" (if (equal reason "overflow") "Context overflow, " "")))) ("auto_compaction_end" - (pi-coding-agent--spinner-stop) (setq pi-coding-agent--status 'idle) + (pi-coding-agent--set-activity-phase "idle") (if (pi-coding-agent--normalize-boolean (plist-get event :aborted)) (progn (message "Pi: Auto-compaction cancelled") diff --git a/pi-coding-agent-ui.el b/pi-coding-agent-ui.el index 4557fc4..d4a453d 100644 --- a/pi-coding-agent-ui.el +++ b/pi-coding-agent-ui.el @@ -34,7 +34,7 @@ ;; - Buffer-local session variables (the shared mutable state) ;; - Buffer creation, naming, and navigation ;; - Display primitives (append-to-chat, scroll preservation, separators) -;; - Header-line formatting and spinner +;; - Header-line formatting and activity phases ;; - Sending infrastructure (send-prompt, abort-send) ;; - Major mode definitions (chat-mode, input-mode) @@ -224,6 +224,11 @@ Subtle blue-tinted background derived from the current theme." "Face for model name in header line." :group 'pi-coding-agent) +(defface pi-coding-agent-activity-phase + '((t :inherit shadow)) + "Face for activity phase label in header line." + :group 'pi-coding-agent) + (defface pi-coding-agent-retry-notice '((t :inherit warning :slant italic)) "Face for retry notifications (rate limit, overloaded, etc.)." @@ -566,6 +571,21 @@ Starts as `line-start' because content begins after separator newline.") ;; pi-coding-agent--status is defined in pi-coding-agent-core.el as the single source of truth ;; for session activity state (idle, sending, streaming, compacting) +(defvar-local pi-coding-agent--activity-phase "idle" + "Fine-grained activity phase for header-line display. +One of: `thinking', `replying', `running', `compact', or `idle'. +Always populated and rendered in a fixed-width slot.") + +(defun pi-coding-agent--set-activity-phase (phase) + "Set activity PHASE for header-line display in current chat buffer. +PHASE should be one of: `thinking', `replying', `running', `compact', `idle'. +Returns non-nil when the phase changed. +Also triggers a header-line refresh when changed." + (unless (equal pi-coding-agent--activity-phase phase) + (setq pi-coding-agent--activity-phase phase) + (force-mode-line-update t) + t)) + (defvar-local pi-coding-agent--cached-stats nil "Cached session statistics for header-line display. Updated after each agent turn completes.") @@ -924,63 +944,6 @@ Removes common prefixes like \"Claude \" and suffixes like \" (latest)\"." (replace-regexp-in-string " (latest)$" "") (replace-regexp-in-string "^claude-" ""))) -(defvar pi-coding-agent--spinner-frames ["⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏"] - "Frames for the busy spinner animation.") - -(defvar pi-coding-agent--spinner-index 0 - "Current frame index in the spinner animation (shared for sync).") - -(defvar pi-coding-agent--spinner-timer nil - "Timer for animating spinners (shared across sessions).") - -(defvar pi-coding-agent--spinning-sessions nil - "List of chat buffers currently spinning.") - -(defun pi-coding-agent--spinner-start () - "Start the spinner for current session." - (let ((chat-buf (pi-coding-agent--get-chat-buffer))) - (when (and chat-buf (not (memq chat-buf pi-coding-agent--spinning-sessions))) - (push chat-buf pi-coding-agent--spinning-sessions) - ;; Start global timer if not running - (unless pi-coding-agent--spinner-timer - (setq pi-coding-agent--spinner-index 0) - (setq pi-coding-agent--spinner-timer - (run-with-timer 0 0.1 #'pi-coding-agent--spinner-tick)))))) - -(defun pi-coding-agent--spinner-stop (&optional chat-buf) - "Stop the spinner for current session. -CHAT-BUF is the buffer to stop spinning; if nil, uses current context. -Note: When called from async callbacks, pass CHAT-BUF explicitly." - (let ((chat-buf (or chat-buf (pi-coding-agent--get-chat-buffer)))) - (setq pi-coding-agent--spinning-sessions (delq chat-buf pi-coding-agent--spinning-sessions)) - ;; Stop global timer if no sessions spinning - (when (and pi-coding-agent--spinner-timer (null pi-coding-agent--spinning-sessions)) - (cancel-timer pi-coding-agent--spinner-timer) - (setq pi-coding-agent--spinner-timer nil)))) - -(defun pi-coding-agent--spinner-tick () - "Advance spinner to next frame and update spinning sessions." - (setq pi-coding-agent--spinner-index - (mod (1+ pi-coding-agent--spinner-index) (length pi-coding-agent--spinner-frames))) - ;; Only update windows showing spinning sessions - (dolist (buf pi-coding-agent--spinning-sessions) - (when (buffer-live-p buf) - (let ((input-buf (buffer-local-value 'pi-coding-agent--input-buffer buf))) - ;; Update input buffer's header line (where spinner shows) - (when (and input-buf (buffer-live-p input-buf)) - (dolist (win (get-buffer-window-list input-buf nil t)) - (with-selected-window win - (force-mode-line-update)))))))) - -(defun pi-coding-agent--spinner-current () - "Return current spinner frame if this session is spinning." - (let ((chat-buf (cond - ((derived-mode-p 'pi-coding-agent-chat-mode) (current-buffer)) - ((derived-mode-p 'pi-coding-agent-input-mode) pi-coding-agent--chat-buffer) - (t nil)))) - (when (and chat-buf (memq chat-buf pi-coding-agent--spinning-sessions)) - (aref pi-coding-agent--spinner-frames pi-coding-agent--spinner-index)))) - ;;; Header-Line Formatting (defvar pi-coding-agent--header-model-map @@ -1075,9 +1038,12 @@ Accesses state from the linked chat buffer." (model-short (if (string-empty-p model-name) "..." (pi-coding-agent--shorten-model-name model-name))) (thinking (or (plist-get state :thinking-level) "")) - (status-str (if-let ((spinner (pi-coding-agent--spinner-current))) - (concat " " spinner) - " "))) ; Same width as " ⠋" to prevent jumping + (activity-phase (or (and chat-buf + (buffer-local-value 'pi-coding-agent--activity-phase chat-buf)) + "idle")) + (activity-phase-str + (propertize (format "%-8s" activity-phase) + 'face 'pi-coding-agent-activity-phase))) (concat ;; Model (clickable) (propertize model-short @@ -1092,8 +1058,8 @@ Accesses state from the linked chat buffer." 'mouse-face 'highlight 'help-echo "mouse-1: Cycle thinking level" 'local-map pi-coding-agent--header-thinking-map))) - ;; Spinner/status (right after model/thinking) - status-str + ;; Activity phase (fixed-width slot after model/thinking) + (concat " " activity-phase-str) ;; Stats (if available) (pi-coding-agent--header-format-stats stats last-usage model-obj) ;; Extension status (if any) @@ -1155,12 +1121,11 @@ Shows an error message if process is unavailable." (defun pi-coding-agent--abort-send (chat-buf) "Clean up after a failed send attempt in CHAT-BUF. -Stops spinner and resets status to idle." +Resets activity phase and status to idle." (when (buffer-live-p chat-buf) (with-current-buffer chat-buf - (pi-coding-agent--spinner-stop) (setq pi-coding-agent--status 'idle) - (force-mode-line-update)))) + (pi-coding-agent--set-activity-phase "idle")))) (provide 'pi-coding-agent-ui) diff --git a/test/pi-coding-agent-input-test.el b/test/pi-coding-agent-input-test.el index c6547b1..08f9ee6 100644 --- a/test/pi-coding-agent-input-test.el +++ b/test/pi-coding-agent-input-test.el @@ -259,7 +259,6 @@ When user aborts, they want to stop everything - including queued messages." ;; Mock send functions to detect if queue processing sends the message (cl-letf (((symbol-function 'pi-coding-agent--prepare-and-send) (lambda (_text) (setq message-was-sent t))) - ((symbol-function 'pi-coding-agent--spinner-stop) #'ignore) ((symbol-function 'pi-coding-agent--fontify-timer-stop) #'ignore) ((symbol-function 'pi-coding-agent--refresh-header) #'ignore)) ;; Simulate agent_end arriving after abort @@ -400,8 +399,7 @@ When user aborts, they want to stop everything - including queued messages." (cl-letf (((symbol-function 'pi-coding-agent--get-process) (lambda () 'mock-proc)) ((symbol-function 'process-live-p) (lambda (_) t)) ((symbol-function 'pi-coding-agent--send-prompt) - (lambda (text) (setq sent-prompt text))) - ((symbol-function 'pi-coding-agent--spinner-start) #'ignore)) + (lambda (text) (setq sent-prompt text)))) (pi-coding-agent-queue-followup)) ;; Should send as normal prompt (should (equal sent-prompt "Do something else")) @@ -579,8 +577,7 @@ correct position in the conversation." (cl-letf (((symbol-function 'pi-coding-agent--get-process) (lambda () 'mock-proc)) ((symbol-function 'process-live-p) (lambda (_) t)) ((symbol-function 'pi-coding-agent--send-prompt) - (lambda (text) (setq sent-prompt text))) - ((symbol-function 'pi-coding-agent--spinner-start) #'ignore)) + (lambda (text) (setq sent-prompt text)))) (pi-coding-agent-send)) ;; Should send literal command (pi handles expansion) (should (equal sent-prompt "/greet world")))) @@ -807,8 +804,7 @@ mixed together like: (insert "First message") (cl-letf (((symbol-function 'pi-coding-agent--get-process) (lambda () 'mock-proc)) ((symbol-function 'process-live-p) (lambda (_) t)) - ((symbol-function 'pi-coding-agent--send-prompt) #'ignore) - ((symbol-function 'pi-coding-agent--spinner-start) #'ignore)) + ((symbol-function 'pi-coding-agent--send-prompt) #'ignore)) (pi-coding-agent-send))) ;; After normal send, variable should store the message text (with-current-buffer chat-buf @@ -855,8 +851,7 @@ When pi echoes it back via message_start, we should NOT display it again." (insert "Hello pi") (cl-letf (((symbol-function 'pi-coding-agent--get-process) (lambda () 'mock-proc)) ((symbol-function 'process-live-p) (lambda (_) t)) - ((symbol-function 'pi-coding-agent--send-prompt) #'ignore) - ((symbol-function 'pi-coding-agent--spinner-start) #'ignore)) + ((symbol-function 'pi-coding-agent--send-prompt) #'ignore)) (pi-coding-agent-send))) ;; Now simulate pi echoing the message back via message_start (with-current-buffer chat-buf @@ -909,8 +904,6 @@ On agent_end, we pop from queue and send (which displays the message)." ((symbol-function 'process-live-p) (lambda (_) t)) ((symbol-function 'pi-coding-agent--send-prompt) (lambda (text) (setq sent-prompt text))) - ((symbol-function 'pi-coding-agent--spinner-start) #'ignore) - ((symbol-function 'pi-coding-agent--spinner-stop) #'ignore) ((symbol-function 'pi-coding-agent--fontify-timer-stop) #'ignore) ((symbol-function 'pi-coding-agent--refresh-header) #'ignore)) (pi-coding-agent--handle-display-event '(:type "agent_end"))) @@ -952,8 +945,6 @@ On agent_end, we pop from queue and send (which displays the message)." ((symbol-function 'process-live-p) (lambda (_) t)) ((symbol-function 'pi-coding-agent--send-prompt) (lambda (text) (push text sent-prompts))) - ((symbol-function 'pi-coding-agent--spinner-start) #'ignore) - ((symbol-function 'pi-coding-agent--spinner-stop) #'ignore) ((symbol-function 'pi-coding-agent--fontify-timer-stop) #'ignore) ((symbol-function 'pi-coding-agent--refresh-header) #'ignore)) ;; First agent_end @@ -1981,38 +1972,59 @@ Pi handles command expansion on the server side." (let ((header (pi-coding-agent--header-line-string))) (should (string-match-p "high" header))))) -(ert-deftest pi-coding-agent-test-header-line-shows-streaming-indicator () - "Header line shows spinner when streaming." +(ert-deftest pi-coding-agent-test-header-line-shows-activity-phase () + "Header line shows the current activity phase label." (with-temp-buffer (pi-coding-agent-chat-mode) - (setq pi-coding-agent--state '(:model "claude-sonnet-4")) - (pi-coding-agent--spinner-start) - (unwind-protect - (let ((header (pi-coding-agent--header-line-string))) - (should (string-match-p "[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]" header))) - (pi-coding-agent--spinner-stop)))) - -(ert-deftest pi-coding-agent-test-spinner-stop-with-explicit-buffer () - "Spinner stops correctly when buffer is passed explicitly. -Regression test for #24: spinner wouldn't stop if callback ran in -arbitrary buffer context (e.g., process sentinel)." - (let* ((chat-buf (generate-new-buffer "*pi-coding-agent-chat:test-spinner/*")) - (original-spinning pi-coding-agent--spinning-sessions)) + (setq pi-coding-agent--state '(:model "claude-sonnet-4" :thinking-level "high") + pi-coding-agent--activity-phase "thinking") + (let ((header (pi-coding-agent--header-line-string))) + (should (string-match-p "thinking" header))))) + +(ert-deftest pi-coding-agent-test-header-line-shows-idle () + "Header line shows idle activity phase with fixed-width padding." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--state '(:model "claude-sonnet-4" :thinking-level "high") + pi-coding-agent--activity-phase "idle") + (let ((header (substring-no-properties (pi-coding-agent--header-line-string)))) + (should (string-match-p "idle " header))))) + +(ert-deftest pi-coding-agent-test-header-line-phase-is-padded () + "Header line activity phase slot is always 8 characters wide." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--state '(:model "claude-sonnet-4" :thinking-level "high") + pi-coding-agent--activity-phase "running") + (let* ((header (substring-no-properties (pi-coding-agent--header-line-string))) + (pos (string-match "running" header))) + (should pos) + (should (equal (substring header pos (+ pos 8)) "running "))))) + +(ert-deftest pi-coding-agent-test-header-line-shows-thinking-activity-phase () + "Header line shows semantic activity label during streaming." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--state '(:model "claude-sonnet-4") + pi-coding-agent--activity-phase "thinking") + (let ((header (pi-coding-agent--header-line-string))) + (should (string-match-p "thinking" header))))) + +(ert-deftest pi-coding-agent-test-abort-send-resets-activity-phase () + "Abort send resets activity phase and status to idle in CHAT-BUF." + (let ((chat-buf (generate-new-buffer "*pi-coding-agent-chat:test-abort-send/*"))) (unwind-protect (progn (with-current-buffer chat-buf (pi-coding-agent-chat-mode) - (pi-coding-agent--spinner-start)) - ;; Verify spinner started - (should (memq chat-buf pi-coding-agent--spinning-sessions)) - ;; Stop from different buffer context (simulating sentinel/callback) + (setq pi-coding-agent--activity-phase "running" + pi-coding-agent--status 'streaming)) + ;; Simulate callback/sentinel context by calling from other buffer (with-temp-buffer - ;; Without explicit buffer, this would fail to remove chat-buf - (pi-coding-agent--spinner-stop chat-buf)) - ;; Verify spinner stopped - (should-not (memq chat-buf pi-coding-agent--spinning-sessions))) - ;; Cleanup - (setq pi-coding-agent--spinning-sessions original-spinning) + (pi-coding-agent--abort-send chat-buf)) + (with-current-buffer chat-buf + (should (equal pi-coding-agent--activity-phase "idle")) + (should (eq pi-coding-agent--status 'idle)))) (when (buffer-live-p chat-buf) (kill-buffer chat-buf))))) diff --git a/test/pi-coding-agent-menu-test.el b/test/pi-coding-agent-menu-test.el index a992f51..f1208b1 100644 --- a/test/pi-coding-agent-menu-test.el +++ b/test/pi-coding-agent-menu-test.el @@ -74,7 +74,8 @@ pi-coding-agent--in-code-block t pi-coding-agent--in-thinking-block t pi-coding-agent--line-parse-state 'code-fence - pi-coding-agent--pending-tool-overlay (make-overlay 1 1)) + pi-coding-agent--pending-tool-overlay (make-overlay 1 1) + pi-coding-agent--activity-phase "running") ;; Add entry to tool-args-cache (puthash "tool-1" '(:path "/test") pi-coding-agent--tool-args-cache) ;; Clear the buffer @@ -94,6 +95,7 @@ (should (null pi-coding-agent--in-thinking-block)) (should (eq pi-coding-agent--line-parse-state 'line-start)) (should (null pi-coding-agent--pending-tool-overlay)) + (should (equal pi-coding-agent--activity-phase "idle")) ;; Tool args cache should be empty (should (= 0 (hash-table-count pi-coding-agent--tool-args-cache))))) @@ -417,15 +419,17 @@ Also verifies that the new session-file is stored in state for reload to work." (when (buffer-live-p chat-buf) (kill-buffer chat-buf))))) -(ert-deftest pi-coding-agent-test-send-stops-spinner-when-process-dead () - "Sending when process is dead stops spinner and resets status." - (let ((chat-buf (get-buffer-create "*pi-coding-agent-test-spinner-dead*")) - (input-buf (get-buffer-create "*pi-coding-agent-test-spinner-dead-input*"))) +(ert-deftest pi-coding-agent-test-send-resets-activity-when-process-dead () + "Sending when process is dead resets activity phase and status." + (let ((chat-buf (get-buffer-create "*pi-coding-agent-test-process-dead*")) + (input-buf (get-buffer-create "*pi-coding-agent-test-process-dead-input*"))) (unwind-protect (progn (with-current-buffer chat-buf (pi-coding-agent-chat-mode) - (setq pi-coding-agent--input-buffer input-buf) + (setq pi-coding-agent--input-buffer input-buf + pi-coding-agent--activity-phase "running" + pi-coding-agent--status 'idle) ;; Set up dead process (let ((dead-proc (start-process "test-dead" nil "true"))) (should (pi-coding-agent-test-wait-for-process-exit dead-proc)) @@ -435,8 +439,9 @@ Also verifies that the new session-file is stored in state for reload to work." (setq pi-coding-agent--chat-buffer chat-buf) (insert "test message") (pi-coding-agent-send)) - ;; Verify spinner stopped and status reset + ;; Verify activity phase and status reset (with-current-buffer chat-buf + (should (equal pi-coding-agent--activity-phase "idle")) (should (eq pi-coding-agent--status 'idle)))) (when (buffer-live-p chat-buf) (kill-buffer chat-buf)) (when (buffer-live-p input-buf) (kill-buffer input-buf))))) diff --git a/test/pi-coding-agent-render-test.el b/test/pi-coding-agent-render-test.el index b638461..efc34b7 100644 --- a/test/pi-coding-agent-render-test.el +++ b/test/pi-coding-agent-render-test.el @@ -487,8 +487,7 @@ since we don't display them locally. Let pi's message_start handle it." (insert "/fix-tests") (cl-letf (((symbol-function 'pi-coding-agent--get-process) (lambda () 'mock-proc)) ((symbol-function 'process-live-p) (lambda (_) t)) - ((symbol-function 'pi-coding-agent--send-prompt) #'ignore) - ((symbol-function 'pi-coding-agent--spinner-start) #'ignore)) + ((symbol-function 'pi-coding-agent--send-prompt) #'ignore)) (pi-coding-agent-send))) ;; KEY ASSERTION: assistant-header-shown should still be t @@ -2654,6 +2653,81 @@ Commands with embedded newlines should not have any lines deleted." :assistantMessageEvent (:type "thinking_delta" :delta "Analyzing..."))) (should (string-match-p "Analyzing..." (buffer-string))))) +(ert-deftest pi-coding-agent-test-activity-phase-thinking-on-agent-start () + "Activity phase becomes thinking on agent_start." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--activity-phase "idle") + (pi-coding-agent--handle-display-event '(:type "agent_start")) + (should (equal pi-coding-agent--activity-phase "thinking")))) + +(ert-deftest pi-coding-agent-test-activity-phase-replying-on-text-delta () + "Activity phase becomes replying on text_delta." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--activity-phase "idle") + (pi-coding-agent--handle-display-event '(:type "agent_start")) + (pi-coding-agent--handle-display-event + '(:type "message_update" + :assistantMessageEvent (:type "text_delta" :delta "Hello"))) + (should (equal pi-coding-agent--activity-phase "replying")))) + +(ert-deftest pi-coding-agent-test-activity-phase-running-on-tool-start () + "Activity phase becomes running on tool_execution_start." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--activity-phase "idle") + (pi-coding-agent--handle-display-event + '(:type "tool_execution_start" + :toolCallId "tool-1" + :toolName "bash" + :args (:command "ls"))) + (should (equal pi-coding-agent--activity-phase "running")))) + +(ert-deftest pi-coding-agent-test-activity-phase-thinking-on-tool-end () + "Activity phase returns to thinking on tool_execution_end." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--activity-phase "running") + (pi-coding-agent--handle-display-event + '(:type "tool_execution_start" + :toolCallId "tool-1" + :toolName "bash" + :args (:command "ls"))) + (pi-coding-agent--handle-display-event + '(:type "tool_execution_end" + :toolCallId "tool-1" + :toolName "bash" + :result (:content nil) + :isError nil)) + (should (equal pi-coding-agent--activity-phase "thinking")))) + +(ert-deftest pi-coding-agent-test-activity-phase-compact-on-compaction () + "Activity phase becomes compact on auto_compaction_start." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--activity-phase "idle") + (pi-coding-agent--handle-display-event + '(:type "auto_compaction_start" :reason "threshold")) + (should (equal pi-coding-agent--activity-phase "compact")))) + +(ert-deftest pi-coding-agent-test-activity-phase-idle-on-agent-end () + "Activity phase becomes idle on agent_end." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--activity-phase "thinking") + (pi-coding-agent--handle-display-event '(:type "agent_end")) + (should (equal pi-coding-agent--activity-phase "idle")))) + +(ert-deftest pi-coding-agent-test-activity-phase-idle-on-compaction-end () + "Activity phase becomes idle on auto_compaction_end." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--activity-phase "compact") + (pi-coding-agent--handle-display-event + '(:type "auto_compaction_end" :aborted t :result nil)) + (should (equal pi-coding-agent--activity-phase "idle")))) + (ert-deftest pi-coding-agent-test-display-compaction-result-shows-header-tokens-summary () "pi-coding-agent--display-compaction-result shows header, token count, and summary." (with-temp-buffer