From a1afef83a4dcf53a423a58eaf37b46aa5fea4f78 Mon Sep 17 00:00:00 2001 From: Daniel Nouri Date: Sat, 5 Sep 2026 21:40:56 +0200 Subject: [PATCH 1/2] See reply models, token usage, and tool timings on hover Hover completed replies, thinking, and tools, or use C-h . for full help when terminal hover is clipped. Links, images, and buttons keep their own help. Replies show their own provider/model, message-level input/output tokens, and separate cache reads/writes. Thinking shows line count and its message's model. Tools show shortened command/path hints and their matching result's timestamp. Saved times show creation in local time, not exact completion; parallel tool results may be created after the batch finishes. Stream measures an observed assistant-message interval, not full request latency. Approximate output rates use reported output tokens, including reasoning and tool-call arguments where counted. Tool timing excludes argument streaming. Live timings survive folding and remain available in older output, but disappear on reload, resume, or a full history rebuild; saved metadata remains. Verified with make check (1,716 passing tests, one skip), terminal mouse/keyboard tests, and GUI tooltip checks. --- README.org | 6 + pilish-render.el | 422 +++++++++++++++--- pilish-ui.el | 11 +- test/pilish-render-test.el | 873 +++++++++++++++++++++++++++++++++++++ 4 files changed, 1242 insertions(+), 70 deletions(-) diff --git a/README.org b/README.org index a3112bd..9044cad 100644 --- a/README.org +++ b/README.org @@ -250,6 +250,12 @@ tool block to expand or collapse it. Long-running commands stream output live, file operations (=read=, =write=, =edit=) get syntax highlighting, and edit diffs highlight what changed. +Hover completed replies, thinking, or tool blocks for timestamps and details: +reply provider/model and message-level token/cache usage, thinking line counts, +or short tool command/path hints. Native =C-h .= shows full help at point, +including in terminals. Stream durations, output rates, and tool timings are +approximate and live-only; reload or resume drops them. + Image content in sent user turns and completed tool results is shown inline in graphical Emacs and as a useful type-and-size placeholder in terminals. Sent and returned image content share the same bounded renderer. Pi's built-in diff --git a/pilish-render.el b/pilish-render.el index ca3be7b..28c3d01 100644 --- a/pilish-render.el +++ b/pilish-render.el @@ -94,6 +94,17 @@ assistant message. This table exists only while the current assistant message is being generated; completed tool execution remains keyed separately by tool call ID in `pilish--live-tool-blocks'.") +(defvar-local pilish--hover-assistant nil + "Current assistant boundary: saved timestamp, local start time, stream seen. +The timestamp matches the final message, never a heading/content index. Local +samples are display-only and cleared at completion, teardown and rebuild.") + +(defvar-local pilish--hover-pending-tool-blocks nil + "Call-ID associations awaiting timestamped toolResult messages this run. +Values are weak references to existing finalized tool records: hover must not +retain their output, markers, or overlays after cooling. Entries are removed +on receipt and the table is cleared at agent/session/process teardown.") + (defun pilish--history-postprocessing-deferred-p () "Return non-nil when history display post-processing is currently deferred." pilish--defer-history-postprocessing) @@ -360,29 +371,26 @@ at most one empty paragraph separator while preserving indentation." order)) (defun pilish--propertize-completed-thinking - (rendered order normalized display) - "Return RENDERED tagged as completed thinking block metadata. -ORDER identifies the logical block across rerenders. NORMALIZED stores the -canonical completed thinking text, and DISPLAY records whether this block is -currently shown as `visible' or `hidden'." - (propertize rendered - 'pilish-thinking-block order - 'pilish-thinking-normalized normalized - 'pilish-thinking-block-display display - 'help-echo "TAB: toggle completed thinking")) + (rendered order normalized display &optional help) + "Return RENDERED tagged with ORDER, NORMALIZED text, DISPLAY mode and HELP." + (pilish--apply-completed-thinking-properties + 0 (length rendered) order normalized display help rendered) + rendered) (defun pilish--apply-completed-thinking-properties - (start end order normalized display) - "Tag START..END as completed thinking metadata. -ORDER identifies the block, NORMALIZED stores its canonical text, and DISPLAY -records whether it is currently shown as `visible' or `hidden'." + (start end order normalized display &optional help object) + "Tag START..END with ORDER, NORMALIZED text, DISPLAY mode and native HELP. +OBJECT is a rendered string, or nil for the current buffer." (when (< start end) (add-text-properties start end `(pilish-thinking-block ,order pilish-thinking-normalized ,normalized pilish-thinking-block-display ,display - help-echo "TAB: toggle completed thinking")))) + pilish-thinking-help ,help + rear-nonsticky t) object) + (pilish--set-hover-help + start end (or help (pilish--thinking-hover-help nil normalized)) object))) (defun pilish--thinking-block-probe-pos (pos) "Return a position inside the completed-thinking block at POS, or nil. @@ -444,6 +452,7 @@ PROBE must already be inside a completed-thinking block." 'pilish-thinking-block-display) 'visible) :normalized normalized + :help (get-text-property probe 'pilish-thinking-help) :start (car bounds) :end (cdr bounds)))) @@ -469,13 +478,14 @@ non-empty." (display (and order (get-text-property 0 'pilish-thinking-block-display - text)))) + text))) + (help (and order (get-text-property 0 'pilish-thinking-help text)))) (when (<= start end) (let ((existing (buffer-substring-no-properties start end))) (if (equal existing plain-text) (when order (pilish--apply-completed-thinking-properties - start end order normalized display)) + start end order normalized display help)) (goto-char start) (delete-region start end) (insert text) @@ -1244,6 +1254,7 @@ which asks upfront before any buffers are touched." (pilish--finalize-live-tool-blocks 'pilish-tool-block-error) (pilish--reset-toolcall-streams) + (pilish--hover-clear-live-state) (when pilish--tool-args-cache (clrhash pilish--tool-args-cache)) (pilish--set-process nil) @@ -1270,21 +1281,32 @@ which asks upfront before any buffers are touched." (defun pilish--handle-display-event (event) "Handle EVENT for display purposes. Updates buffer-local state and renders display updates." - ;; Update state first (now buffer-local) - (pilish--update-state-from-event event) - ;; Then handle display - (pcase (plist-get event :type) + ;; Sample before state updates or rendering, never on deltas or remote + ;; user/toolResult lifecycle events. Both endpoints use this local clock. + (let ((hover-time + (when (or (member (plist-get event :type) + '("tool_execution_start" "tool_execution_end")) + (and (member (plist-get event :type) '("message_start" "message_end")) + (equal (plist-get (plist-get event :message) :role) "assistant"))) + (float-time (current-time))))) + (pilish--update-state-from-event event) + (pcase (plist-get event :type) ("agent_start" + (pilish--hover-clear-live-state) (pilish--invalidate-prompt-start-wait) (pilish--cancel-followup-drain-timer) (pilish--display-agent-start)) ("message_start" (let* ((message (plist-get event :message)) (role (plist-get message :role))) - ;; A new message starts a fresh rendering context. - (setq pilish--in-thinking-block nil) - (pilish--reset-thinking-state) + ;; Late toolResult messages must not move assistant markers or clear + ;; thinking. Other new messages keep the existing context cleanup. + (unless (equal role "toolResult") + (setq pilish--in-thinking-block nil) + (pilish--reset-thinking-state)) (when (equal role "assistant") + (setq pilish--hover-assistant + (list :timestamp (plist-get message :timestamp) :time hover-time)) (pilish--reset-toolcall-streams)) (pcase role ("user" @@ -1311,7 +1333,7 @@ Updates buffer-local state and renders display updates." (when (plist-get message :display) (pilish--display-custom-message (plist-get message :content)))) - (_ + ("assistant" ;; Assistant message - show header if needed, reset markers (unless pilish--assistant-header-shown (pilish--append-to-chat @@ -1323,7 +1345,10 @@ Updates buffer-local state and renders display updates." (when-let* ((msg-event (plist-get event :assistantMessageEvent)) (event-type (plist-get msg-event :type))) (pcase event-type - ("text_start") ; No-op: text block started, nothing to render + ("text_start" + (when pilish--hover-assistant + (setq pilish--hover-assistant + (plist-put pilish--hover-assistant :streamed t)))) ("text_delta" (pilish--set-activity-phase "replying") (pilish--display-message-delta (plist-get msg-event :delta))) @@ -1333,12 +1358,18 @@ Updates buffer-local state and renders display updates." (pilish--maybe-decorate-streaming-table) (setq pilish--streaming-table-candidate nil)) ("thinking_start" + (when pilish--hover-assistant + (setq pilish--hover-assistant + (plist-put pilish--hover-assistant :streamed t))) (pilish--display-thinking-start)) ("thinking_delta" (pilish--display-thinking-delta (plist-get msg-event :delta))) ("thinking_end" (pilish--display-thinking-end (plist-get msg-event :content))) ((or "toolcall_start" "toolcall_delta" "toolcall_end") + (when (and pilish--hover-assistant (equal event-type "toolcall_start")) + (setq pilish--hover-assistant + (plist-put pilish--hover-assistant :streamed t))) (pilish--set-activity-phase "running") (pilish--handle-toolcall-message-event msg-event)) ("error" @@ -1346,18 +1377,35 @@ Updates buffer-local state and renders display updates." (pilish--display-error (plist-get msg-event :reason)))))) ("message_end" (let* ((message (plist-get event :message)) - (assistant-p (equal (plist-get message :role) "assistant"))) + (role (plist-get message :role)) + (assistant-p (equal role "assistant")) + (duration + (and assistant-p + (plist-get pilish--hover-assistant :streamed) + (equal (plist-get pilish--hover-assistant :timestamp) + (plist-get message :timestamp)) + (not (member (plist-get message :stopReason) '("error" "aborted"))) + (not pilish--aborted) + (pilish--hover-elapsed + (plist-get pilish--hover-assistant :time) hover-time)))) + (when assistant-p (setq pilish--hover-assistant nil)) ;; Display error if message ended with error (e.g., API error) (when (equal (plist-get message :stopReason) "error") (pilish--display-error (plist-get message :errorMessage))) ;; The completed assistant message is authoritative over streamed - ;; preview identities, arguments, and membership. + ;; preview identities, arguments, membership, ownership, and usage. (when assistant-p (when (plist-member message :content) (pilish--reconcile-toolcall-previews message)) (pilish--reset-toolcall-streams) - (pilish--refresh-header))) - (pilish--render-complete-message)) + (pilish--refresh-header)) + (when (equal role "toolResult") + (pilish--hover-update-tool-from-result message)) + (when assistant-p + (pilish--render-complete-message) + ;; message_end.message is authoritative; state has already cleared + ;; :current-message before this handler runs. + (pilish--apply-assistant-message-hover message duration)))) ("tool_execution_start" (pilish--set-activity-phase "running") (let* ((tool-call-id (plist-get event :toolCallId)) @@ -1370,6 +1418,7 @@ Updates buffer-local state and renders display updates." (unless block (setq block (pilish--display-tool-start (plist-get event :toolName) args tool-call-id))) + (pilish--tool-block-set-execution-start block hover-time) ;; Update header and path from authoritative args. ;; During streaming, the header may show placeholders since delta ;; args can be partial. Execution start carries the real args. @@ -1389,16 +1438,21 @@ Updates buffer-local state and renders display updates." ;; Retrieve cached args since tool_execution_end doesn't include args (args (when (and tool-call-id pilish--tool-args-cache) (prog1 (gethash tool-call-id pilish--tool-args-cache) - (remhash tool-call-id pilish--tool-args-cache))))) + (remhash tool-call-id pilish--tool-args-cache)))) + (duration (pilish--hover-elapsed + (and block (pilish--tool-block-execution-start block)) + hover-time))) ;; The authoritative result supersedes any pending preview; discard ;; it first so completion renders exactly once. (pilish--discard-pending-tool-update tool-call-id) (pilish--display-tool-end (plist-get event :toolName) - args - (plist-get result :content) - (plist-get result :details) - (plist-get event :isError) - block))) + args + (plist-get result :content) + (plist-get result :details) + (plist-get event :isError) + block) + (when block + (pilish--complete-tool-hover block (plist-get event :toolName) args duration)))) ("tool_execution_update" (let ((tool-call-id (plist-get event :toolCallId)) (partial-result (plist-get event :partialResult))) @@ -1424,6 +1478,7 @@ Updates buffer-local state and renders display updates." (pilish--set-canonical-messages (plist-get pilish--state :messages)) (pilish--display-agent-end) + (pilish--hover-clear-live-state) (pilish--update-hot-tail-boundary) (pilish--queue-tool-cooling-outside-hot-tail)) ("auto_retry_start" @@ -1437,7 +1492,7 @@ Updates buffer-local state and renders display updates." ("extension_error" (pilish--display-extension-error event)) ("extension_ui_request" - (pilish--handle-extension-ui-request event)))) + (pilish--handle-extension-ui-request event))))) ;;;; Tool Output @@ -1540,7 +1595,8 @@ overlays are left alone." (clrhash pilish--tool-args-cache)) (when pilish--live-tool-blocks (clrhash pilish--live-tool-blocks)) - (pilish--reset-toolcall-streams)) + (pilish--reset-toolcall-streams) + (pilish--hover-clear-live-state)) (cl-defstruct (pilish--tool-block (:constructor pilish--make-tool-block)) @@ -1555,7 +1611,9 @@ overlays are left alone." offset line-map last-tail - image-previews) + image-previews + execution-start + help-echo) (cl-defstruct (pilish--toolcall-stream (:conc-name pilish--tool-stream-) @@ -1709,9 +1767,22 @@ needed for compatibility, the current non-keyed pending block." (overlay-put ov 'pilish-line-map (pilish--tool-block-line-map block)) (overlay-put ov 'pilish-last-tail - (pilish--tool-block-last-tail block))) + (pilish--tool-block-last-tail block)) + (when (pilish--tool-block-help-echo block) + (pilish--tool-block-apply-hover block))) block) +(defun pilish--tool-block-set-execution-start (block time) + "Store local execution-start TIME on BLOCK, not its earlier preview." + (setf (pilish--tool-block-execution-start block) time)) + +(defun pilish--tool-block-apply-hover (block) + "Apply BLOCK's completed help to its current text, below specific UI help." + (when-let* ((help (pilish--tool-block-help-echo block)) + (ov (pilish--tool-block-overlay block)) + (start (overlay-start ov))) + (pilish--set-hover-help start (overlay-end ov) help))) + (defun pilish--tool-emacs-path (path) "Return Pi tool PATH normalized for Emacs in the current chat session." (pilish--emacs-path @@ -1813,6 +1884,192 @@ renders with the normal absent-path placeholder." (when-let* ((path (pilish--tool-path-string path))) (pilish--escape-control-chars-for-display path))) +;;;; Completed block help + +(defun pilish--hover-title (title message) + "Return TITLE with MESSAGE's saved timestamp when available." + (if-let* ((timestamp (plist-get message :timestamp)) + ((numberp timestamp))) + (format "%s · %s" title + (pilish--format-message-timestamp (pilish--ms-to-time timestamp))) + title)) + +(defun pilish--hover-provenance (message) + "Return MESSAGE's provider/model line, without consulting session state." + (string-join + (delq nil (mapcar (lambda (key) + (pilish--normalize-string-or-null (plist-get message key))) + '(:provider :model))) + " / ")) + +(defun pilish--assistant-hover-help (message &optional duration) + "Return native reply help for final MESSAGE and optional live DURATION." + (let ((lines (list (pilish--hover-title "Reply" message))) + (provenance (pilish--hover-provenance message)) + (usage (plist-get message :usage))) + (unless (string-empty-p provenance) (push provenance lines)) + (dolist (group '(("Message tokens: " (:input . "input") (:output . "output")) + ("Cache: " (:cacheRead . "read") (:cacheWrite . "write")))) + (let (fields) + (dolist (field (cdr group)) + (when-let* ((value (plist-get usage (car field))) + ((numberp value))) + (push (format "%s %s" (cdr field) (pilish--format-number value)) fields))) + (when fields + (push (concat (car group) (string-join (nreverse fields) " · ")) lines)))) + (when duration + (push (concat (format "Stream: %.1f s" duration) + (when-let* ((output (plist-get usage :output)) + ((numberp output))) + (format " · ~%.0f output tokens/s" (/ (float output) duration)))) + lines)) + (help--docstring-quote (string-join (nreverse lines) "\n")))) + +(defun pilish--thinking-hover-help (message normalized) + "Return thinking help for MESSAGE and NORMALIZED text, without an excerpt." + (let ((provenance (pilish--hover-provenance message))) + (help--docstring-quote + (concat (pilish--hover-title "Thinking" message) + " · " (pilish--thinking-line-count-label + (length (split-string normalized "\n" nil))) + (unless (string-empty-p provenance) (concat "\n" provenance)))))) + +(defun pilish--tool-hover-help (tool-name args &optional result) + "Return native tool help for TOOL-NAME, ARGS and matching saved RESULT." + (let* ((title (if (member tool-name '("bash" "read" "write" "edit")) + (capitalize tool-name) + (pilish--tool-display-value-string tool-name "Tool"))) + (hint (pcase tool-name + ("bash" (pilish--tool-arg-get args :command)) + ((or "read" "write" "edit") (pilish--tool-arg-path args))))) + (when (and (stringp hint) (equal tool-name "read")) + (let (range) + (dolist (field '(:offset :limit)) + (when-let* ((value (pilish--tool-arg-get args field)) + ((numberp value))) + (push (format "%s %s" (substring (symbol-name field) 1) value) range))) + (when range (setq hint (format "%s (%s)" hint (string-join (nreverse range) ", ")))))) + ;; Quote once, after abbreviation: native help substitutes documentation + ;; escapes such as \\{...}, even in shell commands and file names. + (help--docstring-quote + (concat (pilish--hover-title title result) + (when (and (stringp hint) (not (string-blank-p hint))) + (concat "\n" (pilish--truncate-string + (pilish--escape-control-chars-for-display + (string-trim (replace-regexp-in-string "[ \t\n\r\f]+" " " hint))) + 80))))))) + +(defun pilish--set-hover-help (start end help &optional object) + "Set native HELP on START..END in OBJECT, or the current buffer. +HELP is already quoted for native documentation substitution. Save fallback +throughout the span, including underneath specific help, but only replace +absent or previously owned help. Links, images and buttons keep precedence." + (let ((inhibit-read-only t) + (pos start)) + (while (< pos end) + (let* ((existing (get-text-property pos 'help-echo object)) + (next (min (next-single-property-change pos 'help-echo object end) + (next-single-property-change pos 'pilish-hover-help object end)))) + (when (or (null existing) + (equal existing (get-text-property pos 'pilish-hover-help object))) + (put-text-property pos next 'help-echo help object)) + (setq pos next))) + (add-text-properties start end + `(pilish-hover-help ,help rear-nonsticky t) object))) + +(defun pilish--fontify-with-hover-help (function start end &rest args) + "Fontify START..END with FUNCTION and ARGS, letting native help win. +Mask fallback at the native unfontification seam: its bounds already include +line/multiline expansion, unlike the original fontification request. Restore +cached fallback after native links have supplied their more-specific help." + (let ((unfontify font-lock-unfontify-region-function) + ranges) + (let ((font-lock-unfontify-region-function + (lambda (beg end) + (save-restriction + (widen) + (let ((pos beg)) + ;; No completed help in a streaming span: one property lookup. + (while (setq pos (text-property-not-all pos end 'pilish-hover-help nil)) + (let ((limit (next-single-property-change pos 'pilish-hover-help nil end)) + (help (get-text-property pos 'pilish-hover-help))) + (push (list pos limit help) ranges) + (while (< pos limit) + (let ((next (next-single-property-change pos 'help-echo nil limit))) + (when (equal (get-text-property pos 'help-echo) help) + (remove-text-properties pos next '(help-echo nil))) + (setq pos next))))))) + (funcall unfontify beg end)))) + (with-silent-modifications + (unwind-protect + (apply function start end args) + (save-restriction + (widen) + (dolist (range ranges) + (pilish--set-hover-help (car range) (cadr range) (nth 2 range))))))))) + +(defun pilish--apply-assistant-message-hover (message &optional duration) + "Apply final MESSAGE help and live DURATION inside its message markers." + (when (and pilish--message-start-marker pilish--streaming-marker) + (let ((help (pilish--assistant-hover-help message duration)) + (start (marker-position pilish--message-start-marker)) + (end (marker-position pilish--streaming-marker))) + ;; Tools have their own ownership. Work only inside this message, not + ;; the shared Assistant heading or content indexes reused next message. + (dolist (range (pilish--ranges-excluding-property start end 'pilish-thinking-block)) + (let ((pos (car range))) + (dolist (ov (pilish--tool-block-overlays-in-region (car range) (cdr range))) + (pilish--set-hover-help pos (max pos (overlay-start ov)) help) + (setq pos (max pos (overlay-end ov)))) + (pilish--set-hover-help pos (cdr range) help))) + (let ((pos start) (inhibit-read-only t)) + (while (< pos end) + (let ((next (next-single-property-change pos 'pilish-thinking-block nil end))) + (when-let* ((normalized (get-text-property pos 'pilish-thinking-normalized))) + (let ((thinking-help (pilish--thinking-hover-help message normalized))) + (put-text-property pos next 'pilish-thinking-help thinking-help) + (pilish--set-hover-help pos next thinking-help))) + (setq pos next))))))) + +(defun pilish--hover-elapsed (start end) + "Return elapsed seconds for positive local START..END boundaries, or nil." + (when (and (numberp start) (numberp end) (> end start)) (- end start))) + +(defun pilish--hover-clear-live-state () + "Clear unfinished hover measurements and pending result associations." + (setq pilish--hover-assistant nil) + (when pilish--live-tool-blocks + (maphash (lambda (_id block) + (setf (pilish--tool-block-execution-start block) nil)) + pilish--live-tool-blocks)) + (when pilish--hover-pending-tool-blocks + (clrhash pilish--hover-pending-tool-blocks))) + +(defun pilish--complete-tool-hover (block tool-name args duration) + "Complete BLOCK's TOOL-NAME/ARGS help with DURATION, awaiting its saved result." + (setf (pilish--tool-block-help-echo block) + (concat (pilish--tool-hover-help tool-name args) + (when duration (format "\nExecution time: %.1f s" duration)))) + (pilish--tool-block-apply-hover block) + (unless pilish--hover-pending-tool-blocks + (setq pilish--hover-pending-tool-blocks + (make-hash-table :test 'equal :weakness 'value))) + (puthash (pilish--tool-block-tool-call-id block) + block pilish--hover-pending-tool-blocks)) + +(defun pilish--hover-update-tool-from-result (result) + "Attach RESULT's saved timestamp to its completed block by call ID." + (when-let* ((pending pilish--hover-pending-tool-blocks) + (id (plist-get result :toolCallId)) + (block (gethash id pending))) + (remhash id pending) + (when-let* ((help (pilish--tool-block-help-echo block))) + (let ((split (string-match "\n" help))) + (setf (pilish--tool-block-help-echo block) + (concat (pilish--hover-title (substring help 0 split) result) + (and split (substring help split))))) + (pilish--tool-block-apply-hover block)))) + (defun pilish--tool-render-path-metadata (path) "Return passive render metadata for backend tool PATH. The returned plist may contain: @@ -2181,6 +2438,9 @@ until an authoritative tool execution/history event supplies it." (ov (pilish--tool-block-overlay block))) (when-let* ((end-marker (pilish--tool-block-end-marker block))) (set-marker-insertion-type end-marker nil)) + ;; Forced finalization is not an observed execution end. Only the event + ;; handler may calculate a duration, before this boundary is discarded. + (setf (pilish--tool-block-execution-start block) nil) (overlay-put ov 'face face) (pilish--tool-block-refresh-overlay block) (pilish--tool-block-unregister block) @@ -3503,7 +3763,8 @@ coordinates from before the rewrites." (pilish--completed-thinking-rendered-from-normalized (plist-get block :normalized) (plist-get block :order) - display))) + display + (plist-get block :help)))) (let* ((start (plist-get block :start)) (end (plist-get block :end)) (new-bounds (pilish--replace-thinking-block-region @@ -3523,7 +3784,8 @@ was toggled successfully." (rendered (pilish--completed-thinking-rendered-from-normalized normalized order - (if (eq display 'hidden) 'visible 'hidden)))) + (if (eq display 'hidden) 'visible 'hidden) + (plist-get block :help)))) (let* ((original-pos (point)) (new-bounds (pilish--replace-thinking-block block rendered)) (new-start (car new-bounds)) @@ -3840,6 +4102,7 @@ buttons, full content, markers, and absolute buffer positions." :raw-path (overlay-get overlay 'pilish-tool-raw-path) :path-error (overlay-get overlay 'pilish-tool-path-error) :offset (overlay-get overlay 'pilish-tool-offset) + :help (and record (pilish--tool-block-help-echo record)) ;; Only collapsed previews need the small visible-line map. :line-map (and collapsed (overlay-get overlay 'pilish-line-map)) @@ -3906,7 +4169,9 @@ diff annotations." (pilish--ensure-cold-tool-property-nonsticky) (add-text-properties ov-start (point) - `(pilish-cold-tool-block ,target-metadata))) + `(pilish-cold-tool-block ,target-metadata)) + (when-let* ((help (plist-get target-metadata :help))) + (pilish--set-hover-help ov-start (point) help))) t)))) (defun pilish--cool-completed-tool-blocks (overlays) @@ -6603,9 +6868,10 @@ MESSAGE has no visible text content." (t nil)))) (defun pilish--completed-thinking-rendered-from-normalized - (normalized &optional block-order display) + (normalized &optional block-order display help) "Return completed thinking NORMALIZED text rendered for DISPLAY. BLOCK-ORDER identifies the logical completed-thinking block across rerenders. +HELP is the owning message's native thinking help when known. Returns nil when NORMALIZED has no visible completed-thinking content." (unless (string-empty-p normalized) (let ((display (or display (pilish--thinking-display-mode)))) @@ -6615,23 +6881,30 @@ Returns nil when NORMALIZED has no visible completed-thinking content." (_ (pilish--thinking-blockquote-text normalized))) (or block-order (pilish--next-thinking-block-order)) normalized - display)))) + display + help)))) (defun pilish--completed-thinking-rendered-text - (text &optional block-order display) + (text &optional block-order display help) "Return completed thinking TEXT rendered for DISPLAY. BLOCK-ORDER identifies the logical completed-thinking block across rerenders. +HELP is the owning message's native thinking help when known. Returns nil when TEXT normalizes to no visible thinking content." (pilish--completed-thinking-rendered-from-normalized (pilish--thinking-normalize-text text) block-order - display)) + display + help)) -(defun pilish--render-history-thinking (text) - "Render completed thinking TEXT during session history replay. +(defun pilish--render-history-thinking (text &optional message) + "Render completed thinking TEXT for assistant MESSAGE during history replay. Uses the current buffer's completed-thinking display mode." - (when-let* ((rendered (pilish--completed-thinking-rendered-text text))) - (pilish--render-history-text rendered))) + (let* ((normalized (pilish--thinking-normalize-text text)) + (help (pilish--thinking-hover-help message normalized))) + (when-let* ((rendered + (pilish--completed-thinking-rendered-from-normalized + normalized nil nil help))) + (pilish--render-history-text rendered help)))) (defun pilish--build-tool-result-index (messages) "Build hash-table mapping toolCallId to toolResult message from MESSAGES." @@ -6680,22 +6953,26 @@ synchronous pass decorates candidate tables in the recent hot tail." (narrow-to-region start end) (pilish--decorate-tables-in-region start end))))) -(defun pilish--render-history-text (text) +(defun pilish--render-history-text (text &optional help) "Render TEXT as markdown content with proper isolation. +HELP, when non-nil, is applied to the inserted content. Ensures markdown structures don't leak to subsequent content. Display-only table decoration is applied after deferred history insertion." (when (and text (not (string-empty-p text))) (let ((start (with-current-buffer (pilish--get-chat-buffer) (point-max)))) (pilish--append-to-chat text) (with-current-buffer (pilish--get-chat-buffer) - ;; History replay should keep rendering even if markdown - ;; fontification trips over a tree-sitter/runtime mismatch. - ;; Preserve debugger behavior when `debug-on-error' is non-nil. - (unless (pilish--history-postprocessing-deferred-p) - (condition-case-unless-debug nil - (font-lock-ensure start (point-max)) - (error nil)) - (pilish--decorate-tables-in-region start (point-max)))) + (let ((content-end (point-max))) + ;; History replay should keep rendering even if markdown + ;; fontification trips over a tree-sitter/runtime mismatch. + ;; Preserve debugger behavior when `debug-on-error' is non-nil. + (unless (pilish--history-postprocessing-deferred-p) + (condition-case-unless-debug nil + (font-lock-ensure start content-end) + (error nil)) + (pilish--decorate-tables-in-region start content-end)) + (when help + (pilish--set-hover-help start content-end help)))) ;; Two trailing newlines reset any open markdown list/paragraph context (pilish--append-to-chat "\n\n")))) @@ -6703,33 +6980,39 @@ Display-only table decoration is applied after deferred history insertion." "Render a single tool from history: TOOL-CALL block with its RESULT. TOOL-CALL is a content block plist with :type \"toolCall\", :id, :name, and :arguments. RESULT is the matching toolResult message, or nil." - (let ((tool-name (plist-get tool-call :name)) - (args (plist-get tool-call :arguments))) - (pilish--display-tool-start tool-name args) + (let* ((tool-name (plist-get tool-call :name)) + (args (plist-get tool-call :arguments)) + (block (pilish--display-tool-start tool-name args))) (if result (pilish--display-tool-end tool-name args (plist-get result :content) (plist-get result :details) - (plist-get result :isError)) - (pilish--tool-overlay-finalize 'pilish-tool-block) + (plist-get result :isError) + block) + (pilish--tool-overlay-finalize 'pilish-tool-block block) (let ((inhibit-read-only t)) - (save-excursion (goto-char (point-max)) (insert "\n")))))) + (save-excursion (goto-char (point-max)) (insert "\n")))) + (setf (pilish--tool-block-help-echo block) + (pilish--tool-hover-help tool-name args result)) + (pilish--tool-block-apply-hover block))) (defun pilish--render-history-assistant-content (message results) "Render assistant MESSAGE content blocks in source order. RESULTS maps toolCallId strings to matching toolResult messages." (let ((content (plist-get message :content)) + (help (pilish--assistant-hover-help message)) (pending-text nil)) (cl-labels ((flush-text () (when pending-text (pilish--render-history-text - (string-join (nreverse pending-text) "")) + (string-join (nreverse pending-text) "") + help) (setq pending-text nil)))) (cond ((stringp content) (unless (string-empty-p content) - (pilish--render-history-text content))) + (pilish--render-history-text content help))) ((vectorp content) (dolist (block (pilish--content-block-list content)) (let ((block-type (plist-get block :type))) @@ -6742,7 +7025,8 @@ RESULTS maps toolCallId strings to matching toolResult messages." (flush-text) (pilish--render-history-thinking (pilish--render-safe-string - (plist-get block :thinking)))) + (plist-get block :thinking)) + message)) ("toolCall" (flush-text) (pilish--render-history-tool diff --git a/pilish-ui.el b/pilish-ui.el index aabb99f..aa1a018 100644 --- a/pilish-ui.el +++ b/pilish-ui.el @@ -59,6 +59,8 @@ (declare-function pilish--dispatch-button "pilish-render") (declare-function pilish--cleanup-on-kill "pilish-render") (declare-function pilish--restore-tool-properties "pilish-render") +(declare-function pilish--fontify-with-hover-help "pilish-render") +(declare-function pilish--hover-clear-live-state "pilish-render") (declare-function pilish--maybe-refresh-hot-tail-tables "pilish-table") ;; pilish-input.el (input buffer commands) @@ -900,6 +902,9 @@ This is a read-only buffer showing the conversation history." ;; Run after font-lock to undo markdown damage in tool overlays. (jit-lock-register #'pilish--restore-tool-properties) + ;; Let native Markdown help take precedence over completed-block fallback. + (add-function :around (local 'font-lock-fontify-region-function) + #'pilish--fontify-with-hover-help) ;; Compute theme-derived faces used by chat overlays. (pilish--update-theme-derived-faces) @@ -1159,7 +1164,11 @@ CHAT-BUFFER defaults to the current buffer." Resets cached process version and starts a delayed version probe for new live processes in interactive sessions." (unless (eq process pilish--process) - (pilish--invalidate-model-change)) + (pilish--invalidate-model-change) + ;; Reload can replace a process without its exit handler or a successful + ;; subsequent history refresh. End live measurements at this boundary. + (when (fboundp 'pilish--hover-clear-live-state) + (pilish--hover-clear-live-state))) (setq pilish--process process pilish--process-version nil) (when (and (processp process) diff --git a/test/pilish-render-test.el b/test/pilish-render-test.el index 344afa5..193ec05 100644 --- a/test/pilish-render-test.el +++ b/test/pilish-render-test.el @@ -15,6 +15,879 @@ (require 'ert) (require 'pilish) (require 'pilish-test-common) +(require 'help-at-pt) + +;;; Hover metadata + +(defun pilish-test--hover-pos (text) + "Return the first buffer position of TEXT, failing if it is absent." + (save-excursion + (goto-char (point-min)) + (search-forward text) + (- (point) (length text)))) + +(defun pilish-test--hover-help (text) + "Return effective native help at the first occurrence of TEXT." + (get-char-property (pilish-test--hover-pos text) 'help-echo)) + +(defun pilish-test--hover-displayed-help (text) + "Invoke native local help inside TEXT and return the exact displayed message." + (save-excursion + (goto-char (+ 2 (pilish-test--hover-pos text))) + (let (shown) + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) + (when fmt (setq shown (apply #'format fmt args)))))) + (display-local-help)) + (should (stringp shown)) + (substring-no-properties shown)))) + +(defun pilish-test--hover-message (content &rest properties) + "Return an assistant fixture with CONTENT and overriding PROPERTIES." + (append properties + (list :role "assistant" :timestamp 1784817120000 + :provider "anthropic" :model "claude-sonnet-4-6" + :usage '(:input 1240 :output 386 :cacheRead 8192 :cacheWrite 0 + :cost (:total 123) :totalTokens 99999) + :stopReason "stop" :content content))) + +(defun pilish-test--hover-stream-content (message) + "Stream MESSAGE content through the normal delta-only event handler." + (cl-loop for block across (plist-get message :content) + for index from 0 + for type = (plist-get block :type) + do (pcase type + ((or "text" "thinking") + (pilish-test--send-assistant-message-update + `(:type ,(concat type "_start") :contentIndex ,index)) + (pilish-test--send-assistant-message-update + `(:type ,(concat type "_delta") :contentIndex ,index + :delta ,(plist-get block (if (equal type "text") + :text :thinking)))) + (pilish-test--send-assistant-message-update + `(:type ,(concat type "_end") :contentIndex ,index))) + ("toolCall" + (pilish-test--send-assistant-message-update + `(:type "toolcall_start" :contentIndex ,index + :id ,(plist-get block :id) :toolName ,(plist-get block :name))) + (pilish-test--send-assistant-message-update + `(:type "toolcall_delta" :contentIndex ,index + :delta ,(json-serialize (plist-get block :arguments)))))))) + +(defun pilish-test--hover-reply-help (&optional timestamp) + "Return the expected static help for the standard fixture at TIMESTAMP." + (concat "Reply · " + (pilish--format-message-timestamp + (pilish--ms-to-time (or timestamp 1784817120000))) + "\nanthropic / claude-sonnet-4-6" + "\nMessage tokens: input 1,240 · output 386" + "\nCache: read 8,192 · write 0")) + +(ert-deftest pilish-test-hover-static-live-final-message-ownership () + "Final payload owns all its blocks, not a shared heading or selected model." + (with-temp-buffer + (pilish-chat-mode) + (let* ((first (pilish-test--hover-message + [(:type "text" :text "First reply.\n") + (:type "thinking" :thinking "Private thought\nSecond line") + (:type "text" :text "After thought.")])) + (second (pilish-test--hover-message + [(:type "text" :text "Second reply.")] + :timestamp 1784817180000 :provider "other" :model "owned" + :usage '(:input 0 :output 0)))) + ;; Same local clock at both boundaries: this step tests static metadata. + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time 10)))) + (pilish--handle-display-event '(:type "agent_start")) + (pilish--handle-display-event + `(:type "message_start" :message + ,(pilish-test--hover-message [] :usage nil :model "provisional"))) + (pilish-test--hover-stream-content first) + (setq pilish--state '(:model (:provider "selected" :id "not-owned"))) + (pilish--handle-display-event `(:type "message_end" :message ,first)) + (should-not (plist-get pilish--state :current-message)) + (pilish--handle-display-event `(:type "message_start" :message ,second)) + (pilish-test--hover-stream-content second) + (pilish--handle-display-event `(:type "message_end" :message ,second))) + (should (= 1 (pilish-test--count-matches "Assistant\n===" (buffer-string)))) + (should (equal (pilish-test--hover-help "First reply") + (pilish-test--hover-reply-help))) + (should (equal (pilish-test--hover-help "After thought") + (pilish-test--hover-reply-help))) + (should (equal (pilish-test--hover-help "Second reply") + (concat "Reply · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817180000)) + "\nother / owned\nMessage tokens: input 0 · output 0"))) + (should (equal (pilish-test--hover-help "Private thought") + (concat "Thinking · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817120000)) + " · 2 lines\nanthropic / claude-sonnet-4-6"))) + (should-not (pilish-test--hover-help "Assistant"))))) + +(ert-deftest pilish-test-hover-static-history-ownership-and-omissions () + "Replay keeps message-local provenance and distinguishes absent from zero." + (with-temp-buffer + (pilish-chat-mode) + (let* ((first (pilish-test--hover-message + [(:type "text" :text "Saved reply") + (:type "thinking" :thinking "Saved reasoning")])) + (second (pilish-test--hover-message + [(:type "text" :text "Bare reply")] + :timestamp nil :provider nil :model nil :usage nil)) + (third (pilish-test--hover-message + [(:type "text" :text "Zero reply")] + :timestamp nil :provider nil :model nil + :usage '(:input 0 :cacheWrite 0)))) + (pilish--display-session-history (vector first second third)) + (should (equal (pilish-test--hover-help "Saved reply") + (pilish-test--hover-reply-help))) + (should (equal (pilish-test--hover-help "Bare reply") "Reply")) + (should (equal (pilish-test--hover-help "Zero reply") + "Reply\nMessage tokens: input 0\nCache: write 0")) + (should (equal (pilish-test--hover-help "Saved reasoning") + (concat "Thinking · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817120000)) + " · 1 line\nanthropic / claude-sonnet-4-6")))))) + +(ert-deftest pilish-test-hover-static-tools-use-matching-results-and-bounded-hints () + "Tool help uses the matching result timestamp, safe hints, and no nested usage." + (with-temp-buffer + (pilish-chat-mode) + (let* ((command (concat "printf 'λ\a'\n\t\"quoted\" " (make-string 150 ?x))) + (message (pilish-test--hover-message + (vector (pilish-test--toolcall "A" "bash" `(:command ,command)) + (pilish-test--toolcall "B" "read" + '(:path "a.el" :offset 4 :limit 8)) + (pilish-test--toolcall "C" "custom_tool" + '(:secret "do-not-show"))))) + (a '(:role "toolResult" :toolCallId "A" :timestamp 1784817120000 + :usage (:input 900 :output 42) + :content [(:type "text" :text "Output A")])) + (b '(:role "toolResult" :toolCallId "B" :timestamp 1784817180000 + :content [(:type "text" :text "Output B")])) + (c '(:role "toolResult" :toolCallId "C" + :content [(:type "text" :text "Output C")]))) + (pilish--display-session-history (vector message b c a)) + (let ((expected + (concat "Bash · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817120000)) + "\n" (pilish--truncate-string + (concat "printf 'λ\\x07' \"quoted\" " + (make-string 150 ?x)) 80)))) + (should (equal (pilish-test--hover-displayed-help "Output A") expected)) + (should (equal (pilish-test--hover-displayed-help "$ printf") expected))) + (should (equal (pilish-test--hover-help "Output B") + (concat "Read · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817180000)) + "\na.el (offset 4, limit 8)"))) + (should (equal (pilish-test--hover-help "Output C") "custom_tool")) + (should (string-match-p (regexp-quote (make-string 150 ?x)) + (buffer-string))) + (should (equal (plist-get (plist-get (aref (plist-get message :content) 0) + :arguments) :command) + command))))) + +(ert-deftest pilish-test-hover-preserves-thinking-tool-rewrites-and-cooling () + "Native help survives local/global folds, body/header rewrites, and cooling." + (let ((pilish-bash-preview-lines 2)) + (with-temp-buffer + (pilish-chat-mode) + (pilish--display-session-history + (vector (pilish-test--hover-message + [(:type "thinking" :thinking "Folded reasoning\nMore reasoning") + (:type "toolCall" :id "A" :name "bash" :arguments (:command "ls"))]) + '(:role "toolResult" :toolCallId "A" :timestamp 1784817120000 + :content [(:type "text" :text "row1\nrow2\nrow3\nrow4")])) ) + (let ((thinking-help (pilish-test--hover-help "Folded reasoning")) + (tool-help (pilish-test--hover-help "row1")) + (ov (car (pilish--tool-block-overlays-in-region (point-min) (point-max))))) + (should (string-prefix-p "Thinking · " thinking-help)) + (should (string-prefix-p "Bash · " tool-help)) + (goto-char (pilish-test--hover-pos "Folded reasoning")) + (pilish-toggle-tool-section) + (should (equal (get-char-property (point) 'help-echo) thinking-help)) + (let (messages) + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) (push (apply #'format fmt args) messages)))) + (pilish--set-chat-thinking-display 'visible)) + (should (equal messages '("Pi: This chat now shows completed thinking")))) + (should (equal (pilish-test--hover-help "More reasoning") thinking-help)) + (goto-char (pilish-test--hover-pos "row1")) + (pilish-toggle-tool-section) + (should (equal (pilish-test--hover-help "row4") tool-help)) + (pilish-toggle-tool-section) + (pilish--display-tool-update-header + "bash" '(:command "ls --color=never") (pilish--tool-block-from-overlay ov)) + (should (equal (pilish-test--hover-help "$ ls --color=never") tool-help)) + (should (pilish--cool-tool-overlay ov)) + (should-not (overlay-buffer ov)) + (should (equal (pilish-test--hover-help "row1") tool-help)) + (let ((inhibit-read-only t)) + (goto-char (point-max)) + (insert "Outside metadata")) + (should-not (pilish-test--hover-help "Outside metadata")))))) + +(ert-deftest pilish-test-hover-specific-native-help-and-nonsticky-bounds () + "Deferred replay and refontification keep native links, images, and buttons." + (let ((pilish-bash-preview-lines 2)) + (with-temp-buffer + (pilish-chat-mode) + (pilish--display-session-history + (vector + (pilish-test--hover-message + [(:type "text" :text "Before [real link](https://example.org/hover) after.") + (:type "thinking" :thinking "Thought [thinking link](https://example.org/thought)") + (:type "toolCall" :id "A" :name "bash" :arguments (:command "echo image"))]) + `(:role "toolResult" :toolCallId "A" + :content [(:type "text" :text "line1\nline2\nline3\nline4") + (:type "image" :mimeType "image/png" + :data ,(pilish-test--prompt-image-base64 'png))]))) + (font-lock-ensure (point-min) (point-max)) + (let ((link-help (pilish-test--hover-help "real link")) + (thinking-link-help (pilish-test--hover-help "thinking link"))) + (should (string-match-p "https://example.org/hover" link-help)) + (should (string-match-p "https://example.org/thought" thinking-link-help)) + (should (button-at (pilish-test--hover-pos "real link"))) + (font-lock-flush) + (font-lock-ensure) + (should (equal link-help (pilish-test--hover-help "real link"))) + (should (equal thinking-link-help (pilish-test--hover-help "thinking link"))) + (goto-char (pilish-test--hover-pos "thinking link")) + (pilish-toggle-tool-section) + (pilish-toggle-tool-section) + (font-lock-ensure) + (should (equal thinking-link-help (pilish-test--hover-help "thinking link"))) + (should (equal (pilish-test--hover-help "Before") + (pilish-test--hover-reply-help)))) + (let* ((ov (car (pilish--tool-block-overlays-in-region (point-min) (point-max)))) + (button (pilish--find-toggle-button-in-region + (overlay-start ov) (overlay-end ov))) + (image-pos (copy-marker + (text-property-any (overlay-start ov) (overlay-end ov) + 'pilish-image-preview t)))) + (should button) + ;; Put more-specific help over already-owned fallback help, then + ;; exercise the real replacement path. Ownership tags alone must + ;; not authorize overwriting a newer specific help value. + (let* ((start (pilish-test--hover-pos "line1")) + (overlay-button (make-button start (+ start 5) + 'help-echo "Native overlay control")) + (inhibit-read-only t)) + (button-put button 'help-echo "Native text control") + (pilish--display-tool-update-header + "bash" '(:command "echo changed") (pilish--tool-block-from-overlay ov)) + (should (equal (get-char-property (button-start button) 'help-echo) + "Native text control")) + (should (equal (pilish-test--hover-help "line1") "Native overlay control")) + (delete-overlay overlay-button)) + (should image-pos) + (should (string-match-p "image/png" + (get-char-property image-pos 'help-echo)))) + (let ((inhibit-read-only t)) + (goto-char (point-min)) + (insert "Outside before\n") + (goto-char (point-max)) + (insert "Outside after")) + (should-not (pilish-test--hover-help "Outside before")) + (should-not (pilish-test--hover-help "Outside after"))))) + +(defun pilish-test--hover-tool-start (id) + "Send an execution start for ID; A and B deliberately share arguments." + (pilish--handle-display-event + `(:type "tool_execution_start" :toolCallId ,id :toolName "bash" + :args (:command "same command")))) + +(defun pilish-test--hover-tool-end (id &optional output) + "Send an execution end for ID with OUTPUT, without a saved timestamp." + (pilish--handle-display-event + `(:type "tool_execution_end" :toolCallId ,id :toolName "bash" + :result (:content [(:type "text" :text ,(or output (concat "Output " id)))])))) + +(ert-deftest pilish-test-hover-stream-clock-is-message-local-and-before-rendering () + "12.4 seconds and 386 final output tokens give ~31, excluding rendering." + (with-temp-buffer + (pilish-chat-mode) + (pilish--handle-display-event '(:type "agent_start")) + (let* ((now 10) + (message (pilish-test--hover-message + [(:type "thinking" :thinking "Measured reasoning") + (:type "text" :text "Measured reply")])) + (original-state-update (symbol-function 'pilish--update-state-from-event))) + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time now))) + ((symbol-function 'pilish--update-state-from-event) + (lambda (event) + ;; Boundary work is deliberately expensive in local time. + ;; The sample must precede even the state-update call. + (prog1 (funcall original-state-update event) + (when (equal (plist-get (plist-get event :message) :role) "assistant") + (setq now (+ now 100))))))) + (pilish--handle-display-event `(:type "message_start" :message ,message)) + (pilish-test--hover-stream-content message) + ;; Neither unrelated message lifecycle changes the assistant clock. + (setq now 17) + (dolist (role '("toolResult" "user")) + (pilish--handle-display-event `(:type "message_start" :message (:role ,role))) + (pilish--handle-display-event `(:type "message_end" :message (:role ,role)))) + (setq now 22.4) + (pilish--handle-display-event `(:type "message_end" :message ,message)) + (should (equal (pilish-test--hover-help "Measured reply") + (concat (pilish-test--hover-reply-help) + "\nStream: 12.4 s · ~31 output tokens/s"))) + (should (equal (pilish-test--hover-help "Measured reasoning") + (concat "Thinking · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817120000)) + " · 1 line\nanthropic / claude-sonnet-4-6"))) + ;; Another message reuses content index zero and the visible heading. + (let ((second (pilish-test--hover-message + [(:type "text" :text "Next measured reply")] + :timestamp 1784817180000))) + (setq now 50) + (pilish--handle-display-event `(:type "message_start" :message ,second)) + (pilish-test--hover-stream-content second) + (setq now 52) + (pilish--handle-display-event `(:type "message_end" :message ,second)) + (should (equal (pilish-test--hover-help "Next measured reply") + (concat (pilish-test--hover-reply-help 1784817180000) + "\nStream: 2.0 s · ~193 output tokens/s"))) + (should (string-suffix-p "Stream: 12.4 s · ~31 output tokens/s" + (pilish-test--hover-help "Measured reply")))))))) + +(ert-deftest pilish-test-hover-tool-clocks-exclude-preview-and-late-results () + "Interleaved A10 B11 B12 A15 belongs to call IDs, including late timestamps." + (let ((pilish-bash-preview-lines 2)) + (with-temp-buffer + (pilish-chat-mode) + (let* ((now 1) + (message (pilish-test--hover-message + [(:type "text" :text "Before tools") + (:type "toolCall" :id "A" :name "bash" + :arguments (:command "same command")) + (:type "toolCall" :id "B" :name "bash" + :arguments (:command "same command"))])) + (a '(:role "toolResult" :toolCallId "A" :timestamp 1784817180000 + :content [(:type "text" :text "A1\nA2\nA3\nA4")])) + (b '(:role "toolResult" :toolCallId "B" :timestamp 1784817240000 + :content [(:type "text" :text "Output B")])) + (canonical (vector message a b)) + (snapshot (copy-tree canonical t))) + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time now)))) + (pilish--handle-display-event '(:type "agent_start")) + (pilish--handle-display-event `(:type "message_start" :message ,message)) + (pilish-test--hover-stream-content message) + (setq now 2) + (pilish--handle-display-event `(:type "message_end" :message ,message)) + (setq now 10) + (pilish-test--hover-tool-start "A") + (setq now 11) + (pilish-test--hover-tool-start "B") + (setq now 12) + (pilish-test--hover-tool-end "B") + (setq now 15) + (pilish-test--hover-tool-end "A" "A1\nA2\nA3\nA4") + (should (= 0 (hash-table-count pilish--live-tool-blocks))) + (should (equal (pilish-test--hover-help "A1") + "Bash\nsame command\nExecution time: 5.0 s")) + (should (equal (pilish-test--hover-help "Output B") + "Bash\nsame command\nExecution time: 1.0 s")) + (setq now 100) + (dolist (result (list a b)) + (pilish--handle-display-event `(:type "message_start" :message ,result)) + (pilish--handle-display-event `(:type "message_end" :message ,result))) + (should (= 0 (hash-table-count pilish--hover-pending-tool-blocks))) + (let ((help-a (concat "Bash · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817180000)) + "\nsame command\nExecution time: 5.0 s")) + (help-b (concat "Bash · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817240000)) + "\nsame command\nExecution time: 1.0 s"))) + (should (equal (pilish-test--hover-help "A1") help-a)) + (should (equal (pilish-test--hover-help "Output B") help-b)) + (goto-char (pilish-test--hover-pos "A1")) + (pilish-toggle-tool-section) + (should (equal (pilish-test--hover-help "A4") help-a)) + (pilish-toggle-tool-section) + (let ((ov (car (pilish--tool-block-overlays-in-region + (point) (1+ (point)))))) + (should (pilish--cool-tool-overlay ov)) + (should-not (overlay-buffer ov))) + (should (equal (pilish-test--hover-help "A1") help-a)) + (pilish--handle-display-event `(:type "agent_end" :messages ,canonical)) + (pilish--display-session-history canonical) + (should (equal (pilish-test--hover-help "Before tools") + (pilish-test--hover-reply-help))) + (should (equal (pilish-test--hover-help "A1") + (string-remove-suffix "\nExecution time: 5.0 s" help-a))) + (should (equal (pilish-test--hover-help "Output B") + (string-remove-suffix "\nExecution time: 1.0 s" help-b))))) + (should (equal canonical snapshot)))))) + +(ert-deftest pilish-test-hover-stream-usage-and-invalid-boundaries () + "Zero differs from missing, and missing/nonpositive/mismatched starts omit timing." + (dolist (case '((10 12 (:output 0) "\nStream: 2.0 s · ~0 output tokens/s") + (10 12 nil "\nStream: 2.0 s") + (10 12 (:input 99) "\nStream: 2.0 s") + (10 10 (:output 386) "") + (10 9 (:output 386) "") + (nil 12 (:output 386) "") + (mismatch 12 (:output 386) ""))) + (with-temp-buffer + (pilish-chat-mode) + (let* ((start (nth 0 case)) (now (if (numberp start) start 10)) + (message (pilish-test--hover-message + [(:type "text" :text "Boundary reply")] + :provider nil :model nil :timestamp 1000 :usage (nth 2 case))) + (expected (concat "Reply · " + (pilish--format-message-timestamp (seconds-to-time 1)) + (pcase (nth 2 case) + (`(:output ,value) (format "\nMessage tokens: output %s" value)) + (`(:input ,value) (format "\nMessage tokens: input %s" value))) + (nth 3 case)))) + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time now)))) + (pilish--handle-display-event '(:type "agent_start")) + (when start + (pilish--handle-display-event + `(:type "message_start" :message + ,(if (eq start 'mismatch) (plist-put (copy-sequence message) :timestamp 2000) + message)))) + (pilish-test--hover-stream-content message) + (setq now (nth 1 case)) + (pilish--handle-display-event `(:type "message_end" :message ,message))) + (should (equal (pilish-test--hover-help "Boundary reply") expected)))))) + +(ert-deftest pilish-test-hover-interrupted-and-nonstreamed-replies-have-no-rate () + "Abort/error payloads and messages without observed stream events have no rate." + (dolist (reason '("aborted" "error" "nonstreamed")) + (with-temp-buffer + (pilish-chat-mode) + (let* ((now 10) + (message (pilish-test--hover-message + [(:type "text" :text "Interrupted reply")] + :stopReason reason :errorMessage "Expected interruption"))) + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time now)))) + (pilish--handle-display-event '(:type "agent_start")) + (pilish--handle-display-event `(:type "message_start" :message ,message)) + (if (equal reason "nonstreamed") + ;; There are no stream-start events, even though text is present. + (pilish--display-message-delta "Interrupted reply") + (pilish-test--hover-stream-content message)) + (setq now 12) + (pilish--handle-display-event `(:type "message_end" :message ,message))) + (should (equal (pilish-test--hover-displayed-help "Interrupted reply") + (pilish-test--hover-reply-help))) + (when (equal reason "error") + (should (string-match-p "Expected interruption" (buffer-string)))))))) + +(ert-deftest pilish-test-hover-tool-missing-and-nonpositive-boundaries () + "Tool execution without both ordered local boundaries never invents time." + (dolist (start '(nil 12 13)) + (with-temp-buffer + (pilish-chat-mode) + (let ((now (or start 10))) + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time now)))) + (pilish--handle-display-event '(:type "agent_start")) + (when start (pilish-test--hover-tool-start "A")) + (setq now 12) + (pilish-test--hover-tool-end "A"))) + (should (equal (pilish-test--hover-help "Output A") + (if start "Bash\nsame command" "Bash")))))) + +(ert-deftest pilish-test-hover-identical-thinking-finalization-keeps-properties () + "The unchanged-text thinking-end fast path still acquires final provenance." + (with-temp-buffer + (pilish-chat-mode) + (let ((message (pilish-test--hover-message + [(:type "thinking" :thinking "Identical thinking")])) + (now 10)) + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time now)))) + (pilish--handle-display-event '(:type "agent_start")) + (pilish--handle-display-event `(:type "message_start" :message ,message)) + (pilish-test--send-assistant-message-update '(:type "thinking_start" :contentIndex 0)) + (pilish-test--send-assistant-message-update + '(:type "thinking_delta" :contentIndex 0 :delta "Identical thinking")) + (let ((inhibit-read-only t) + (pos (pilish-test--hover-pos "Identical thinking"))) + (put-text-property pos (1+ pos) 'pilish-test-sentinel t)) + (pilish-test--send-assistant-message-update '(:type "thinking_end" :contentIndex 0)) + (setq now 22.4) + (pilish--handle-display-event `(:type "message_end" :message ,message))) + (should (get-text-property (pilish-test--hover-pos "Identical thinking") + 'pilish-test-sentinel)) + (should (equal (pilish-test--hover-help "Identical thinking") + (concat "Thinking · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817120000)) + " · 1 line\nanthropic / claude-sonnet-4-6")))))) + +(ert-deftest pilish-test-hover-no-metadata-work-on-output-deltas () + "Thinking/text deltas and coalesced execution updates do not format hover." + (pilish-test--with-streaming-assistant + (pilish-test--send-assistant-message-update '(:type "thinking_start")) + (pilish-test--hover-tool-start "A") + (cl-letf (((symbol-function 'pilish--set-hover-help) + (lambda (&rest _) (ert-fail "Hover write on a delta"))) + ((symbol-function 'pilish--assistant-hover-help) + (lambda (&rest _) (ert-fail "Reply formatting on a delta"))) + ((symbol-function 'pilish--tool-hover-help) + (lambda (&rest _) (ert-fail "Tool formatting on a delta")))) + (pilish-test--send-assistant-message-update + '(:type "thinking_delta" :delta "No hover work")) + (pilish-test--send-assistant-message-update + '(:type "text_delta" :delta "No hover work")) + (pilish--handle-display-event + '(:type "tool_execution_update" :toolCallId "A" + :partialResult (:content [(:type "text" :text "Updated output")])) ) + (pilish--flush-tool-updates (current-buffer))) + (pilish--cancel-tool-update-flush) + (should (string-match-p "Updated output" (buffer-string))))) + +(ert-deftest pilish-test-hover-cleanup-clears-pending-and-unfinished-clocks () + "Abort, death and rebuild release associations and do not invent tool ends." + (dolist (cleanup '(abort death rebuild clear)) + (let ((process (start-process "pilish-hover-cleanup" nil "cat"))) + (unwind-protect + (with-temp-buffer + (pilish-chat-mode) + (setq pilish--process process) + (let ((now 10)) + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time now)))) + (pilish--handle-display-event '(:type "agent_start")) + (pilish--handle-display-event + '(:type "message_start" :message (:role "assistant"))) + (pilish-test--hover-tool-start "A") + (pilish-test--hover-tool-start "B") + (setq now 12) + (pilish-test--hover-tool-end "A") + (should (= 1 (hash-table-count pilish--hover-pending-tool-blocks))) + (pcase cleanup + ('abort + (setq pilish--aborted t) + (pilish--handle-display-event '(:type "agent_end" :messages [])) + (should (string-match-p "\\[Aborted\\]" (buffer-string)))) + ('death + (pilish--mark-process-exited process '(:error "Expected death" :exitCode 1)) + (should (string-match-p "Expected death" (buffer-string)))) + ('rebuild (pilish--display-session-history [])) + ('clear (pilish--clear-render-artifacts))) + (should-not pilish--hover-assistant) + (should (= 0 (hash-table-count pilish--hover-pending-tool-blocks))) + ;; No start in this fresh generation: reused ID B gets no + ;; duration from the unfinished execution above. + (pilish--handle-display-event '(:type "agent_start")) + (setq now 100) + (pilish-test--hover-tool-end "B" "New generation B") + (should (equal (pilish-test--hover-help "New generation B") "Bash")) + (pilish--handle-display-event '(:type "agent_end" :messages []))))) + (when (process-live-p process) (delete-process process)))))) + +(ert-deftest pilish-test-hover-two-buffers-and-reused-call-ids-are-isolated () + "Two concurrent chats and later generations never share pending clocks." + (let ((a (generate-new-buffer " *pilish-hover-A*")) + (b (generate-new-buffer " *pilish-hover-B*")) + (now 10)) + (unwind-protect + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time now)))) + (dolist (buffer (list a b)) + (with-current-buffer buffer + (pilish-chat-mode) + (pilish--handle-display-event '(:type "agent_start")) + (pilish-test--hover-tool-start "same")) + (setq now (1+ now))) + (with-current-buffer b + (pilish-test--hover-tool-end "same") + (should (equal (pilish-test--hover-help "Output same") + "Bash\nsame command\nExecution time: 1.0 s"))) + (setq now 15) + (with-current-buffer a + (pilish-test--hover-tool-end "same") + (should (equal (pilish-test--hover-help "Output same") + "Bash\nsame command\nExecution time: 5.0 s")) + (pilish--handle-display-event '(:type "agent_end" :messages [])) + (pilish--handle-display-event '(:type "agent_start"))) + (with-current-buffer b + (pilish--handle-display-event + '(:type "message_end" :message (:role "toolResult" :toolCallId "same" + :timestamp 1784817120000))) + (should (equal (pilish-test--hover-help "Output same") + (concat "Bash · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817120000)) + "\nsame command\nExecution time: 1.0 s")))) + (with-current-buffer a + (setq now 100) + (pilish-test--hover-tool-start "same") + (setq now 102) + (pilish-test--hover-tool-end "same" "Fresh same") + (should (equal (pilish-test--hover-help "Fresh same") + "Bash\nsame command\nExecution time: 2.0 s")) + (should (equal (pilish-test--hover-help "Output same") + "Bash\nsame command\nExecution time: 5.0 s")))) + (pilish-test--kill-live-buffers a b)))) + +(ert-deftest pilish-test-hover-later-markdown-reference-definition-still-resolves () + "Metadata cannot prevent native links when a reference definition arrives later." + (with-temp-buffer + (pilish-chat-mode) + (pilish--display-history-messages + (vector (pilish-test--hover-message + [(:type "text" :text "Earlier [late reference][target] prose.")]))) + (font-lock-ensure) + (pilish--display-history-messages + (vector (pilish-test--hover-message + [(:type "text" :text "[target]: https://example.org/later")] + :timestamp 1784817180000))) + (font-lock-flush) + (font-lock-ensure) + (should (equal (pilish-test--hover-help "late reference") + "https://example.org/later")) + (should (button-at (pilish-test--hover-pos "late reference"))) + (should (equal (pilish-test--hover-help "Earlier") + (pilish-test--hover-reply-help))))) + +(defun pilish-test--hover-complete-and-cool-tool () + "Create and cool a tool, returning its pending count before cooling. +Return before the reachability assertion so mixed compiled/interpreted +execution cannot retain a temporary record on this setup's evaluator stack." + (pilish--handle-display-event '(:type "agent_start")) + (pilish-test--hover-tool-start "A") + (pilish-test--hover-tool-end "A") + (prog1 (hash-table-count pilish--hover-pending-tool-blocks) + (pilish--cool-tool-overlay + (car (pilish--tool-block-overlays-in-region (point-min) (point-max)))))) + +(ert-deftest pilish-test-hover-pending-result-does-not-retain-cooled-record () + "A missing late result must not keep cooled overlays or full output alive." + (with-temp-buffer + (pilish-chat-mode) + (should (= 1 (pilish-test--hover-complete-and-cool-tool))) + (should-not (pilish--tool-block-overlays-in-region (point-min) (point-max))) + (garbage-collect) + (should (= 0 (hash-table-count pilish--hover-pending-tool-blocks))) + (should (string-prefix-p "Bash\nsame command" + (pilish-test--hover-help "Output A"))))) + +(ert-deftest pilish-test-hover-completion-preserves-source-point-and-exact-boundary () + "Hover changes no Markdown text or point, and is nonsticky at the exact end." + (pilish-test--with-streaming-assistant + (let ((message (pilish-test--hover-message + [(:type "text" :text "Unchanged **source** body")]))) + (pilish-test--hover-stream-content message) + (goto-char (pilish-test--hover-pos "source")) + (let ((before (buffer-substring-no-properties (point-min) (point-max))) + (saved-point (point))) + (pilish--handle-display-event `(:type "message_end" :message ,message)) + (should (= (point) saved-point)) + ;; Completion already owns adding a newline; metadata adds no text. + (should (equal (buffer-substring-no-properties (point-min) (point-max)) + (concat before "\n"))) + (should (equal (pilish-test--hover-help "body") + (pilish-test--hover-reply-help))))) + (let ((inhibit-read-only t)) + (goto-char (marker-position pilish--streaming-marker)) + (insert-and-inherit "Immediately outside")) + (should-not (pilish-test--hover-help "Immediately outside")))) + +(ert-deftest pilish-test-hover-new-agent-start-discards-unfinished-tool-clock () + "Even a new agent run after an absent agent_end cannot inherit a tool clock." + (with-temp-buffer + (pilish-chat-mode) + (let ((now 10)) + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time now)))) + (pilish--handle-display-event '(:type "agent_start")) + (pilish-test--hover-tool-start "A") + (setq now 100) + (pilish--handle-display-event '(:type "agent_start")) + (pilish-test--hover-tool-end "A" "After absent end"))) + (should (equal (pilish-test--hover-help "After absent end") + "Bash\nsame command")))) + +(ert-deftest pilish-test-hover-native-partial-fontification-expands-to-inline-link () + "A tiny fontification request must not shield the rest of its native line." + (with-temp-buffer + (pilish-chat-mode) + (pilish--display-session-history + (vector (pilish-test--hover-message + [(:type "text" :text "Before [inline](https://example.org/inline) after.")]))) + (let* ((start (pilish-test--hover-pos "Before")) + (result (font-lock-fontify-region start (1+ start)))) + (should (eq (car result) 'jit-lock-bounds)) + (should (> (cddr result) (1+ start)))) + (should (equal (pilish-test--hover-displayed-help "inline") + "https://example.org/inline")) + (should (button-at (pilish-test--hover-pos "inline"))))) + +(ert-deftest pilish-test-hover-native-split-jit-fontification-multiline-link () + "Adjacent JIT chunks of one multiline link retain its native button and URL." + (with-temp-buffer + (pilish-chat-mode) + ;; Batch Emacs and space-prefixed temp buffers deliberately disable Font + ;; Lock. Enable the real backend before inserting any history; otherwise + ;; jit-lock only runs tool-property restoration and never parses Markdown. + (rename-buffer "pilish-hover-native-jit" t) + (let ((noninteractive nil)) (font-lock-mode 1)) + (should (memq #'font-lock-fontify-region jit-lock-functions)) + (pilish--display-session-history + (vector (pilish-test--hover-message + [(:type "text" :text "Before [long\nmultiline link](https://example.org/target) after.")]))) + (let ((start (pilish-test--hover-pos "Before")) + (split (pilish-test--hover-pos "multiline link"))) + (jit-lock-fontify-now start split) + (jit-lock-fontify-now split (point-max))) + (should (equal (pilish-test--hover-displayed-help "multiline link") + "https://example.org/target")) + (should (button-at (pilish-test--hover-pos "multiline link"))))) + +(ert-deftest pilish-test-hover-native-narrowed-and-full-fontification-contract () + "Native expansion beyond narrowing preserves bounds, point, flags and text." + (dolist (narrow '(nil t)) + (with-temp-buffer + (pilish-chat-mode) + (pilish--display-session-history + (vector (pilish-test--hover-message + [(:type "text" :text "Before [long\nmultiline link](https://example.org/target) after.\nNext line.")]))) + (let ((source (buffer-substring-no-properties (point-min) (point-max))) + (start (pilish-test--hover-pos "multiline link")) + (end (pilish-test--hover-pos "Next line")) + (font-lock-dont-widen t)) + (goto-char start) + (set-buffer-modified-p nil) + (save-restriction + (when narrow (narrow-to-region start end)) + (let* ((minimum (point-min)) (maximum (point-max)) + (result (font-lock-fontify-region minimum maximum))) + (should (eq (car result) 'jit-lock-bounds)) + (if narrow + (should (< (cadr result) start)) + (should (equal result `(jit-lock-bounds ,minimum . ,maximum)))) + (should (= (point-min) minimum)) + (should (= (point-max) maximum)) + (should (= (point) start)) + (should buffer-read-only) + (should-not (buffer-modified-p))) + (should (equal (pilish-test--hover-displayed-help "multiline link") + "https://example.org/target"))) + (should (equal source (buffer-substring-no-properties (point-min) (point-max)))))))) + +(ert-deftest pilish-test-hover-native-link-before-completion-regains-fallback () + "A link fontified during streaming regains reply help when folding removes it." + (with-temp-buffer + (pilish-chat-mode) + (let ((message (pilish-test--hover-message + [(:type "text" :text "Earlier [anchor][target].\n") + (:type "thinking" :thinking "[target]: https://example.org/live") + (:type "text" :text "End of reply.")]))) + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time 10)))) + (pilish--handle-display-event '(:type "agent_start")) + (pilish--handle-display-event `(:type "message_start" :message ,message)) + (pilish-test--hover-stream-content message) + (font-lock-ensure) + (should (equal (pilish-test--hover-displayed-help "anchor") + "https://example.org/live")) + (pilish--handle-display-event `(:type "message_end" :message ,message))) + (should (equal (get-text-property (pilish-test--hover-pos "anchor") 'pilish-hover-help) + (pilish-test--hover-reply-help))) + (should (equal (pilish-test--hover-displayed-help "anchor") + "https://example.org/live")) + (goto-char (pilish-test--hover-pos "[target]:")) + (pilish-toggle-tool-section) + (font-lock-flush) + (font-lock-ensure) + (should-not (button-at (pilish-test--hover-pos "anchor"))) + (should (equal (pilish-test--hover-displayed-help "anchor") + (pilish-test--hover-reply-help))) + (goto-char (car (pilish--thinking-block-bounds-at-pos + (pilish-test--hover-pos "Thinking hidden")))) + (pilish-toggle-tool-section) + (font-lock-flush) + (font-lock-ensure) + (should (equal (pilish-test--hover-displayed-help "anchor") + "https://example.org/live"))))) + +(ert-deftest pilish-test-hover-native-literal-command-path-and-provenance () + "Native help must show quotes and backslashes literally, never as commands." + (dolist (command '("rg \\[WARNING\\] output.log" "printf \\{global-map}" + "printf '\\[find-file]' `echo ‘λ’`" "printf \\= \\")) + (with-temp-buffer + (pilish-chat-mode) + (pilish--display-session-history + (vector (pilish-test--hover-message + (vector (pilish-test--toolcall "A" "bash" `(:command ,command)))) + '(:role "toolResult" :toolCallId "A" + :content [(:type "text" :text "Output A")])) ) + (should (equal (pilish-test--hover-displayed-help "Output A") + (concat "Bash\n" command))) + (should (< (length (pilish-test--hover-displayed-help "Output A")) 90)))) + (with-temp-buffer + (pilish-chat-mode) + (pilish--display-session-history + (vector (pilish-test--hover-message + [(:type "text" :text "Literal provenance") + (:type "thinking" :thinking "Literal thinking") + (:type "toolCall" :id "P" :name "read" + :arguments (:path "a\\[find-file]'`‘’.el"))] + :provider "p\\{global-map}" :model "m'`\\[find-file]") + '(:role "toolResult" :toolCallId "P" + :content [(:type "text" :text "Path output")]))) + (should (equal (pilish-test--hover-displayed-help "Path output") + "Read\na\\[find-file]'`‘’.el")) + (dolist (text '("Literal provenance" "Literal thinking")) + (should (string-match-p (regexp-quote "p\\{global-map} / m'`\\[find-file]") + (pilish-test--hover-displayed-help text)))))) + +(ert-deftest pilish-test-hover-native-process-replacement-clears-live-state () + "The real process setter is a boundary even without agent_start or history." + (with-temp-buffer + (pilish-chat-mode) + (let ((first (make-process :name "pilish-hover-first" :command '("cat") + :connection-type 'pipe :noquery t :filter #'pilish--process-filter)) + (second (make-process :name "pilish-hover-second" :command '("cat") + :connection-type 'pipe :noquery t :filter #'pilish--process-filter)) + (now 10)) + (unwind-protect + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time now)))) + (dolist (proc (list first second)) + (process-put proc 'pilish-chat-buffer (current-buffer)) + (pilish--register-display-handler proc)) + (pilish--set-process first) + (process-send-string + first (concat "{\"type\":\"agent_start\"}\n" + "{\"type\":\"message_start\",\"message\":{\"role\":\"assistant\"}}\n" + "{\"type\":\"tool_execution_start\",\"toolCallId\":\"A\",\"toolName\":\"bash\",\"args\":{\"command\":\"old command\"}}\n" + "{\"type\":\"tool_execution_end\",\"toolCallId\":\"pending\",\"toolName\":\"bash\",\"result\":{\"content\":[]}}\n")) + (should (pilish-test-wait-until + (lambda () (and pilish--hover-pending-tool-blocks + (= 1 (hash-table-count pilish--hover-pending-tool-blocks)))) + 3 .01 first)) + (should pilish--hover-assistant) + ;; Setting the same identity is not a process-generation change. + (pilish--set-process first) + (should pilish--hover-assistant) + (should (= 1 (hash-table-count pilish--hover-pending-tool-blocks))) + (pilish--unregister-display-handler first) + (pilish--set-process second) + (should-not pilish--hover-assistant) + (should (= 0 (hash-table-count pilish--hover-pending-tool-blocks))) + (setq now 100) + (process-send-string + second "{\"type\":\"tool_execution_end\",\"toolCallId\":\"A\",\"toolName\":\"bash\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"New process A\"}]}}\n") + (should (pilish-test-wait-until + (lambda () (save-excursion (goto-char (point-min)) + (search-forward "New process A" nil t))) + 3 .01 second)) + (should (equal (pilish-test--hover-displayed-help "New process A") + "Bash\nold command")) + (should (= 1 (hash-table-count pilish--hover-pending-tool-blocks))) + (pilish--set-process nil) + (should (= 0 (hash-table-count pilish--hover-pending-tool-blocks)))) + (dolist (proc (list first second)) + (pilish--unregister-display-handler proc) + (delete-process proc)))))) ;;; Response Display From 789f59da354c2b1f7aa97c51d3bc25154f4f89ac Mon Sep 17 00:00:00 2001 From: Daniel Nouri Date: Sun, 6 Sep 2026 08:43:27 +0200 Subject: [PATCH 2/2] Fix hover help for interrupted thinking Keep unfinished thinking out of reply tooltips after aborts or errors. Avoid unnecessary Markdown updates when changing hover metadata. --- pilish-render.el | 35 +++++++--- test/pilish-render-test.el | 135 +++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 11 deletions(-) diff --git a/pilish-render.el b/pilish-render.el index 28c3d01..8815db1 100644 --- a/pilish-render.el +++ b/pilish-render.el @@ -1966,16 +1966,18 @@ throughout the span, including underneath specific help, but only replace absent or previously owned help. Links, images and buttons keep precedence." (let ((inhibit-read-only t) (pos start)) - (while (< pos end) - (let* ((existing (get-text-property pos 'help-echo object)) - (next (min (next-single-property-change pos 'help-echo object end) - (next-single-property-change pos 'pilish-hover-help object end)))) - (when (or (null existing) - (equal existing (get-text-property pos 'pilish-hover-help object))) - (put-text-property pos next 'help-echo help object)) - (setq pos next))) - (add-text-properties start end - `(pilish-hover-help ,help rear-nonsticky t) object))) + ;; These properties do not change Markdown content or require reparsing. + (with-silent-modifications + (while (< pos end) + (let* ((existing (get-text-property pos 'help-echo object)) + (next (min (next-single-property-change pos 'help-echo object end) + (next-single-property-change pos 'pilish-hover-help object end)))) + (when (or (null existing) + (equal existing (get-text-property pos 'pilish-hover-help object))) + (put-text-property pos next 'help-echo help object)) + (setq pos next))) + (add-text-properties start end + `(pilish-hover-help ,help rear-nonsticky t) object)))) (defun pilish--fontify-with-hover-help (function start end &rest args) "Fontify START..END with FUNCTION and ARGS, letting native help win. @@ -2016,7 +2018,18 @@ cached fallback after native links have supplied their more-specific help." (end (marker-position pilish--streaming-marker))) ;; Tools have their own ownership. Work only inside this message, not ;; the shared Assistant heading or content indexes reused next message. - (dolist (range (pilish--ranges-excluding-property start end 'pilish-thinking-block)) + ;; Abort/error may omit thinking_end. Leave that active span unfinished, + ;; without completed help, rather than assigning it reply usage. + (dolist (range (append + (pilish--ranges-excluding-property + start (if pilish--thinking-start-marker + (marker-position pilish--thinking-start-marker) + end) + 'pilish-thinking-block) + (when pilish--thinking-marker + (pilish--ranges-excluding-property + (marker-position pilish--thinking-marker) end + 'pilish-thinking-block)))) (let ((pos (car range))) (dolist (ov (pilish--tool-block-overlays-in-region (car range) (cdr range))) (pilish--set-hover-help pos (max pos (overlay-start ov)) help) diff --git a/test/pilish-render-test.el b/test/pilish-render-test.el index 193ec05..2786084 100644 --- a/test/pilish-render-test.el +++ b/test/pilish-render-test.el @@ -484,6 +484,95 @@ (when (equal reason "error") (should (string-match-p "Expected interruption" (buffer-string)))))))) +(defun pilish-test--hover-interrupted-thinking (reason) + "Check unfinished thinking ownership after terminal REASON, alone and mixed." + (dolist (mixed '(nil t)) + (with-temp-buffer + (pilish-chat-mode) + (let* ((prefix (if mixed + [(:type "thinking" :thinking "Completed thought") + (:type "text" :text "Ordinary reply.")] + [])) + (thought '(:type "thinking" :thinking "Unfinished thought\nStill thinking")) + (message (pilish-test--hover-message + (vconcat prefix (vector thought)) + :stopReason reason :errorMessage "Expected interruption")) + (next (pilish-test--hover-message + [(:type "thinking" :thinking "Next thought") + (:type "text" :text "Next reply.")] + :timestamp 1784817180000 :provider "next" :model "owned" + :usage '(:input 0 :output 0)))) + (pilish--handle-display-event '(:type "agent_start")) + (pilish--handle-display-event + `(:type "message_start" :message ,(pilish-test--hover-message []))) + (pilish-test--hover-stream-content (pilish-test--hover-message prefix)) + (pilish-test--send-assistant-message-update + `(:type "thinking_start" :contentIndex ,(length prefix))) + (pilish-test--send-assistant-message-update + `(:type "thinking_delta" :contentIndex ,(length prefix) + :delta ,(plist-get thought :thinking))) + (should-not (pilish-test--hover-help "Unfinished thought")) + (goto-char (+ 2 (pilish-test--hover-pos "Unfinished thought"))) + (let ((source (buffer-substring-no-properties (point-min) (point-max))) + (saved-point (point))) + ;; No thinking_end: abort/error is a valid terminal path by itself. + (pilish--handle-display-event `(:type "message_end" :message ,message)) + (should (= (point) saved-point)) + ;; Completion may append its newline/error, never rewrite the stream. + (should (equal source (buffer-substring-no-properties + (point-min) (+ (point-min) (length source)))))) + (should pilish--thinking-start-marker) + (should pilish--thinking-marker) + (should (equal pilish--thinking-raw (plist-get thought :thinking))) + (pilish--handle-display-event `(:type "agent_end" :messages [,message])) + (font-lock-ensure) + (should-not pilish--thinking-start-marker) + (should-not pilish--thinking-marker) + (should-not (get-text-property (pilish-test--hover-pos "Unfinished thought") + 'pilish-thinking-block)) + (should (equal (pilish-test--hover-displayed-help "Unfinished thought") + "No local help at point")) + (should (equal (pilish-test--hover-displayed-help "Still thinking") + "No local help at point")) + (when mixed + (should (equal (pilish-test--hover-displayed-help "Ordinary reply") + (pilish-test--hover-reply-help))) + (should (equal (pilish-test--hover-displayed-help "Completed thought") + (concat "Thinking · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817120000)) + " · 1 line\nanthropic / claude-sonnet-4-6")))) + (let ((source (buffer-substring-no-properties (point-min) (point-max)))) + (cl-letf (((symbol-function 'current-time) (lambda () (seconds-to-time 10)))) + (pilish--handle-display-event '(:type "agent_start")) + (pilish--handle-display-event `(:type "message_start" :message ,next)) + (pilish-test--hover-stream-content next) + (pilish--handle-display-event `(:type "message_end" :message ,next)) + (pilish--handle-display-event `(:type "agent_end" :messages [,next]))) + (font-lock-ensure) + (should (equal source (buffer-substring-no-properties + (point-min) (+ (point-min) (length source)))))) + (should (equal (pilish-test--hover-displayed-help "Unfinished thought") + "No local help at point")) + (should (equal (pilish-test--hover-displayed-help "Next thought") + (concat "Thinking · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817180000)) + " · 1 line\nnext / owned"))) + (should (equal (pilish-test--hover-displayed-help "Next reply") + (concat "Reply · " + (pilish--format-message-timestamp + (pilish--ms-to-time 1784817180000)) + "\nnext / owned\nMessage tokens: input 0 · output 0"))))))) + +(ert-deftest pilish-test-hover-aborted-thinking-is-not-reply () + "An aborted final message without thinking_end must not label thinking Reply." + (pilish-test--hover-interrupted-thinking "aborted")) + +(ert-deftest pilish-test-hover-error-thinking-is-not-reply () + "An error final message without thinking_end must not label thinking Reply." + (pilish-test--hover-interrupted-thinking "error")) + (ert-deftest pilish-test-hover-tool-missing-and-nonpositive-boundaries () "Tool execution without both ordered local boundaries never invents time." (dolist (start '(nil 12 13)) @@ -672,6 +761,52 @@ execution cannot retain a temporary record on this setup's evaluator stack." (should (string-prefix-p "Bash\nsame command" (pilish-test--hover-help "Output A"))))) +(ert-deftest pilish-test-hover-metadata-update-is-silent () + "Metadata updates skip content notifications without losing native help." + (dolist (modified '(nil t)) + (with-temp-buffer + (pilish-chat-mode) + (pilish--display-session-history + (vector (pilish-test--hover-message + [(:type "text" :text "Plain [native link](https://example.org/silent) tail.")]))) + (font-lock-ensure) + (should (button-at (pilish-test--hover-pos "native link"))) + (goto-char (pilish-test--hover-pos "Plain")) + (set-buffer-modified-p modified) + (let* ((source (buffer-substring-no-properties (point-min) (point-max))) + (saved-point (point)) + (help "Reply\nMessage tokens: input 0") + (before-count 0) + (after-count 0) + ;; Keep the mode's actual native change hooks, adding observers. + (before-change-functions + (cons (lambda (&rest _) (cl-incf before-count)) before-change-functions)) + (after-change-functions + (cons (lambda (&rest _) (cl-incf after-count)) after-change-functions))) + (pilish--set-hover-help (point) (point-max) help) + (should (equal (list before-count after-count (buffer-modified-p)) + (list 0 0 modified))) + (should (= (point) saved-point)) + (should (equal source (buffer-substring-no-properties (point-min) (point-max)))) + (should buffer-read-only) + (should (equal (pilish-test--hover-displayed-help "Plain") help)) + (should (equal (get-text-property (pilish-test--hover-pos "native link") + 'pilish-hover-help) + help)) + (font-lock-flush) + (font-lock-ensure) + (should (equal (pilish-test--hover-displayed-help "Plain") help)) + (should (equal (pilish-test--hover-displayed-help "native link") + "https://example.org/silent")) + (should (button-at (pilish-test--hover-pos "native link"))) + ;; Suppression is local to metadata; real edits still notify the mode. + (setq before-count 0 after-count 0) + (let ((inhibit-read-only t)) + (goto-char (point-max)) + (insert "New content")) + (should (> before-count 0)) + (should (> after-count 0)))))) + (ert-deftest pilish-test-hover-completion-preserves-source-point-and-exact-boundary () "Hover changes no Markdown text or point, and is nonsticky at the exact end." (pilish-test--with-streaming-assistant