Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Bugfix: the `eca-workspaces` dashboard was impossible to navigate while a chat ran, since the live refresh (once a second) scrolled the view and could throw point to the top. Rendering erases the buffer, which collapses every window start marker, so a window shorter than the content recentered on each refresh; point resting on a line with no entity, the footer, fell back to the buffer start. Each window now keeps its scroll position, and point on the footer stays on it.
- The `stop`, `Steering:` and `Queued:` lines of a running prompt now show right below its progress status, above the `@` context line, instead of splitting that line from the prompt field (#322).
- Show the `@cursor` context with `eca-chat-context-cursor-unsaved-face` (inheriting `warning`) while its buffer has unsaved changes, since ECA only sees the file on disk (#291).
- Image mentions show inline as thumbnails: screenshots pasted in the prompt, image files added as context and images mentioned in sent messages. `RET` toggles them back to text. Customize with `eca-chat-image-show-thumbnails` and `eca-chat-image-thumbnail-size`.
Expand Down
70 changes: 45 additions & 25 deletions eca-workspaces.el
Original file line number Diff line number Diff line change
Expand Up @@ -248,13 +248,35 @@ whitespace."
(forward-line 1)))
found))))

(defun eca-workspaces--restore-point (entity column)
"Move point to ENTITY line at COLUMN, falling back to buffer start."
(if-let* ((position (eca-workspaces--find-entity entity)))
(progn
(goto-char position)
(move-to-column (or column 0)))
(goto-char (point-min))))
(defun eca-workspaces--line-position (line)
"Return the position where LINE starts, clamped to the buffer."
(save-excursion
(goto-char (point-min))
(forward-line (1- line))
(point)))

(defun eca-workspaces--point-state (position)
"Return the (ENTITY COLUMN LINE) state that finds POSITION again."
(save-excursion
(goto-char position)
(list (eca-workspaces--entity-at position)
(current-column)
(line-number-at-pos position))))

(defun eca-workspaces--point-position (state)
"Return where STATE points to in the freshly rendered buffer.
STATE comes from `eca-workspaces--point-state'. A line without an
entity, the footer one, is found back by its line number, so point
resting there survives a render too. A row whose entity is gone
falls back to buffer start."
(-let* (((entity column line) state)
(position (and entity (eca-workspaces--find-entity entity))))
(if (and entity (not position))
(point-min)
(save-excursion
(goto-char (or position (eca-workspaces--line-position line)))
(move-to-column (or column 0))
(point)))))

(defun eca-workspaces--footer ()
"Return the dashboard footer hint line."
Expand All @@ -268,20 +290,19 @@ whitespace."
"\n"))

(defun eca-workspaces--window-state (window)
"Return (WINDOW ENTITY COLUMN) for WINDOW's current point."
(let ((position (window-point window)))
(list window
(eca-workspaces--entity-at position)
(save-excursion
(goto-char position)
(current-column)))))
"Return (WINDOW POINT-STATE START-LINE) of WINDOW.
START-LINE is the line WINDOW starts at: `erase-buffer' collapses
the window start marker, so a window shorter than the content
scrolls back and recenters on every refresh without it."
(list window
(eca-workspaces--point-state (window-point window))
(line-number-at-pos (window-start window))))

(defun eca-workspaces--render ()
"Render the dashboard into the current buffer.
Preserves point, per-window points and fold state across renders."
Preserves point, per-window points and scroll, and fold state."
(let* ((inhibit-read-only t)
(point-entity (eca-workspaces--entity-at (point)))
(point-column (current-column))
(point-state (eca-workspaces--point-state (point)))
(window-states (-map #'eca-workspaces--window-state
(get-buffer-window-list nil nil t))))
(erase-buffer)
Expand All @@ -297,15 +318,14 @@ Preserves point, per-window points and fold state across renders."
(dolist (chat-buffer (eca-chat-buffers session))
(insert (eca-workspaces--chat-line session chat-buffer)))))))
(insert (eca-workspaces--footer))
(eca-workspaces--restore-point point-entity point-column)
(goto-char (eca-workspaces--point-position point-state))
(dolist (state window-states)
(-let [(window entity column) state]
(when-let* ((position (eca-workspaces--find-entity entity)))
(set-window-point window
(save-excursion
(goto-char position)
(move-to-column (or column 0))
(point))))))))
(-let [(window window-point-state start-line) state]
(set-window-point window
(eca-workspaces--point-position window-point-state))
(set-window-start window
(eca-workspaces--line-position start-line)
t)))))

(defun eca-workspaces--any-chat-running-p ()
"Return non-nil when any chat of any session is loading."
Expand Down
33 changes: 33 additions & 0 deletions test/eca-workspaces-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,39 @@ CHATS is a list of chat buffers ordered oldest-first."
(expect (eca-workspaces--chat-id-at-point) :to-equal chat-id)
(expect (current-column) :to-equal column)))))

(it "keeps point on a line without an entity, like the footer"
(eca-workspaces-test--make-session
1 "/tmp/proj"
(list (eca-workspaces-test--make-chat :title "Chat A")))
(with-current-buffer (eca-workspaces-test--render)
(eca-workspaces-test--goto "type ?")
(let ((line (line-number-at-pos)))
(expect (eca-workspaces--entity-at (point)) :to-be nil)
(eca-workspaces--render)
(expect (line-number-at-pos) :to-equal line))))

(it "keeps the point and scroll of each window across re-renders"
(let ((chat-c (eca-workspaces-test--make-chat :title "Chat C")))
(eca-workspaces-test--make-session
1 "/tmp/proj"
(list (eca-workspaces-test--make-chat :title "Chat A")
(eca-workspaces-test--make-chat :title "Chat B")
chat-c))
(let ((buffer (eca-workspaces-test--render)))
(save-window-excursion
(set-window-buffer (selected-window) buffer)
(with-current-buffer buffer
(let ((window (get-buffer-window buffer))
(chat-id (buffer-local-value 'eca-chat--id chat-c)))
;; A window scrolled past the first chat, point on the last one.
(set-window-start window (eca-workspaces--line-position 3) t)
(eca-workspaces-test--goto "Chat C")
(set-window-point window (point))
(eca-workspaces--render)
(expect (line-number-at-pos (window-start window)) :to-equal 3)
(expect (eca-workspaces--entity-at (window-point window))
:to-equal (cons 1 chat-id))))))))

(it "falls back to buffer start when the entity is gone"
(let ((chat (eca-workspaces-test--make-chat :title "Chat A")))
(eca-workspaces-test--make-session 1 "/tmp/proj" (list chat))
Expand Down
Loading