diff --git a/pi-coding-agent-input.el b/pi-coding-agent-input.el index 23f7a6e..edf8e5b 100644 --- a/pi-coding-agent-input.el +++ b/pi-coding-agent-input.el @@ -310,12 +310,16 @@ The /compact command is handled locally; other slash commands sent to pi." (interactive) (let* ((text (string-trim (buffer-string))) (chat-buf (pi-coding-agent--get-chat-buffer)) - (transitioning (and chat-buf + (chat-live-p (buffer-live-p chat-buf)) + (transitioning (and chat-live-p (pi-coding-agent--session-transition-active-p chat-buf))) - (busy (and chat-buf (pi-coding-agent--session-busy-p chat-buf)))) + (busy (and chat-live-p + (pi-coding-agent--session-busy-p chat-buf)))) (cond ((string-empty-p text) nil) + ((not chat-live-p) + (message "Pi: No chat session available")) (transitioning (message "Pi: Cannot send while session is switching")) ((and busy (pi-coding-agent--builtin-command-text-p text)) @@ -331,11 +335,11 @@ The /compact command is handled locally; other slash commands sent to pi." (defun pi-coding-agent-abort () "Abort the current pi operation. -Works while streaming or compacting." +Works while sending, streaming, or compacting." (interactive) (when-let* ((chat-buf (pi-coding-agent--get-chat-buffer))) (let ((status (buffer-local-value 'pi-coding-agent--status chat-buf))) - (when (memq status '(streaming compacting)) + (when (memq status '(sending streaming compacting)) (when (eq status 'streaming) (with-current-buffer chat-buf (pi-coding-agent--set-aborted t))) @@ -388,12 +392,16 @@ Includes both built-in commands and commands from pi's `get_commands' RPC." (when (and (eq (char-after (point-min)) ?/) (> (point) (point-min))) (let* ((start (1+ (point-min))) - (end (point)) + (end (save-excursion + (goto-char start) + (skip-chars-forward "^ \t\n") + (point))) (builtin-names (mapcar #'car pi-coding-agent--builtin-commands)) (rpc-names (mapcar (lambda (cmd) (plist-get cmd :name)) pi-coding-agent--commands)) (commands (delete-dups (append builtin-names rpc-names)))) - (list start end commands :exclusive 'no)))) + (when (<= start (point) end) + (list start end commands :exclusive 'no))))) ;;;; Editor Features: File Reference (@) @@ -479,15 +487,9 @@ Triggers when @ is typed, provides completion of project files." (point))))) (let* ((start (1+ at-pos)) (end (point)) - (prefix (buffer-substring-no-properties start end)) - (files (pi-coding-agent--get-project-files)) - (candidates (if (string-empty-p prefix) - files - (cl-remove-if-not - (lambda (f) (string-match-p (regexp-quote prefix) f)) - files)))) - (when candidates - (list start end candidates + (files (pi-coding-agent--get-project-files))) + (when files + (list start end files :exclusive 'no :annotation-function (lambda (_) " (file)") :company-kind (lambda (_) 'file)))))) diff --git a/pi-coding-agent-menu.el b/pi-coding-agent-menu.el index 3846aba..2979e9f 100644 --- a/pi-coding-agent-menu.el +++ b/pi-coding-agent-menu.el @@ -656,6 +656,14 @@ handled, even when the response failed." (pi-coding-agent--finish-session-transition generation))))))))))) +(defun pi-coding-agent--discard-reload-process (proc) + "Detach and terminate temporary reload process PROC." + (when (processp proc) + (pi-coding-agent--unregister-display-handler proc) + (when (process-live-p proc) + (delete-process proc)) + (pi-coding-agent--cleanup-process-stderr-buffer proc))) + ;;;###autoload (defun pi-coding-agent-reload () "Reload the current session by restarting the pi process. @@ -675,6 +683,7 @@ buffer from session history." (message "Pi: No session to reload")) ((not session-file) (message "Pi: No session file available - cannot reload")) + ((not (pi-coding-agent--session-transition-ready-p chat-buf "reload"))) (t (message "Pi: Reloading...") (with-current-buffer chat-buf @@ -699,8 +708,12 @@ buffer from session history." new-proc (list :type "switch_session" :sessionPath session-path) (lambda (response) - (when (pi-coding-agent--session-transition-current-p - chat-buf new-proc generation) + (when (or (pi-coding-agent--session-transition-current-p + chat-buf new-proc generation) + (progn + (pi-coding-agent--discard-reload-process + new-proc) + nil)) (let* ((data (plist-get response :data)) (cancelled (plist-get data :cancelled))) (if (and (eq (plist-get response :success) t) diff --git a/pi-coding-agent-render.el b/pi-coding-agent-render.el index 59771b0..8c8def0 100644 --- a/pi-coding-agent-render.el +++ b/pi-coding-agent-render.el @@ -1223,6 +1223,9 @@ Updates buffer-local state and renders display updates." (plist-get message :content)))) (_ ;; Assistant message - show header if needed, reset markers + (setq pi-coding-agent--line-parse-state 'line-start + pi-coding-agent--in-code-block nil + pi-coding-agent--streaming-table-candidate nil) (unless pi-coding-agent--assistant-header-shown (pi-coding-agent--append-to-chat (concat "\n" (pi-coding-agent--make-separator "Assistant") "\n")) diff --git a/pi-coding-agent-table.el b/pi-coding-agent-table.el index 56207a4..e914b51 100644 --- a/pi-coding-agent-table.el +++ b/pi-coding-agent-table.el @@ -130,6 +130,9 @@ visible after streaming while hidden." (setq pi-coding-agent--table-decoration-pending nil) (setq pi-coding-agent--last-table-display-width width) (let ((gc-cons-threshold (max gc-cons-threshold (* 8 1024 1024)))) + ;; Known macOS NS limitation: shrinking a scrolled chat frame can move + ;; the top visible line after overlay rewrapping. Reproduce with + ;; `pi-coding-agent-gui-test-table-resize-refreshes-hot-tail-only'. (pi-coding-agent--with-scroll-preservation (save-excursion (pi-coding-agent--refresh-hot-tail-tables width))))))) @@ -300,6 +303,8 @@ When `pi-coding-agent-prettify-tables' is non-nil, emits Unicode box-drawing verticals instead of markdown pipes." (let* ((num-cols (length col-widths)) (padded (append cells (make-list (max 0 (- num-cols (length cells))) ""))) + (aligns (append aligns + (make-list (max 0 (- num-cols (length aligns))) nil))) (wrapped-cells (cl-mapcar (lambda (cell column-width) (markdown-table-wrap-cell (or cell "") column-width)) @@ -345,30 +350,56 @@ box-drawing verticals instead of markdown pipes." "Render the separator line for COL-WIDTHS and ALIGNS. When `pi-coding-agent-prettify-tables' is non-nil, emits a box-drawing rule (├─┼─┤) directly; otherwise emits standard markdown syntax." - (if pi-coding-agent-prettify-tables - (concat "├─" (mapconcat (lambda (w) (make-string (max 1 w) ?─)) - col-widths "─┼─") - "─┤") - (let ((parts - (cl-mapcar - (lambda (column-width align) - (let ((dashes (make-string (max 1 column-width) ?-))) - (pcase align - ('left - (if (>= column-width 2) - (concat ":" (substring dashes 1)) - ":")) - ('right - (if (>= column-width 2) - (concat (substring dashes 1) ":") - ":")) - ('center - (if (>= column-width 3) - (concat ":" (substring dashes 2) ":") - (if (>= column-width 2) "::" ":"))) - (_ dashes)))) - col-widths aligns))) - (concat "| " (mapconcat #'identity parts " | ") " |")))) + (let* ((num-cols (length col-widths)) + (aligns (append aligns + (make-list (max 0 (- num-cols (length aligns))) nil)))) + (if pi-coding-agent-prettify-tables + (concat "├─" (mapconcat (lambda (w) (make-string w ?─)) + col-widths "─┼─") + "─┤") + (let ((parts + (cl-mapcar + (lambda (column-width align) + (if (zerop column-width) + "" + (let ((dashes (make-string column-width ?-))) + (pcase align + ('left + (if (>= column-width 2) + (concat ":" (substring dashes 1)) + ":")) + ('right + (if (>= column-width 2) + (concat (substring dashes 1) ":") + ":")) + ('center + (if (>= column-width 3) + (concat ":" (substring dashes 2) ":") + (if (>= column-width 2) "::" ":"))) + (_ dashes))))) + col-widths aligns))) + (concat "| " (mapconcat #'identity parts " | ") " |"))))) + +(defun pi-coding-agent--table-full-grid-p () + "Return non-nil when tables should render a full web-style cell grid. +Requires both `pi-coding-agent-prettify-tables' and +`pi-coding-agent-table-full-grid' to be enabled." + (and pi-coding-agent-prettify-tables + pi-coding-agent-table-full-grid)) + +(defun pi-coding-agent--render-table-grid-rule (col-widths position) + "Render a horizontal box-drawing rule for COL-WIDTHS. +POSITION is one of `top', `middle', or `bottom', selecting the corner +and junction characters (┌┬┐ / ├┼┤ / └┴┘). Each column contributes +`column-width' + 2 horizontal bars to match the one-space cell padding +used by `pi-coding-agent--render-table-row-lines'." + (let ((left (pcase position ('top "┌") ('bottom "└") (_ "├"))) + (mid (pcase position ('top "┬") ('bottom "┴") (_ "┼"))) + (right (pcase position ('top "┐") ('bottom "┘") (_ "┤")))) + (concat left + (mapconcat (lambda (w) (make-string (+ w 2) ?─)) + col-widths mid) + right))) (defun pi-coding-agent--table-alignments (separator-line) "Return column alignment symbols parsed from SEPARATOR-LINE." @@ -443,20 +474,53 @@ Plain tables (no prefix) take a fast path that skips prefix splitting." (mapcar (lambda (row) (pi-coding-agent--render-table-row-lines row col-widths aligns)) - display-rows))) + display-rows)) + ;; Full web-style grid: weave border rules into the existing + ;; per-raw-line groups so the group↔raw-line 1:1 invariant that + ;; `pi-coding-agent--decorate-table' relies on is preserved. + ;; The top border joins the header group, an inter-row rule + ;; prefixes every data group after the first, and the bottom + ;; border joins the last group (or the separator group when the + ;; table has no data rows). + (full-grid (pi-coding-agent--table-full-grid-p)) + (has-data (consp row-groups)) + (header-group + (if full-grid + (cons (pi-coding-agent--render-table-grid-rule col-widths 'top) + header-lines) + header-lines)) + (sep-group + (if (and full-grid (not has-data)) + (list separator-line + (pi-coding-agent--render-table-grid-rule col-widths 'bottom)) + (list separator-line))) + (data-groups + (if (and full-grid has-data) + (let ((mid-rule (pi-coding-agent--render-table-grid-rule + col-widths 'middle)) + (bottom (pi-coding-agent--render-table-grid-rule + col-widths 'bottom)) + (last-index (1- (length row-groups)))) + (cl-loop for group in row-groups + for i from 0 + collect (append (when (> i 0) (list mid-rule)) + group + (when (= i last-index) (list bottom))))) + row-groups))) (if no-prefix - (append (list header-lines) - (list (list separator-line)) - row-groups) + (append (list header-group) + (list sep-group) + data-groups) (append (list (mapcar (lambda (line) (concat (car prefixes) line)) - header-lines)) - (list (list (concat (nth 1 prefixes) separator-line))) + header-group)) + (list (mapcar (lambda (line) (concat (nth 1 prefixes) line)) + sep-group)) (cl-mapcar (lambda (prefix row-lines) (mapcar (lambda (line) (concat prefix line)) row-lines)) (nthcdr 2 prefixes) - row-groups))))))) + data-groups))))))) ;;;; Line Mapping diff --git a/pi-coding-agent-ui.el b/pi-coding-agent-ui.el index 8e438e2..b0c49f1 100644 --- a/pi-coding-agent-ui.el +++ b/pi-coding-agent-ui.el @@ -285,6 +285,17 @@ still operate on the raw table source." :type 'boolean :group 'pi-coding-agent) +(defcustom pi-coding-agent-table-full-grid t + "Whether prettified tables draw a full cell grid (web-style borders). +When non-nil, and `pi-coding-agent-prettify-tables' is also enabled, +tables render a top border, a horizontal rule between every row, and a +bottom border using Unicode box-drawing characters, so each cell is fully +enclosed like an HTML table. When nil, tables keep only the column +verticals and a single header rule. Has no effect when +`pi-coding-agent-prettify-tables' is nil (raw markdown pipes are used)." + :type 'boolean + :group 'pi-coding-agent) + ;;;; Faces (defface pi-coding-agent-timestamp diff --git a/test/pi-coding-agent-input-test.el b/test/pi-coding-agent-input-test.el index 5f23da0..39d798e 100644 --- a/test/pi-coding-agent-input-test.el +++ b/test/pi-coding-agent-input-test.el @@ -53,6 +53,34 @@ (pi-coding-agent-send) (should-not send-called)))))) +(ert-deftest pi-coding-agent-test-send-without-chat-preserves-input () + "Sending from an unlinked input buffer preserves the draft and history." + (with-temp-buffer + (pi-coding-agent-input-mode) + (insert "unsent draft") + (let ((shown-message nil)) + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) + (setq shown-message (apply #'format fmt args))))) + (pi-coding-agent-send)) + (should (equal (buffer-string) "unsent draft")) + (should (null pi-coding-agent--input-ring)) + (should (equal shown-message "Pi: No chat session available"))))) + +(ert-deftest pi-coding-agent-test-send-with-dead-chat-preserves-input () + "Sending with a dead chat link preserves the draft without signaling." + (let ((dead-chat (generate-new-buffer + "*pi-coding-agent-test-dead-send-chat*"))) + (kill-buffer dead-chat) + (with-temp-buffer + (pi-coding-agent-input-mode) + (setq pi-coding-agent--chat-buffer dead-chat) + (insert "draft for dead chat") + (cl-letf (((symbol-function 'message) #'ignore)) + (pi-coding-agent-send)) + (should (equal (buffer-string) "draft for dead chat")) + (should (null pi-coding-agent--input-ring))))) + (ert-deftest pi-coding-agent-test-send-queues-locally-while-streaming () "pi-coding-agent-send adds to local queue while streaming, no RPC sent." (let ((chat-buf (get-buffer-create "*pi-coding-agent-test-queue-stream*")) @@ -784,6 +812,41 @@ Uses :false (JSON false representation) to verify boolean normalization." (should (equal (plist-get sent-command :type) "abort")) (should-not pi-coding-agent--aborted))))) +(ert-deftest pi-coding-agent-test-abort-sends-command-while-sending () + "pi-coding-agent-abort sends abort while waiting for agent_start." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (let ((sent-command nil) + (pi-coding-agent--status 'sending)) + (cl-letf (((symbol-function 'pi-coding-agent--get-process) + (lambda () 'mock-proc)) + ((symbol-function 'pi-coding-agent--get-chat-buffer) + (lambda () (current-buffer))) + ((symbol-function 'pi-coding-agent--rpc-async) + (lambda (_proc command _callback) + (setq sent-command command)))) + (pi-coding-agent-abort) + (should (equal (plist-get sent-command :type) "abort")) + (should-not pi-coding-agent--aborted))))) + +(ert-deftest pi-coding-agent-test-abort-sends-command-during-auto-retry () + "pi-coding-agent-abort cancels an auto-retry wait represented by sending." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (let ((sent-command nil)) + (pi-coding-agent--update-state-from-event + '(:type "auto_retry_start" :attempt 2 :errorMessage "retrying")) + (should (eq pi-coding-agent--status 'sending)) + (cl-letf (((symbol-function 'pi-coding-agent--get-process) + (lambda () 'mock-proc)) + ((symbol-function 'pi-coding-agent--get-chat-buffer) + (lambda () (current-buffer))) + ((symbol-function 'pi-coding-agent--rpc-async) + (lambda (_proc command _callback) + (setq sent-command command)))) + (pi-coding-agent-abort) + (should (equal (plist-get sent-command :type) "abort")))))) + (ert-deftest pi-coding-agent-test-abort-noop-when-idle () "pi-coding-agent-abort does nothing when idle." (with-temp-buffer @@ -2864,6 +2927,29 @@ only offer our own capfs (slash commands, file references, paths)." ;; No duplicates (should (= (length (seq-filter (lambda (n) (equal n "compact")) names)) 1))))) +(ert-deftest pi-coding-agent-test-command-capf-ignores-arguments () + "Command completion does not treat arguments as part of the command name." + (with-temp-buffer + (pi-coding-agent-input-mode) + (insert "/compact keep these details") + (let ((before (buffer-string))) + (should-not (pi-coding-agent--command-capf)) + (should (equal (buffer-string) before))))) + +(ert-deftest pi-coding-agent-test-command-capf-region-stops-before-arguments () + "Command completion replaces only the command token before arguments." + (with-temp-buffer + (pi-coding-agent-input-mode) + (insert "/comp keep details") + (goto-char 6) + (let ((completion (pi-coding-agent--command-capf))) + (should completion) + (should (= (nth 0 completion) 2)) + (should (= (nth 1 completion) 6)) + (should (equal (buffer-substring-no-properties + (nth 1 completion) (point-max)) + " keep details"))))) + (ert-deftest pi-coding-agent-test-send-prompt-sends-literal () "pi-coding-agent--send-prompt sends text literally (no expansion). Pi handles command expansion on the server side." @@ -3955,6 +4041,40 @@ This occurs after compaction before the next assistant message." (should result) (should (member "test.el" (nth 2 result)))))) +(ert-deftest pi-coding-agent-test-file-reference-capf-basic-prefix-match () + "Basic completion matches the project-relative path prefix." + (with-temp-buffer + (pi-coding-agent-input-mode) + (setq pi-coding-agent--project-files-cache + '("dir/foo.el" "other/bar.el") + pi-coding-agent--project-files-cache-time (float-time)) + (insert "@dir/f") + (let* ((completion-styles '(basic)) + (capf (pi-coding-agent--file-reference-capf)) + (input (buffer-substring-no-properties + (nth 0 capf) (nth 1 capf))) + (matches (completion-all-completions + input (nth 2 capf) nil (length input)))) + (should (equal (substring-no-properties (car matches)) + "dir/foo.el"))))) + +(ert-deftest pi-coding-agent-test-file-reference-capf-flex-controls-matching () + "Flex completion can match candidates not selected by literal substring." + (with-temp-buffer + (pi-coding-agent-input-mode) + (setq pi-coding-agent--project-files-cache + '("dir/foo.el" "other/bar.el") + pi-coding-agent--project-files-cache-time (float-time)) + (insert "@dfe") + (let* ((completion-styles '(flex)) + (capf (pi-coding-agent--file-reference-capf)) + (input (buffer-substring-no-properties + (nth 0 capf) (nth 1 capf))) + (matches (completion-all-completions + input (nth 2 capf) nil (length input)))) + (should (equal (substring-no-properties (car matches)) + "dir/foo.el"))))) + ;;; Path Completion (Tab) (ert-deftest pi-coding-agent-test-path-capf-returns-nil-for-non-path () diff --git a/test/pi-coding-agent-menu-test.el b/test/pi-coding-agent-menu-test.el index 5e4d8ad..9bfe759 100644 --- a/test/pi-coding-agent-menu-test.el +++ b/test/pi-coding-agent-menu-test.el @@ -644,6 +644,88 @@ BINDING-SPEC is (DIR CHAT-NAME INPUT-NAME PROC). DIR is evaluated once." (delete-process pi-coding-agent--process))) (kill-buffer chat-buf))))) +(ert-deftest pi-coding-agent-test-reload-refuses-while-transition-active () + "Reload does not start another process during an active session transition." + (let ((start-count 0) + (shown-message nil) + (chat-buf (generate-new-buffer + "*pi-coding-agent-test-reload-reentry-chat*"))) + (unwind-protect + (progn + (with-current-buffer chat-buf + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--state + '(:session-file "/tmp/test-session.json")) + (pi-coding-agent--begin-session-transition 'active-transition)) + (cl-letf (((symbol-function 'pi-coding-agent--get-chat-buffer) + (lambda () chat-buf)) + ((symbol-function 'pi-coding-agent--start-process) + (lambda (_dir) + (setq start-count (1+ start-count)) + (ert-fail "Reload must not start during a transition"))) + ((symbol-function 'message) + (lambda (fmt &rest args) + (setq shown-message (apply #'format fmt args))))) + (pi-coding-agent-reload)) + (should (= start-count 0)) + (should (equal shown-message + "Pi: Cannot reload while Pi is busy"))) + (when (buffer-live-p chat-buf) + (kill-buffer chat-buf))))) + +(ert-deftest pi-coding-agent-test-stale-reload-callback-cleans-new-process () + "A stale reload callback releases the temporary process it owns." + (let ((old-proc nil) + (new-proc nil) + (new-stderr nil) + (switch-callback nil) + (chat-buf (generate-new-buffer + "*pi-coding-agent-test-stale-reload-chat*"))) + (unwind-protect + (progn + (with-current-buffer chat-buf + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--state + '(:session-file "/tmp/test-session.json")) + (setq old-proc (start-process "test-stale-reload-old" nil "cat") + pi-coding-agent--process old-proc)) + (cl-letf (((symbol-function 'pi-coding-agent--get-chat-buffer) + (lambda () chat-buf)) + ((symbol-function 'pi-coding-agent--start-process) + (lambda (_dir) + (setq new-proc + (start-process "test-stale-reload-new" nil "cat") + new-stderr + (generate-new-buffer + " *pi-coding-agent-test-stale-stderr*")) + (process-put new-proc + 'pi-coding-agent-stderr-buf new-stderr) + new-proc)) + ((symbol-function 'pi-coding-agent--rpc-async) + (lambda (_proc command callback) + (when (equal (plist-get command :type) "switch_session") + (setq switch-callback callback)))) + ((symbol-function 'message) #'ignore)) + (pi-coding-agent-reload)) + (should (functionp switch-callback)) + (should (process-live-p new-proc)) + (should (process-get new-proc 'pi-coding-agent-display-handler)) + (with-current-buffer chat-buf + (pi-coding-agent--begin-session-transition 'replacement-process)) + (funcall switch-callback + '(:success t :data (:cancelled :false))) + (should-not (process-live-p new-proc)) + (should-not + (process-get new-proc 'pi-coding-agent-display-handler)) + (should-not (buffer-live-p new-stderr)) + (should (process-live-p old-proc))) + (when (and old-proc (process-live-p old-proc)) + (delete-process old-proc)) + (when (and new-proc (process-live-p new-proc)) + (delete-process new-proc)) + (when (buffer-live-p chat-buf) + (kill-buffer chat-buf))))) + (ert-deftest pi-coding-agent-test-reload-switch-failure-keeps-old-process () "A failed reload switch does not attach the fresh process to the UI." (let* ((old-proc nil) diff --git a/test/pi-coding-agent-render-test.el b/test/pi-coding-agent-render-test.el index 0d714d5..5830f9c 100644 --- a/test/pi-coding-agent-render-test.el +++ b/test/pi-coding-agent-render-test.el @@ -88,6 +88,30 @@ (should (= table-scan-count 1)) (should-not pi-coding-agent--streaming-table-candidate)))) +(ert-deftest pi-coding-agent-test-assistant-message-start-resets-line-state () + "A later assistant message starts at a fresh Markdown line." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--line-parse-state 'mid-line + pi-coding-agent--assistant-header-shown t) + (pi-coding-agent--handle-display-event + '(:type "message_start" :message (:role "assistant"))) + (should (eq pi-coding-agent--line-parse-state 'line-start)) + (should (equal (pi-coding-agent--transform-delta "# Heading") + "## Heading")))) + +(ert-deftest pi-coding-agent-test-assistant-message-start-resets-block-state () + "A later assistant message does not inherit code or table parse state." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (setq pi-coding-agent--in-code-block t + pi-coding-agent--streaming-table-candidate '(10 . 20) + pi-coding-agent--assistant-header-shown t) + (pi-coding-agent--handle-display-event + '(:type "message_start" :message (:role "assistant"))) + (should-not pi-coding-agent--in-code-block) + (should-not pi-coding-agent--streaming-table-candidate))) + (ert-deftest pi-coding-agent-test-delta-transforms-atx-headings () "ATX headings in assistant content are leveled down. # becomes ##, ## becomes ###, etc. This keeps our setext H1 separators diff --git a/test/pi-coding-agent-table-test.el b/test/pi-coding-agent-table-test.el index 42e2d36..2c4b7ae 100644 --- a/test/pi-coding-agent-table-test.el +++ b/test/pi-coding-agent-table-test.el @@ -409,6 +409,33 @@ so visible text still needs consistent alignment across all display lines." (let ((widths (mapcar #'string-width (nreverse all-lines)))) (should (= (length (delete-dups (copy-sequence widths))) 1)))))) +(ert-deftest pi-coding-agent-test-zero-width-column-keeps-separator-aligned () + "A zero-width column occupies the same width in rows and separators." + (dolist (pretty '(t nil)) + (let* ((pi-coding-agent-prettify-tables pretty) + (widths '(1 0 2)) + (aligns '(nil nil nil)) + (row (car (pi-coding-agent--render-table-row-lines + '("a" "" "bc") widths aligns))) + (separator (pi-coding-agent--render-table-separator-line + widths aligns))) + (should (= (string-width row) (string-width separator)))))) + +(ert-deftest pi-coding-agent-test-short-alignments-render-all-columns () + "Missing alignment entries default without truncating trailing columns." + (dolist (pretty '(t nil)) + (let* ((pi-coding-agent-prettify-tables pretty) + (widths '(1 1 1)) + (aligns '(left)) + (row (car (pi-coding-agent--render-table-row-lines + '("a" "b" "c") widths aligns))) + (separator (pi-coding-agent--render-table-separator-line + widths aligns))) + (should (string-match-p "a" row)) + (should (string-match-p "b" row)) + (should (string-match-p "c" row)) + (should (= (string-width row) (string-width separator)))))) + (ert-deftest pi-coding-agent-test-decorate-table-prettifies-visible-separators () "Rendered table display uses box-drawing separators instead of raw pipes." (with-temp-buffer @@ -430,6 +457,78 @@ so visible text still needs consistent alignment across all display lines." (should (string-match-p "Name" all-display)) (should (string-match-p "Alpha" all-display))))) +(ert-deftest pi-coding-agent-test-render-table-grid-rule-widths-match-rows () + "Top, middle, and bottom grid rules share the width of pretty data rows." + (let* ((pi-coding-agent-prettify-tables t) + (widths '(5 1 0 3)) + (aligns '(nil nil nil nil)) + (row (car (pi-coding-agent--render-table-row-lines + '("a" "b" "" "d") widths aligns))) + (top (pi-coding-agent--render-table-grid-rule widths 'top)) + (mid (pi-coding-agent--render-table-grid-rule widths 'middle)) + (bot (pi-coding-agent--render-table-grid-rule widths 'bottom))) + (should (string-prefix-p "┌" top)) + (should (string-suffix-p "┐" top)) + (should (string-match-p "┬" top)) + (should (string-prefix-p "├" mid)) + (should (string-suffix-p "┤" mid)) + (should (string-match-p "┼" mid)) + (should (string-prefix-p "└" bot)) + (should (string-suffix-p "┘" bot)) + (should (string-match-p "┴" bot)) + (should (= (string-width row) (string-width top))) + (should (= (string-width row) (string-width mid))) + (should (= (string-width row) (string-width bot))))) + +(ert-deftest pi-coding-agent-test-decorate-table-full-grid-encloses-cells () + "With full grid enabled, a decorated table gains top/mid/bottom borders." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (let ((pi-coding-agent-prettify-tables t) + (pi-coding-agent-table-full-grid t) + (inhibit-read-only t)) + (insert "| Name | Value |\n|------|-------|\n| Alpha | Beta |\n| Gamma | Delta |\n") + (font-lock-ensure) + (pi-coding-agent--decorate-tables-in-region (point-min) (point-max) 40) + (let* ((displays (pi-coding-agent-test--table-overlay-displays-in-region + (point-min) (point-max))) + (all (mapconcat #'identity displays "\n")) + ;; Split each overlay individually to avoid empty lines from the + ;; trailing newlines already present in each display string. + (lines nil)) + (dolist (display displays) + (dolist (line (split-string + (string-trim-right display "\n+") "\n")) + (push line lines))) + ;; Top border, an inter-row rule, and a bottom border are present. + (should (string-match-p "┌.*┬.*┐" all)) + (should (string-match-p "├.*┼.*┤" all)) + (should (string-match-p "└.*┴.*┘" all)) + ;; Every visible line has the same display width (full alignment). + (should (= (length (delete-dups + (mapcar #'string-width lines))) + 1)) + ;; The 1:1 group↔raw-line invariant: one overlay per raw line (4). + (should (= (length displays) 4)))))) + +(ert-deftest pi-coding-agent-test-decorate-table-full-grid-off-has-no-outer-border () + "With full grid disabled, only header rule remains (no top/bottom border)." + (with-temp-buffer + (pi-coding-agent-chat-mode) + (let ((pi-coding-agent-prettify-tables t) + (pi-coding-agent-table-full-grid nil) + (inhibit-read-only t)) + (insert "| Name | Value |\n|------|-------|\n| Alpha | Beta |\n") + (font-lock-ensure) + (pi-coding-agent--decorate-tables-in-region (point-min) (point-max) 40) + (let ((all (mapconcat #'identity + (pi-coding-agent-test--table-overlay-displays-in-region + (point-min) (point-max)) + "\n"))) + (should (string-match-p "├.*┼.*┤" all)) + (should-not (string-match-p "┌" all)) + (should-not (string-match-p "└" all)))))) + (ert-deftest pi-coding-agent-test-decorate-table-preserves-pipes-when-prettify-off () "With prettify disabled, rendered tables use standard markdown pipes." (with-temp-buffer