Add 8 real Emacs feature implementations: electric quotes, aggressive indent, volatile highlights, highlight changes, dtrt-indent, rainbow mode, hl-todo, glasses mode

ober

e25e0168e97b9c5583576eacc4a37dabc7a5e4d8

diff --git a/src/jerboa-emacs/app.ss b/src/jerboa-emacs/app.ss
index f1db72c..ae35f04 100644
--- a/src/jerboa-emacs/app.ss
+++ b/src/jerboa-emacs/app.ss
@@ -562,6 +562,9 @@
       ;; Tick pulse highlight countdown
       (pulse-tick!)
 
+      ;; Tick volatile highlights countdown
+      (volatile-highlight-tick!)
+
       ;; Tick which-key delayed display
       (which-key-tui-tick! app)
 
diff --git a/src/jerboa-emacs/editor-core.ss b/src/jerboa-emacs/editor-core.ss
index 96575c9..5b4bb41 100644
--- a/src/jerboa-emacs/editor-core.ss
+++ b/src/jerboa-emacs/editor-core.ss
@@ -35,6 +35,7 @@
 ;;;============================================================================
 (def *auto-pair-mode* #t)
 (def *auto-revert-mode* #f)
+(def *aggressive-indent-mode* #f)
 
 ;;;============================================================================
 ;;; Pulse/flash highlight on jump (beacon-like)
@@ -80,6 +81,45 @@
     (set! *pulse-countdown* 0)))
 
 ;;;============================================================================
+;;; Volatile highlights — flash changed regions (yank, undo)
+;;;============================================================================
+(def *volatile-highlight-indicator* 2)
+(def *volatile-highlight-countdown* 0)
+(def *volatile-highlight-editor* #f)
+
+(def (volatile-highlight! ed start len)
+  "Flash-highlight region [START, START+LEN) briefly when volatile-highlights is on."
+  (when (and *volatile-highlights* (> len 0))
+    ;; Clear any previous volatile highlight
+    (volatile-highlight-clear!)
+    ;; Set up indicator: green-tinted box
+    (send-message ed SCI_INDICSETSTYLE *volatile-highlight-indicator* INDIC_ROUNDBOX)
+    (send-message ed SCI_INDICSETFORE *volatile-highlight-indicator* #x80FF80) ; light green
+    (send-message ed SCI_INDICSETALPHA *volatile-highlight-indicator* 60)
+    (send-message ed SCI_INDICSETUNDER *volatile-highlight-indicator* 1)
+    (send-message ed SCI_SETINDICATORCURRENT *volatile-highlight-indicator* 0)
+    (send-message ed SCI_INDICATORFILLRANGE start len)
+    (set! *volatile-highlight-editor* ed)
+    (set! *volatile-highlight-countdown* 8)))  ; 8 * 50ms = 400ms
+
+(def (volatile-highlight-tick!)
+  "Called each main loop iteration. Decrements volatile highlight countdown."
+  (when (> *volatile-highlight-countdown* 0)
+    (set! *volatile-highlight-countdown* (- *volatile-highlight-countdown* 1))
+    (when (= *volatile-highlight-countdown* 0)
+      (volatile-highlight-clear!))))
+
+(def (volatile-highlight-clear!)
+  "Remove volatile highlight indicator."
+  (when *volatile-highlight-editor*
+    (let ((len (editor-get-text-length *volatile-highlight-editor*)))
+      (send-message *volatile-highlight-editor* SCI_SETINDICATORCURRENT
+                    *volatile-highlight-indicator* 0)
+      (send-message *volatile-highlight-editor* SCI_INDICATORCLEARRANGE 0 len))
+    (set! *volatile-highlight-editor* #f)
+    (set! *volatile-highlight-countdown* 0)))
+
+;;;============================================================================
 ;;; System clipboard integration (xclip/xsel/wl-copy)
 ;;;============================================================================
 
@@ -440,6 +480,19 @@
                                (auto-pair-char ch))))
               (n (get-prefix-arg app))) ; Get prefix arg
          (cond
+           ;; Electric quote mode: convert " and ' to curly quotes
+           ((and *electric-quote-mode* (= n 1)
+                 (or (= ch 34) (= ch 39)))  ; " or '
+            (let* ((pos (editor-get-current-pos ed))
+                   (replacement (electric-quote-char ch ed)))
+              (if replacement
+                (let ((rlen (string-length replacement)))
+                  (editor-insert-text ed pos replacement)
+                  (editor-goto-pos ed (+ pos rlen)))
+                ;; Fallback: insert raw char
+                (begin
+                  (editor-insert-text ed pos (string (integer->char ch)))
+                  (editor-goto-pos ed (+ pos 1))))))
            ;; Auto/electric-pair skip-over: typing a closing delimiter when next char matches
            ((and pair-active (= n 1)
                  (if *electric-pair-mode*
@@ -468,11 +521,66 @@
                    (str (make-string n (integer->char ch))))
               (editor-insert-text ed pos str)
               (editor-goto-pos ed (+ pos n)))))
+         ;; Aggressive indent: reindent current line after each insertion
+         (when (and *aggressive-indent-mode*
+                    (not (dired-buffer? buf))
+                    (not (shell-buffer? buf))
+                    (not (terminal-buffer? buf)))
+           (tui-aggressive-indent-line! ed))
          ;; Auto-fill: break line if past fill-column
          (tui-auto-fill-after-insert! app ed))))))
 
 
 ;;;============================================================================
+;;; Aggressive indent — reindent current line based on paren depth
+;;;============================================================================
+
+(def (tui-aggressive-indent-line! ed)
+  "Reindent the current line based on paren/bracket depth of preceding text."
+  (let* ((text (editor-get-text ed))
+         (len (string-length text))
+         (pos (min (editor-get-current-pos ed) len)))
+    (when (> len 0)
+      (let* ((line-start (let loop ((i (- pos 1)))
+                           (cond ((< i 0) 0)
+                                 ((char=? (string-ref text i) #\newline) (+ i 1))
+                                 (else (loop (- i 1))))))
+             (depth (let loop ((i 0) (d 0))
+                      (if (>= i line-start) d
+                        (case (string-ref text i)
+                          ((#\( #\[ #\{) (loop (+ i 1) (+ d 1)))
+                          ((#\) #\] #\}) (loop (+ i 1) (max 0 (- d 1))))
+                          (else (loop (+ i 1) d))))))
+             (line-end (let loop ((i line-start))
+                         (cond ((>= i len) i)
+                               ((char=? (string-ref text i) #\newline) i)
+                               (else (loop (+ i 1))))))
+             (line-text (substring text line-start line-end))
+             (trimmed (string-trim line-text))
+             (close-first (let loop ((i 0) (d 0))
+                            (if (>= i (string-length trimmed)) d
+                              (case (string-ref trimmed i)
+                                ((#\) #\] #\}) (loop (+ i 1) (+ d 1)))
+                                (else d)))))
+             (target-depth (max 0 (- depth close-first)))
+             (target-indent (make-string (* target-depth 2) #\space))
+             (current-indent (let loop ((i 0))
+                               (if (>= i (string-length line-text)) ""
+                                 (if (char-whitespace? (string-ref line-text i))
+                                   (loop (+ i 1))
+                                   (substring line-text 0 i))))))
+        (unless (string=? current-indent target-indent)
+          (let ((new-line (string-append target-indent trimmed)))
+            (send-message ed SCI_SETTARGETSTART line-start 0)
+            (send-message ed SCI_SETTARGETEND line-end 0)
+            (editor-replace-target ed new-line)
+            (let ((new-pos (+ line-start (string-length target-indent)
+                             (max 0 (- pos line-start
+                                       (string-length current-indent))))))
+              (editor-goto-pos ed (min new-pos
+                                       (+ line-start (string-length new-line)))))))))))
+
+;;;============================================================================
 ;;; Auto-fill check for TUI self-insert
 ;;;============================================================================
 
@@ -865,7 +973,9 @@
     (let ((new-pos (editor-get-current-pos ed)))
       (set! (app-state-last-yank-pos app) pos)
       (set! (app-state-last-yank-len app) (- new-pos pos))
-      (set! (app-state-kill-ring-idx app) 0))))
+      (set! (app-state-kill-ring-idx app) 0)
+      ;; Volatile highlight: flash the yanked region
+      (volatile-highlight! ed pos (- new-pos pos)))))
 
 ;;;============================================================================
 ;;; Mark and region
diff --git a/src/jerboa-emacs/editor-extra-modes.ss b/src/jerboa-emacs/editor-extra-modes.ss
index 8796ddc..a7264dd 100644
--- a/src/jerboa-emacs/editor-extra-modes.ss
+++ b/src/jerboa-emacs/editor-extra-modes.ss
@@ -2043,10 +2043,54 @@
   "Correct word with jinx — delegates to flyspell-correct."
   (cmd-flyspell-correct-word app))
 
-;; Hl-todo — highlight TODO/FIXME/HACK keywords
+;; Hl-todo — highlight TODO/FIXME/HACK keywords with colored indicators
+(def *hl-todo-indicator* 5)
+(def *hl-todo-keywords*
+  '(("TODO"  . #x00CCFF)   ; orange
+    ("FIXME" . #x0000FF)   ; red
+    ("HACK"  . #x00BBFF)   ; dark orange
+    ("BUG"   . #x0000CC)   ; dark red
+    ("XXX"   . #x0088FF)   ; amber
+    ("NOTE"  . #x00CC00))) ; green
+
+(def (hl-todo-refresh! ed)
+  "Scan the buffer and highlight all TODO-like keywords with colored indicators."
+  (let* ((text (editor-get-text ed))
+         (len (string-length text)))
+    ;; Clear existing hl-todo indicators
+    (send-message ed SCI_SETINDICATORCURRENT *hl-todo-indicator* 0)
+    (send-message ed SCI_INDICATORCLEARRANGE 0 (max 1 len))
+    ;; Set up indicator style
+    (send-message ed SCI_INDICSETSTYLE *hl-todo-indicator* INDIC_TEXTFORE)
+    (send-message ed SCI_INDICSETUNDER *hl-todo-indicator* 1)
+    ;; Find and highlight each keyword
+    (for-each
+      (lambda (kw-pair)
+        (let ((kw (car kw-pair))
+              (color (cdr kw-pair))
+              (kw-len (string-length (car kw-pair))))
+          (send-message ed SCI_INDICSETFORE *hl-todo-indicator* color)
+          (send-message ed SCI_SETINDICATORCURRENT *hl-todo-indicator* 0)
+          (let loop ((start 0))
+            (let ((found (string-contains text kw start)))
+              (when found
+                (send-message ed SCI_INDICATORFILLRANGE found kw-len)
+                (loop (+ found kw-len)))))))
+      *hl-todo-keywords*)))
+
+(def (hl-todo-clear! ed)
+  "Remove all hl-todo indicators."
+  (let ((len (editor-get-text-length ed)))
+    (send-message ed SCI_SETINDICATORCURRENT *hl-todo-indicator* 0)
+    (send-message ed SCI_INDICATORCLEARRANGE 0 (max 1 len))))
+
 (def (cmd-hl-todo-mode app)
-  "Toggle hl-todo mode — highlights TODO keywords."
-  (let ((on (toggle-mode! 'hl-todo)))
+  "Toggle hl-todo mode — highlights TODO/FIXME/HACK keywords with colors."
+  (let ((on (toggle-mode! 'hl-todo))
+        (ed (current-editor app)))
+    (if on
+      (hl-todo-refresh! ed)
+      (hl-todo-clear! ed))
     (echo-message! (app-state-echo app) (if on "HL-todo: on" "HL-todo: off"))))
 
 (def (cmd-hl-todo-next app)
@@ -2324,10 +2368,72 @@
     (echo-message! echo (if *global-editorconfig*
                           "Global editorconfig ON" "Global editorconfig OFF"))))
 
+(def (dtrt-detect-indent text)
+  "Analyze TEXT to detect indentation style.  Returns (values use-tabs? indent-size)
+   by sampling the first 200 lines.  Counts leading-tab vs leading-space lines,
+   and for spaces, finds the most common indent width (2, 3, 4, or 8)."
+  (let* ((lines (let loop ((i 0) (start 0) (acc '()) (count 0))
+                  (cond
+                    ((or (>= i (string-length text)) (>= count 200))
+                     (reverse acc))
+                    ((char=? (string-ref text i) #\newline)
+                     (loop (+ i 1) (+ i 1)
+                           (cons (substring text start i) acc)
+                           (+ count 1)))
+                    (else (loop (+ i 1) start acc count)))))
+         (tab-lines 0)
+         (space-lines 0)
+         (widths (make-vector 9 0)))  ; index 0-8, count occurrences of each width
+    (for-each
+      (lambda (line)
+        (when (> (string-length line) 0)
+          (cond
+            ((char=? (string-ref line 0) #\tab)
+             (set! tab-lines (+ tab-lines 1)))
+            ((char=? (string-ref line 0) #\space)
+             (let ((n (let loop ((i 0))
+                        (if (and (< i (string-length line))
+                                 (char=? (string-ref line i) #\space))
+                          (loop (+ i 1))
+                          i))))
+               (when (and (> n 0) (<= n 8))
+                 (set! space-lines (+ space-lines 1))
+                 (vector-set! widths n (+ (vector-ref widths n) 1))))))))
+      lines)
+    (if (> tab-lines space-lines)
+      (values #t 8)  ; tabs with 8-wide tab stops
+      ;; Find the most common space width among 2, 3, 4, 8
+      (let ((best-width 4)
+            (best-count 0))
+        (for-each
+          (lambda (w)
+            (when (> (vector-ref widths w) best-count)
+              (set! best-width w)
+              (set! best-count (vector-ref widths w))))
+          '(2 3 4 8))
+        (values #f best-width)))))
+
+(def (dtrt-apply-indent! ed use-tabs? indent-size)
+  "Apply detected indentation settings to a Scintilla editor."
+  (send-message ed SCI_SETUSETABS (if use-tabs? 1 0) 0)
+  (send-message ed SCI_SETTABWIDTH indent-size 0)
+  (send-message ed SCI_SETINDENT indent-size 0))
+
+(def (dtrt-indent-buffer! app)
+  "Auto-detect and apply indentation for the current buffer."
+  (when *global-dtrt-indent*
+    (let* ((ed (current-editor app))
+           (text (editor-get-text ed)))
+      (when (> (string-length text) 0)
+        (let-values (((use-tabs? indent-size) (dtrt-detect-indent text)))
+          (dtrt-apply-indent! ed use-tabs? indent-size))))))
+
 (def (cmd-toggle-global-dtrt-indent app)
   "Toggle global dtrt-indent-mode (auto-detect indentation)."
   (let ((echo (app-state-echo app)))
     (set! *global-dtrt-indent* (not *global-dtrt-indent*))
+    (when *global-dtrt-indent*
+      (dtrt-indent-buffer! app))
     (echo-message! echo (if *global-dtrt-indent*
                           "Global dtrt-indent ON" "Global dtrt-indent OFF"))))
 
diff --git a/src/jerboa-emacs/editor-extra-tools.ss b/src/jerboa-emacs/editor-extra-tools.ss
index 6b4fdf0..13daa80 100644
--- a/src/jerboa-emacs/editor-extra-tools.ss
+++ b/src/jerboa-emacs/editor-extra-tools.ss
@@ -1355,18 +1355,66 @@
         "Indent guides on"
         "Indent guides off"))))
 
-;;; --- Toggle rainbow delimiters mode ---
+;;; --- Rainbow mode: colorize hex color codes inline ---
 
 (def *rainbow-mode* #f)
+(def *rainbow-indicator* 4)
+
+(def (hex-char-value c)
+  "Return 0-15 for hex char, or #f."
+  (cond ((and (char>=? c #\0) (char<=? c #\9)) (- (char->integer c) 48))
+        ((and (char>=? c #\a) (char<=? c #\f)) (+ 10 (- (char->integer c) 97)))
+        ((and (char>=? c #\A) (char<=? c #\F)) (+ 10 (- (char->integer c) 65)))
+        (else #f)))
+
+(def (rainbow-refresh! ed)
+  "Scan the buffer for #rrggbb hex color codes and highlight each with its color."
+  (let* ((text (editor-get-text ed))
+         (len (string-length text)))
+    ;; Clear all rainbow indicators
+    (send-message ed SCI_SETINDICATORCURRENT *rainbow-indicator* 0)
+    (send-message ed SCI_INDICATORCLEARRANGE 0 (max 1 len))
+    (send-message ed SCI_INDICSETSTYLE *rainbow-indicator* INDIC_STRAIGHTBOX)
+    (send-message ed SCI_INDICSETUNDER *rainbow-indicator* 1)
+    (send-message ed SCI_INDICSETALPHA *rainbow-indicator* 100)
+    ;; Scan for #rrggbb patterns
+    (let loop ((i 0))
+      (when (< (+ i 6) len)
+        (if (and (char=? (string-ref text i) #\#)
+                 (hex-char-value (string-ref text (+ i 1)))
+                 (hex-char-value (string-ref text (+ i 2)))
+                 (hex-char-value (string-ref text (+ i 3)))
+                 (hex-char-value (string-ref text (+ i 4)))
+                 (hex-char-value (string-ref text (+ i 5)))
+                 (hex-char-value (string-ref text (+ i 6))))
+          (let* ((r (+ (* 16 (hex-char-value (string-ref text (+ i 1))))
+                       (hex-char-value (string-ref text (+ i 2)))))
+                 (g (+ (* 16 (hex-char-value (string-ref text (+ i 3))))
+                       (hex-char-value (string-ref text (+ i 4)))))
+                 (b (+ (* 16 (hex-char-value (string-ref text (+ i 5))))
+                       (hex-char-value (string-ref text (+ i 6)))))
+                 ;; Scintilla uses BGR format
+                 (color (+ b (* 256 g) (* 65536 r))))
+            (send-message ed SCI_INDICSETFORE *rainbow-indicator* color)
+            (send-message ed SCI_SETINDICATORCURRENT *rainbow-indicator* 0)
+            (send-message ed SCI_INDICATORFILLRANGE i 7)
+            (loop (+ i 7)))
+          (loop (+ i 1)))))))
 
 (def (cmd-toggle-rainbow-mode app)
-  "Toggle rainbow delimiter/bracket coloring mode."
-  (let ((echo (app-state-echo app)))
+  "Toggle rainbow mode — colorize #rrggbb hex color codes inline."
+  (let* ((echo (app-state-echo app))
+         (ed (current-editor app)))
     (set! *rainbow-mode* (not *rainbow-mode*))
-    (echo-message! echo
-      (if *rainbow-mode*
-        "Rainbow delimiters on"
-        "Rainbow delimiters off"))))
+    (if *rainbow-mode*
+      (begin
+        (rainbow-refresh! ed)
+        (echo-message! echo "Rainbow mode on (hex colors highlighted)"))
+      (begin
+        (let ((len (editor-get-text-length ed)))
+          (send-message ed SCI_SETINDICATORCURRENT *rainbow-indicator* 0)
+          (send-message ed SCI_INDICATORCLEARRANGE 0 (max 1 len)))
+        (echo-message! echo "Rainbow mode off")))))
 
 ;;; --- Quick switch to scratch buffer ---
 
@@ -1433,6 +1481,35 @@
 
 (def *electric-quote-mode* #f)
 
+(def (electric-quote-char ch ed)
+  "Return a curly-quote replacement string for CH at the current position in ED,
+   or #f if no replacement should be made.  Uses the character before point to
+   decide opening vs closing: after whitespace/BOL → opening, otherwise → closing."
+  (let* ((pos (editor-get-current-pos ed))
+         (prev-ch (if (> pos 0)
+                    (send-message ed SCI_GETCHARAT (- pos 1) 0)
+                    0))
+         (at-word-boundary? (or (= pos 0)
+                               (= prev-ch 32)    ; space
+                               (= prev-ch 10)    ; newline
+                               (= prev-ch 13)    ; CR
+                               (= prev-ch 9)     ; tab
+                               (= prev-ch 40)    ; (
+                               (= prev-ch 91)    ; [
+                               (= prev-ch 123)))) ; {
+    (cond
+      ;; Double quote → curly double quotes
+      ((= ch 34)  ; "
+       (if at-word-boundary?
+         "\x201C;"   ; left double quotation mark "
+         "\x201D;")) ; right double quotation mark "
+      ;; Single quote / apostrophe → curly single quotes
+      ((= ch 39)  ; '
+       (if at-word-boundary?
+         "\x2018;"   ; left single quotation mark '
+         "\x2019;")) ; right single quotation mark '
+      (else #f))))
+
 (def (cmd-toggle-electric-quote app)
   "Toggle electric quote mode (auto-convert straight quotes to smart quotes)."
   (let ((echo (app-state-echo app)))
diff --git a/src/jerboa-emacs/editor-extra-tools2.ss b/src/jerboa-emacs/editor-extra-tools2.ss
index 8597013..b1df6c8 100644
--- a/src/jerboa-emacs/editor-extra-tools2.ss
+++ b/src/jerboa-emacs/editor-extra-tools2.ss
@@ -676,9 +676,43 @@
     (echo-message! (app-state-echo app)
       (if on "Superword mode: symbol-aware" "Superword mode off"))))
 
+(def *glasses-indicator* 6)
+
+(def (glasses-refresh! ed)
+  "Scan buffer and mark CamelCase boundaries with a subtle underscore indicator."
+  (let* ((text (editor-get-text ed))
+         (len (string-length text)))
+    ;; Clear existing indicators
+    (send-message ed SCI_SETINDICATORCURRENT *glasses-indicator* 0)
+    (send-message ed SCI_INDICATORCLEARRANGE 0 (max 1 len))
+    ;; Set up indicator: thin underline at CamelCase boundaries
+    (send-message ed SCI_INDICSETSTYLE *glasses-indicator* INDIC_COMPOSITIONTHICK)
+    (send-message ed SCI_INDICSETFORE *glasses-indicator* #x888888) ; grey
+    (send-message ed SCI_INDICSETUNDER *glasses-indicator* 1)
+    (send-message ed SCI_SETINDICATORCURRENT *glasses-indicator* 0)
+    ;; Find CamelCase boundaries: lowercase followed by uppercase
+    (let loop ((i 1))
+      (when (< i len)
+        (let ((prev (string-ref text (- i 1)))
+              (cur (string-ref text i)))
+          (when (and (char-lower-case? prev) (char-upper-case? cur))
+            ;; Mark the boundary with a 1-char indicator on the uppercase char
+            (send-message ed SCI_INDICATORFILLRANGE i 1)))
+        (loop (+ i 1))))))
+
+(def (glasses-clear! ed)
+  "Remove all glasses indicators."
+  (let ((len (editor-get-text-length ed)))
+    (send-message ed SCI_SETINDICATORCURRENT *glasses-indicator* 0)
+    (send-message ed SCI_INDICATORCLEARRANGE 0 (max 1 len))))
+
 (def (cmd-glasses-mode app)
-  "Toggle glasses mode (visual CamelCase separation)."
-  (let ((on (toggle-mode! 'glasses)))
+  "Toggle glasses mode (visual CamelCase separation with indicators)."
+  (let ((on (toggle-mode! 'glasses))
+        (ed (current-editor app)))
+    (if on
+      (glasses-refresh! ed)
+      (glasses-clear! ed))
     (echo-message! (app-state-echo app)
       (if on "Glasses mode enabled" "Glasses mode disabled"))))
 
@@ -1521,17 +1555,75 @@
                   (number->string (string-length text)) " chars)")))))))))
 
 ;;; --- Highlight changes tracking ---
+;;; Uses Scintilla indicator #3 to mark lines modified since last save.
+;;; Tracks modified line ranges and highlights them with a margin marker.
 
 (def *highlight-changes-mode* #f)
+(def *highlight-changes-indicator* 3)
+(def *highlight-changes-saved-text* (make-hash-table))  ; buffer-name -> text at last save
+
+(def (highlight-changes-snapshot! app)
+  "Snapshot current buffer text as the 'clean' baseline for change tracking."
+  (when *highlight-changes-mode*
+    (let* ((buf (current-buffer-from-app app))
+           (ed (current-editor app))
+           (text (editor-get-text ed)))
+      (when buf
+        (hash-put! *highlight-changes-saved-text* (buffer-name buf) text)))))
+
+(def (highlight-changes-refresh! app)
+  "Refresh change indicators by comparing current text against saved snapshot.
+   Highlights lines that differ from the baseline."
+  (when *highlight-changes-mode*
+    (let* ((ed (current-editor app))
+           (buf (current-buffer-from-app app)))
+      (when (and ed buf)
+        (let* ((name (buffer-name buf))
+               (saved (hash-get *highlight-changes-saved-text* name))
+               (current (editor-get-text ed))
+               (total-len (string-length current)))
+          ;; Clear existing change indicators
+          (send-message ed SCI_SETINDICATORCURRENT *highlight-changes-indicator* 0)
+          (send-message ed SCI_INDICATORCLEARRANGE 0 (max 1 total-len))
+          (when (and saved (not (string=? saved current)))
+            ;; Set up indicator style: yellow left-edge bar
+            (send-message ed SCI_INDICSETSTYLE *highlight-changes-indicator* INDIC_FULLBOX)
+            (send-message ed SCI_INDICSETFORE *highlight-changes-indicator* #x60D0FF) ; orange
+            (send-message ed SCI_INDICSETALPHA *highlight-changes-indicator* 40)
+            (send-message ed SCI_INDICSETUNDER *highlight-changes-indicator* 1)
+            (send-message ed SCI_SETINDICATORCURRENT *highlight-changes-indicator* 0)
+            ;; Compare line by line
+            (let* ((saved-lines (string-split saved #\newline))
+                   (cur-lines (string-split current #\newline))
+                   (n-cur (length cur-lines)))
+              (let loop ((i 0) (pos 0) (sl saved-lines) (cl cur-lines))
+                (when (pair? cl)
+                  (let* ((cur-line (car cl))
+                         (line-len (string-length cur-line))
+                         (saved-line (if (pair? sl) (car sl) ""))
+                         (changed? (not (string=? cur-line saved-line))))
+                    (when (and changed? (> line-len 0))
+                      (send-message ed SCI_INDICATORFILLRANGE pos line-len))
+                    ;; +1 for the newline separator
+                    (loop (+ i 1) (+ pos line-len 1)
+                          (if (pair? sl) (cdr sl) '())
+                          (cdr cl))))))))))))
 
 (def (cmd-toggle-highlight-changes app)
   "Toggle tracking of modified regions."
   (let ((echo (app-state-echo app)))
     (set! *highlight-changes-mode* (not *highlight-changes-mode*))
-    (echo-message! echo
-      (if *highlight-changes-mode*
-        "Highlight-changes mode enabled"
-        "Highlight-changes mode disabled"))))
+    (if *highlight-changes-mode*
+      (begin
+        (highlight-changes-snapshot! app)
+        (echo-message! echo "Highlight-changes mode enabled"))
+      (begin
+        ;; Clear indicators when turning off
+        (let* ((ed (current-editor app))
+               (len (editor-get-text-length ed)))
+          (send-message ed SCI_SETINDICATORCURRENT *highlight-changes-indicator* 0)
+          (send-message ed SCI_INDICATORCLEARRANGE 0 (max 1 len)))
+        (echo-message! echo "Highlight-changes mode disabled")))))
 
 ;;; --- Window layout save/restore ---
 
diff --git a/src/jerboa-emacs/editor-extra-web.ss b/src/jerboa-emacs/editor-extra-web.ss
index f7cc6a6..9058ee8 100644
--- a/src/jerboa-emacs/editor-extra-web.ss
+++ b/src/jerboa-emacs/editor-extra-web.ss
@@ -1118,8 +1118,8 @@
     (echo-message! echo "Timer list displayed")))
 
 ;;; --- Aggressive indent mode ---
-
-(def *aggressive-indent-mode* #f)
+;;; *aggressive-indent-mode* and tui-aggressive-indent-line! are defined in
+;;; editor-core.ss so the self-insert path can use them without circular imports.
 
 (def (cmd-toggle-aggressive-indent app)
   "Toggle aggressive auto-indent mode."
diff --git a/src/jerboa-emacs/qt/app.ss b/src/jerboa-emacs/qt/app.ss
index 56b693f..f5e091f 100644
--- a/src/jerboa-emacs/qt/app.ss
+++ b/src/jerboa-emacs/qt/app.ss
@@ -84,7 +84,7 @@
         :jerboa-emacs/qt/menubar
         :jerboa-emacs/ipc
         :jerboa-emacs/vtscreen
-        (only-in :jerboa-emacs/editor-extra-web *aggressive-indent-mode*)
+        (only-in :jerboa-emacs/editor-core *aggressive-indent-mode*)
         (only-in :jerboa-emacs/debug-repl start-debug-repl! stop-debug-repl! debug-repl-bind!)
         :jerboa-emacs/qt/automation)