Skip to content

Commit 0fa6ac8

Browse files
committed
add chat timeline navigation with message timestamps
1 parent 32318e7 commit 0fa6ac8

File tree

2 files changed

+164
-54
lines changed

2 files changed

+164
-54
lines changed

eca-chat.el

Lines changed: 159 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,9 @@ Must be a positive integer."
401401
(define-key map (kbd "C-c C-m") #'eca-chat-select-model)
402402
(define-key map (kbd "C-c C-n") #'eca-chat-new)
403403
(define-key map (kbd "C-c C-f") #'eca-chat-select)
404+
(define-key map (kbd "C-c C-p") #'eca-chat-repeat-prompt)
405+
(define-key map (kbd "C-c C-d") #'eca-chat-clear-prompt)
406+
(define-key map (kbd "C-c C-h") #'eca-chat-timeline)
404407
(define-key map (kbd "C-c C-a") #'eca-chat-tool-call-accept-all)
405408
(define-key map (kbd "C-c C-S-a") #'eca-chat-tool-call-accept-next)
406409
(define-key map (kbd "C-c C-s") #'eca-chat-tool-call-accept-all-and-remember)
@@ -582,19 +585,19 @@ Must be a positive integer."
582585

583586
(defun eca-chat--clear ()
584587
"Clear the chat for SESSION."
585-
(erase-buffer)
586-
(remove-overlays (point-min) (point-max))
587-
(insert "\n")
588-
(eca-chat--insert-prompt-string)
589-
(eca-chat--refresh-context))
588+
(erase-buffer)
589+
(remove-overlays (point-min) (point-max))
590+
(insert "\n")
591+
(eca-chat--insert-prompt-string)
592+
(eca-chat--refresh-context))
590593

591594
(defun eca-chat--stop-prompt (session)
592595
"Stop the running chat prompt for SESSION."
593596
(when eca-chat--chat-loading
594-
(eca-api-notify session
595-
:method "chat/promptStop"
596-
:params (list :chatId eca-chat--id))
597-
(eca-chat--set-chat-loading session nil)))
597+
(eca-api-notify session
598+
:method "chat/promptStop"
599+
:params (list :chatId eca-chat--id))
600+
(eca-chat--set-chat-loading session nil)))
598601

599602
(defun eca-chat--set-chat-loading (session loading)
600603
"Set the SESSION chat to a loading state if LOADING is non nil.
@@ -721,7 +724,7 @@ the prompt/context line."
721724
(string-blank-p (buffer-substring-no-properties (line-beginning-position) (point))))))
722725
(ding))
723726

724-
;; in context area trying to remove a context space separator
727+
;; in context area trying to remove a context space separator
725728
((and cur-ov
726729
(overlay-get cur-ov 'eca-chat-context-area)
727730
(and (string= " " (string (char-before (point))))
@@ -991,7 +994,9 @@ Add a overlay before with OVERLAY-KEY = OVERLAY-VALUE if passed."
991994
(goto-char (1- (point)))
992995
(when overlay-key
993996
(let ((ov (make-overlay (point) (point) (current-buffer))))
994-
(overlay-put ov overlay-key overlay-value)))
997+
(overlay-put ov overlay-key overlay-value)
998+
(when (eq overlay-key 'eca-chat--user-message-id)
999+
(overlay-put ov 'eca-chat--timestamp (float-time)))))
9951000
(insert text)
9961001
(point))))
9971002

@@ -1092,8 +1097,8 @@ If FORCE? decide to CLOSE? or not."
10921097
(content (overlay-get ov-content 'eca-chat--expandable-content-content))
10931098
(empty-content? (string-empty-p content))
10941099
(close? (if force?
1095-
close?
1096-
(overlay-get ov-label 'eca-chat--expandable-content-toggle))))
1100+
close?
1101+
(overlay-get ov-label 'eca-chat--expandable-content-toggle))))
10971102
(save-excursion
10981103
(goto-char (overlay-start ov-label))
10991104
(if (or close? empty-content?)
@@ -1333,7 +1338,7 @@ If STATIC? return strs with no dynamic values."
13331338
Add text property to prompt text to match context."
13341339
(let ((context (get-text-property 0 'eca-chat-completion-item item)))
13351340
(let ((start-pos (save-excursion
1336-
(search-backward eca-chat-context-prefix (line-beginning-position) t)))
1341+
(search-backward eca-chat-context-prefix (line-beginning-position) t)))
13371342
(end-pos (point)))
13381343
(delete-region start-pos end-pos)
13391344
(insert (eca-chat--context->str context 'static))))
@@ -1399,10 +1404,10 @@ Add text property to prompt text to match context."
13991404
(defun eca-chat--go-to-overlay (ov-key range-min range-max first?)
14001405
"Go to overlay finding from RANGE-MIN to RANGE-MAX if matches OV-KEY."
14011406
(eca-chat--with-current-buffer (eca-chat--get-last-buffer (eca-session))
1402-
(let ((get-fn (if first? #'-first #'-last)))
1403-
(when-let ((ov (funcall get-fn (-lambda (ov) (overlay-get ov ov-key))
1404-
(overlays-in range-min range-max))))
1405-
(goto-char (overlay-start ov))))))
1407+
(let ((get-fn (if first? #'-first #'-last)))
1408+
(when-let ((ov (funcall get-fn (-lambda (ov) (overlay-get ov ov-key))
1409+
(overlays-in range-min range-max))))
1410+
(goto-char (overlay-start ov))))))
14061411

14071412
(defun eca-chat--cur-position ()
14081413
"Return the start and end positions for current point.
@@ -1413,8 +1418,8 @@ of (LINE . CHARACTER) representing the current selection or cursor position."
14131418
(end-pos (if (use-region-p) (region-end) (point)))
14141419
(start-line (line-number-at-pos start-pos))
14151420
(start-char (1+ (progn
1416-
(goto-char start-pos)
1417-
(current-column))))
1421+
(goto-char start-pos)
1422+
(current-column))))
14181423
(end-line (line-number-at-pos end-pos))
14191424
(end-char (1+ (progn
14201425
(goto-char end-pos)
@@ -1724,8 +1729,8 @@ Calls CB with the resulting message."
17241729
(cond
17251730
((eq action 'metadata)
17261731
'(metadata (category . eca-capf)
1727-
(display-sort-function . identity)
1728-
(cycle-sort-function . identity)))
1732+
(display-sort-function . identity)
1733+
(cycle-sort-function . identity)))
17291734
((eq (car-safe action) 'boundaries) nil)
17301735
(t
17311736
(complete-with-action action (funcall candidates-fn) probe pred))))
@@ -2054,38 +2059,38 @@ Calls CB with the resulting message."
20542059
(unless (buffer-live-p (eca-chat--get-last-buffer session))
20552060
(eca-chat--create-buffer session))
20562061
(eca-chat--with-current-buffer (eca-chat--get-last-buffer session)
2057-
(unless (derived-mode-p 'eca-chat-mode)
2058-
(eca-chat-mode)
2059-
(eca-chat--track-cursor-position-schedule)
2060-
(when eca-chat-auto-add-cursor
2061-
(eca-chat--add-context (list :type "cursor")))
2062-
(when eca-chat-auto-add-repomap
2063-
(eca-chat--add-context (list :type "repoMap"))))
2064-
(unless (eq (current-buffer) (eca-get (eca--session-chats session) 'empty))
2065-
(setf (eca--session-chats session) (eca-assoc (eca--session-chats session) 'empty (current-buffer))))
2066-
(if (window-live-p (get-buffer-window (buffer-name)))
2067-
(eca-chat--select-window)
2068-
(eca-chat--pop-window))
2069-
(unless (eca--session-last-chat-buffer session)
2070-
(setf (eca--session-last-chat-buffer session) (current-buffer))))
2062+
(unless (derived-mode-p 'eca-chat-mode)
2063+
(eca-chat-mode)
2064+
(eca-chat--track-cursor-position-schedule)
2065+
(when eca-chat-auto-add-cursor
2066+
(eca-chat--add-context (list :type "cursor")))
2067+
(when eca-chat-auto-add-repomap
2068+
(eca-chat--add-context (list :type "repoMap"))))
2069+
(unless (eq (current-buffer) (eca-get (eca--session-chats session) 'empty))
2070+
(setf (eca--session-chats session) (eca-assoc (eca--session-chats session) 'empty (current-buffer))))
2071+
(if (window-live-p (get-buffer-window (buffer-name)))
2072+
(eca-chat--select-window)
2073+
(eca-chat--pop-window))
2074+
(unless (eca--session-last-chat-buffer session)
2075+
(setf (eca--session-last-chat-buffer session) (current-buffer))))
20712076
(eca-chat--track-cursor))
20722077

20732078
(defun eca-chat-exit (session)
20742079
"Exit the ECA chat for SESSION."
20752080
(when (buffer-live-p (eca-chat--get-last-buffer session))
20762081
(eca-chat--with-current-buffer (eca-chat--get-last-buffer session)
2077-
(setq eca-chat--closed t)
2078-
(force-mode-line-update)
2079-
(goto-char (point-max))
2080-
(rename-buffer (concat (buffer-name) ":closed") t)
2081-
;; Keep only the most recently closed chat buffer; kill older ones.
2082-
(let ((current (current-buffer)))
2083-
(dolist (b (buffer-list))
2084-
(when (and (not (eq b current))
2085-
(string-match-p "^<eca-chat:.*>:closed$" (buffer-name b)))
2086-
(kill-buffer b))))
2087-
(when-let* ((window (get-buffer-window (eca-chat--get-last-buffer session))))
2088-
(quit-window nil window)))))
2082+
(setq eca-chat--closed t)
2083+
(force-mode-line-update)
2084+
(goto-char (point-max))
2085+
(rename-buffer (concat (buffer-name) ":closed") t)
2086+
;; Keep only the most recently closed chat buffer; kill older ones.
2087+
(let ((current (current-buffer)))
2088+
(dolist (b (buffer-list))
2089+
(when (and (not (eq b current))
2090+
(string-match-p "^<eca-chat:.*>:closed$" (buffer-name b)))
2091+
(kill-buffer b))))
2092+
(when-let* ((window (get-buffer-window (eca-chat--get-last-buffer session))))
2093+
(quit-window nil window)))))
20892094

20902095
;;;###autoload
20912096
(defun eca-chat-clear ()
@@ -2403,7 +2408,7 @@ if ARG is current prefix, ask for file, otherwise drop current file."
24032408
(eca-assert-session-running session)
24042409
(eca-chat-open session)
24052410
(eca-chat--with-current-buffer (eca-chat--get-last-buffer session)
2406-
(goto-char (point-max)))
2411+
(goto-char (point-max)))
24072412
(let ((buffer (get-buffer-create "*whisper-stdout*")))
24082413
(with-current-buffer buffer
24092414
(erase-buffer)
@@ -2414,15 +2419,117 @@ if ARG is current prefix, ask for file, otherwise drop current file."
24142419
(line-beginning-position)
24152420
(line-end-position))))
24162421
(eca-chat--with-current-buffer (eca-chat--get-last-buffer session)
2417-
(insert transcription)
2418-
(newline)
2419-
(eca-chat--key-pressed-return))))
2422+
(insert transcription)
2423+
(newline)
2424+
(eca-chat--key-pressed-return))))
24202425
nil t)
24212426
(whisper-run)
24222427
(eca-info "Recording audio. Press RET when you are done.")
24232428
(while (not (equal ?\r (read-char)))
24242429
(sit-for 0.5))
24252430
(whisper-run)))))
24262431

2432+
(defun eca-chat--format-message-for-completion (msg)
2433+
"Format MSG for display in completion interface.
2434+
If MSG has :timestamp, prepends [HH:MM] to the text."
2435+
(let ((timestamp (plist-get msg :timestamp))
2436+
(text (plist-get msg :text)))
2437+
(if timestamp
2438+
(format "[%s] %s"
2439+
(format-time-string "%H:%M" timestamp)
2440+
text)
2441+
text)))
2442+
2443+
(defun eca-chat--get-user-messages (&optional buffer)
2444+
"Extract all user messages from the chat BUFFER.
2445+
If BUFFER is nil, use the last chat buffer from current session.
2446+
Returns a list of plists, each containing:
2447+
:text - the message text
2448+
:start - start position in buffer
2449+
:end - end position in buffer
2450+
:id - message ID from overlay
2451+
:line - line number of the message
2452+
:timestamp - timestamp when message was sent
2453+
2454+
Messages are ordered from newest to oldest.
2455+
Returns empty list if session is not running or buffer is not available."
2456+
(when-let* ((session (eca-session))
2457+
(chat-buffer (or buffer (eca-chat--get-last-buffer session)))
2458+
((buffer-live-p chat-buffer)))
2459+
(with-current-buffer chat-buffer
2460+
(let ((messages '()))
2461+
(dolist (ov (overlays-in (point-min) (point-max)))
2462+
(when-let* ((msg-id (overlay-get ov 'eca-chat--user-message-id))
2463+
(start (overlay-start ov))
2464+
(end (save-excursion
2465+
(goto-char start)
2466+
(while (and (not (eobp))
2467+
(progn (forward-line 1)
2468+
(eq (get-text-property (point) 'font-lock-face)
2469+
'eca-chat-user-messages-face))))
2470+
(line-end-position 0)))
2471+
(text (string-trim (buffer-substring-no-properties start end)))
2472+
(timestamp (overlay-get ov 'eca-chat--timestamp)))
2473+
(unless (string-empty-p text)
2474+
(push (list :text text
2475+
:start start
2476+
:end end
2477+
:id msg-id
2478+
:timestamp timestamp
2479+
:line (line-number-at-pos start))
2480+
messages))))
2481+
messages))))
2482+
2483+
(defun eca-chat--select-message-from-completion (prompt)
2484+
"Show completion with user messages using PROMPT.
2485+
Returns selected message plist or nil if no messages or cancelled."
2486+
(when-let ((messages (eca-chat--get-user-messages)))
2487+
(let ((table (make-hash-table :test 'equal)))
2488+
(dolist (msg (reverse messages))
2489+
(puthash (eca-chat--format-message-for-completion msg) msg table))
2490+
(when-let ((choice (completing-read
2491+
prompt
2492+
(lambda (string pred action)
2493+
(if (eq action 'metadata)
2494+
`(metadata (display-sort-function . identity))
2495+
(complete-with-action action (hash-table-keys table) string pred)))
2496+
nil t)))
2497+
(gethash choice table)))))
2498+
2499+
;;;###autoload
2500+
(defun eca-chat-timeline ()
2501+
"Navigate to a user message via completion."
2502+
(interactive)
2503+
(if-let* ((selected-msg (eca-chat--select-message-from-completion "Timeline: "))
2504+
(pos (plist-get selected-msg :start))
2505+
(chat-buffer (eca-chat--get-last-buffer (eca-session))))
2506+
(progn
2507+
(eca-chat--display-buffer chat-buffer)
2508+
(with-current-buffer chat-buffer
2509+
(goto-char pos)
2510+
(recenter)))
2511+
(message "No user messages found")))
2512+
2513+
;;;###autoload
2514+
(defun eca-chat-clear-prompt ()
2515+
"Clear the prompt input field in chat."
2516+
(interactive)
2517+
(when-let ((chat-buffer (eca-chat--get-last-buffer (eca-session))))
2518+
(with-current-buffer chat-buffer
2519+
(eca-chat--set-prompt ""))))
2520+
2521+
;;;###autoload
2522+
(defun eca-chat-repeat-prompt ()
2523+
"Select a previous message and insert its text into the prompt."
2524+
(interactive)
2525+
(if-let* ((selected-msg (eca-chat--select-message-from-completion "Repeat prompt: "))
2526+
(text (plist-get selected-msg :text))
2527+
(chat-buffer (eca-chat--get-last-buffer (eca-session))))
2528+
(progn
2529+
(eca-chat--display-buffer chat-buffer)
2530+
(with-current-buffer chat-buffer
2531+
(eca-chat--set-prompt text)))
2532+
(message "No user messages found")))
2533+
24272534
(provide 'eca-chat)
24282535
;;; eca-chat.el ends here

eca-util.el

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,13 @@ Inheirits BASE-MAP."
200200
"ECA transient menu"
201201
[["Chat"
202202
("n" "New" eca-chat-new)
203-
("f" "New" eca-chat-select)
203+
("f" "Select" eca-chat-select)
204204
("c" "Clear" eca-chat-clear)
205205
("r" "Reset" eca-chat-reset)
206206
("R" "Rename" eca-chat-rename)
207207
("t" "Talk" eca-chat-talk)
208+
("p" "Repeat prompt" eca-chat-repeat-prompt)
209+
("C" "Clear prompt" eca-chat-clear-prompt)
208210
("m" "Select model" eca-chat-select-model)
209211
("b" "Change behavior" eca-chat-select-behavior)
210212
("o" "Open/close chat window" eca-chat-toggle-window)
@@ -213,7 +215,8 @@ Inheirits BASE-MAP."
213215
("A" "Accept next pending tool call" eca-chat-tool-call-accept-next)]
214216

215217
["Navigation"
216-
("C" "Chat" eca)
218+
("h" "Message history" eca-chat-timeline)
219+
("c" "Chat" eca)
217220
("M" "MCP details" eca-mcp-details)
218221
("E" "Show stderr (logs)" eca-show-stderr)]
219222

0 commit comments

Comments
 (0)