Fix terminal bugs: cd path resolution, history navigation, chord interference

ober

140aa197e9756ad71d5e8b0a8fbbc59a04cc74e0

diff --git a/lib/jerboa-emacs/qt/app.sls b/lib/jerboa-emacs/qt/app.sls
index 5c657b0..e741fbd 100644
--- a/lib/jerboa-emacs/qt/app.sls
+++ b/lib/jerboa-emacs/qt/app.sls
@@ -72,6 +72,7 @@
      *which-key-mode* *which-key-delay* *abbrev-mode-enabled*
      *abbrev-table*)
    (jerboa-emacs repl) (jerboa-emacs eshell)
+   (only (jerboa-emacs gsh-eshell) gsh-eshell-buffer?)
    (jerboa-emacs shell) (jerboa-emacs shell-history)
    (jerboa-emacs terminal) (jerboa-emacs chat)
    (jerboa-emacs qt keymap) (jerboa-emacs qt buffer)
@@ -89,6 +90,40 @@
      start-debug-repl!
      stop-debug-repl!)
    (jerboa core) (jerboa runtime))
+  (def *vterm-render-interval-ms* 100)
+  (def *vterm-scrollback-limit* 100000)
+  (def *vterm-last-render-time* (make-hash-table-eq))
+  (def *vterm-last-rendered* (make-hash-table-eq))
+  (def (vterm-render-due? ts)
+       "Return #t if enough time has elapsed since last render for this terminal."
+       (let ([last (hash-ref *vterm-last-render-time* ts 0.0)]
+             [now (time->seconds (current-time))])
+         (>= (* (- now last) 1000) *vterm-render-interval-ms*)))
+  (def (vterm-mark-rendered! ts)
+       "Record that we just rendered this terminal."
+       (hash-put!
+         *vterm-last-render-time*
+         ts
+         (time->seconds (current-time))))
+  (def (vterm-cap-scrollback! ts)
+       "Trim pre-pty-text if it exceeds the scrollback limit."
+       (let ([text (terminal-state-pre-pty-text ts)])
+         (when (and (string? text)
+                    (> (string-length text) *vterm-scrollback-limit*))
+           (let* ([start (- (string-length text)
+                            *vterm-scrollback-limit*)]
+                  [nl (let scan ([i start])
+                        (cond
+                          [(>= i (string-length text)) start]
+                          [(char=? (string-ref text i) #\newline) (+ i 1)]
+                          [else (scan (+ i 1))]))])
+             (terminal-state-pre-pty-text-set!
+               ts
+               (substring text nl (string-length text)))))))
+  (def (vterm-cleanup-state! ts)
+       "Remove throttle state for a terminal that's done."
+       (hash-remove! *vterm-last-render-time* ts)
+       (hash-remove! *vterm-last-rendered* ts))
   (def (parse-repl-port args)
        "Return (port-num . filtered-args) if --repl <port> is present, else #f."
        (let loop ([rest args] [acc (list)])
@@ -314,7 +349,7 @@
                           (qt-plain-text-edit-ensure-cursor-visible! ed)))
                       (loop (cdr wins))))))])))
   (def (qt-poll-terminal-pty-batch! fr buf ts data)
-       "Handle batched PTY data for a terminal buffer.\n   Processes all accumulated data at once, rendering only once."
+       "Handle batched PTY data for a terminal buffer.\n   Feeds data to vtscreen immediately but throttles rendering to avoid\n   replacing the entire QScintilla document more than ~10 times/sec."
        (let ([vt (terminal-state-vtscreen ts)])
          (let loop ([wins (qt-frame-windows fr)])
            (when (pair? wins)
@@ -327,19 +362,29 @@
                    (if vt
                        (begin
                          (vtscreen-feed! vt data)
-                         (let* ([rendered (vtscreen-render vt)]
-                                [full (if (vtscreen-alt-screen? vt)
-                                          rendered
-                                          (string-append
-                                            (or (terminal-state-pre-pty-text
-                                                  ts)
-                                                "")
-                                            rendered))])
-                           (qt-plain-text-edit-set-text! ed full)
-                           (qt-plain-text-edit-move-cursor!
-                             ed
-                             QT_CURSOR_END)
-                           (qt-plain-text-edit-ensure-cursor-visible! ed)))
+                         (vterm-cap-scrollback! ts)
+                         (when (vterm-render-due? ts)
+                           (let* ([rendered (vtscreen-render vt)]
+                                  [full (if (vtscreen-alt-screen? vt)
+                                            rendered
+                                            (string-append
+                                              (or (terminal-state-pre-pty-text
+                                                    ts)
+                                                  "")
+                                              rendered))]
+                                  [prev (hash-ref
+                                          *vterm-last-rendered*
+                                          ts
+                                          #f)])
+                             (unless (and prev (string=? prev full))
+                               (hash-put! *vterm-last-rendered* ts full)
+                               (qt-plain-text-edit-set-text! ed full)
+                               (qt-plain-text-edit-move-cursor!
+                                 ed
+                                 QT_CURSOR_END)
+                               (qt-plain-text-edit-ensure-cursor-visible!
+                                 ed))
+                             (vterm-mark-rendered! ts))))
                        (begin
                          (qt-plain-text-edit-move-cursor! ed QT_CURSOR_END)
                          (qt-plain-text-edit-insert-text!
@@ -360,6 +405,7 @@
             (let* ([alt-screen? (and vt (vtscreen-alt-screen? vt))]
                    [final-render (and vt (vtscreen-render vt))]
                    [pre-text (terminal-state-pre-pty-text ts)])
+              (vterm-cleanup-state! ts)
               (terminal-cleanup-pty! ts)
               (let loop ([wins (qt-frame-windows fr)])
                 (when (pair? wins)
@@ -1111,7 +1157,15 @@
                                                    (app-state-key-state
                                                      app)))
                                                (chord-start-char?
-                                                 (string-ref text 0)))
+                                                 (string-ref text 0))
+                                               (let ([cur-buf (qt-current-buffer
+                                                                fr)])
+                                                 (not (or (terminal-buffer?
+                                                            cur-buf)
+                                                          (shell-buffer?
+                                                            cur-buf)
+                                                          (gsh-eshell-buffer?
+                                                            cur-buf)))))
                                           (set! *chord-pending-char*
                                             (string-ref text 0))
                                           (set! *chord-pending-code* code)
@@ -1222,20 +1276,31 @@
                          (let ([msg (terminal-poll-output ts)])
                            (cond
                              [(not msg)
-                              (when (pair? chunks)
-                                (let ([combined (apply
-                                                  string-append
-                                                  (reverse chunks))])
-                                  (verbose-log!
-                                    "PTY-BATCH: "
-                                    (number->string
-                                      (string-length combined))
-                                    " bytes")
-                                  (qt-poll-terminal-pty-batch!
-                                    fr
-                                    buf
-                                    ts
-                                    combined)))
+                              (if (pair? chunks)
+                                  (let ([combined (apply
+                                                    string-append
+                                                    (reverse chunks))])
+                                    (verbose-log!
+                                      "PTY-BATCH: "
+                                      (number->string
+                                        (string-length combined))
+                                      " bytes")
+                                    (qt-poll-terminal-pty-batch!
+                                      fr
+                                      buf
+                                      ts
+                                      combined))
+                                  (when (and (terminal-state-vtscreen ts)
+                                             (hash-ref
+                                               *vterm-last-rendered*
+                                               ts
+                                               #f)
+                                             (vterm-render-due? ts))
+                                    (qt-poll-terminal-pty-batch!
+                                      fr
+                                      buf
+                                      ts
+                                      "")))
                               (when done-msg
                                 (qt-poll-terminal-pty-msg!
                                   fr
diff --git a/lib/jerboa-emacs/qt/commands-config.sls b/lib/jerboa-emacs/qt/commands-config.sls
index 7977dd6..18b351c 100644
--- a/lib/jerboa-emacs/qt/commands-config.sls
+++ b/lib/jerboa-emacs/qt/commands-config.sls
@@ -37,7 +37,8 @@
      buffer-local-set!)
    (jerboa-emacs editor) (jerboa-emacs repl)
    (jerboa-emacs eshell) (jerboa-emacs gsh-eshell)
-   (jerboa-emacs shell) (jerboa-emacs terminal)
+   (jerboa-emacs shell) (jerboa-emacs shell-history)
+   (jerboa-emacs terminal) (only (jsh environment) env-get)
    (jerboa-emacs qt buffer) (jerboa-emacs qt window)
    (jerboa-emacs qt echo) (jerboa-emacs qt highlight)
    (jerboa-emacs qt modeline) (jerboa-emacs qt commands-core)
@@ -926,6 +927,15 @@
                       [cols (max 20 (quotient widget-w 8))])
                  (verbose-log! "cmd-terminal-send: input=" input " rows="
                    (number->string rows) " cols=" (number->string cols))
+                 (let ([trimmed-input (safe-string-trim-both input)])
+                   (when (and (> (string-length trimmed-input) 0)
+                              (not (string=? trimmed-input "clear"))
+                              (not (string=? trimmed-input "exit")))
+                     (gsh-history-add!
+                       trimmed-input
+                       (or (env-get (terminal-state-env ts) "PWD")
+                           (current-directory)))))
+                 (terminal-history-reset! buf)
                  (qt-plain-text-edit-move-cursor! ed QT_CURSOR_END)
                  (qt-plain-text-edit-insert-text! ed "\n")
                  (let-values ([(mode output new-cwd)
diff --git a/lib/jerboa-emacs/qt/commands-core.sls b/lib/jerboa-emacs/qt/commands-core.sls
index 394fba8..cf4889f 100644
--- a/lib/jerboa-emacs/qt/commands-core.sls
+++ b/lib/jerboa-emacs/qt/commands-core.sls
@@ -18,7 +18,8 @@
    read-dir-locals update-mark-region!
    collapse-selection-to-caret! cmd-forward-char
    cmd-backward-char eshell-on-input-line? eshell-current-input
-   eshell-replace-input! cmd-next-line cmd-previous-line
+   eshell-replace-input! terminal-current-input
+   terminal-replace-input! cmd-next-line cmd-previous-line
    cmd-beginning-of-line cmd-end-of-line cmd-forward-word
    cmd-backward-word subword-boundary? cmd-forward-subword
    cmd-backward-subword cmd-kill-subword
@@ -435,39 +436,72 @@
                ed
                (string-append before new-input))
              (qt-plain-text-edit-move-cursor! ed QT_CURSOR_END)))))
+  (def (terminal-current-input ed ts)
+       "Get the text after the prompt in a terminal buffer."
+       (let* ([text (qt-plain-text-edit-text ed)]
+              [prompt-pos (terminal-state-prompt-pos ts)])
+         (if (< prompt-pos (string-length text))
+             (substring text prompt-pos (string-length text))
+             "")))
+  (def (terminal-replace-input! ed ts new-input)
+       "Replace the current input (text after prompt) in a terminal buffer."
+       (let* ([text (qt-plain-text-edit-text ed)]
+              [prompt-pos (terminal-state-prompt-pos ts)]
+              [before (if (<= prompt-pos (string-length text))
+                          (substring text 0 prompt-pos)
+                          text)])
+         (qt-plain-text-edit-set-text!
+           ed
+           (string-append before new-input))
+         (qt-plain-text-edit-move-cursor! ed QT_CURSOR_END)))
   (def (cmd-next-line app)
        (let* ([buf (current-qt-buffer app)]
               [ed (current-qt-editor app)])
-         (if (and (gsh-eshell-buffer? buf)
-                  (eshell-on-input-line? ed))
-             (let ([cmd (eshell-history-next buf)])
-               (when cmd (eshell-replace-input! ed cmd)))
-             (let ([n (get-prefix-arg app)])
-               (collapse-selection-to-caret! ed)
-               (let loop ([i 0])
-                 (when (< i (abs n))
-                   (qt-plain-text-edit-move-cursor!
-                     ed
-                     (if (>= n 0) QT_CURSOR_DOWN QT_CURSOR_UP))
-                   (loop (+ i 1))))
-               (update-mark-region! app ed)))))
+         (cond
+           [(and (gsh-eshell-buffer? buf) (eshell-on-input-line? ed))
+            (let ([cmd (eshell-history-next buf)])
+              (when cmd (eshell-replace-input! ed cmd)))]
+           [(and (terminal-buffer? buf)
+                 (let ([ts (hash-get *terminal-state* buf)])
+                   (and ts (not (terminal-pty-busy? ts)))))
+            (let* ([ts (hash-get *terminal-state* buf)]
+                   [cmd (terminal-history-next buf)])
+              (when cmd (terminal-replace-input! ed ts cmd)))]
+           [else
+            (let ([n (get-prefix-arg app)])
+              (collapse-selection-to-caret! ed)
+              (let loop ([i 0])
+                (when (< i (abs n))
+                  (qt-plain-text-edit-move-cursor!
+                    ed
+                    (if (>= n 0) QT_CURSOR_DOWN QT_CURSOR_UP))
+                  (loop (+ i 1))))
+              (update-mark-region! app ed))])))
   (def (cmd-previous-line app)
        (let* ([buf (current-qt-buffer app)]
               [ed (current-qt-editor app)])
-         (if (and (gsh-eshell-buffer? buf)
-                  (eshell-on-input-line? ed))
-             (let* ([input (eshell-current-input ed)]
-                    [cmd (eshell-history-prev buf input)])
-               (when cmd (eshell-replace-input! ed cmd)))
-             (let ([n (get-prefix-arg app)])
-               (collapse-selection-to-caret! ed)
-               (let loop ([i 0])
-                 (when (< i (abs n))
-                   (qt-plain-text-edit-move-cursor!
-                     ed
-                     (if (>= n 0) QT_CURSOR_UP QT_CURSOR_DOWN))
-                   (loop (+ i 1))))
-               (update-mark-region! app ed)))))
+         (cond
+           [(and (gsh-eshell-buffer? buf) (eshell-on-input-line? ed))
+            (let* ([input (eshell-current-input ed)]
+                   [cmd (eshell-history-prev buf input)])
+              (when cmd (eshell-replace-input! ed cmd)))]
+           [(and (terminal-buffer? buf)
+                 (let ([ts (hash-get *terminal-state* buf)])
+                   (and ts (not (terminal-pty-busy? ts)))))
+            (let* ([ts (hash-get *terminal-state* buf)]
+                   [input (terminal-current-input ed ts)]
+                   [cmd (terminal-history-prev buf input)])
+              (when cmd (terminal-replace-input! ed ts cmd)))]
+           [else
+            (let ([n (get-prefix-arg app)])
+              (collapse-selection-to-caret! ed)
+              (let loop ([i 0])
+                (when (< i (abs n))
+                  (qt-plain-text-edit-move-cursor!
+                    ed
+                    (if (>= n 0) QT_CURSOR_UP QT_CURSOR_DOWN))
+                  (loop (+ i 1))))
+              (update-mark-region! app ed))])))
   (def (cmd-beginning-of-line app)
        "Smart beginning of line: toggle between first non-whitespace and column 0."
        (let* ([ed (current-qt-editor app)]
diff --git a/lib/jerboa-emacs/qt/commands-shell.sls b/lib/jerboa-emacs/qt/commands-shell.sls
index c6d5e3b..8cbb3bf 100644
--- a/lib/jerboa-emacs/qt/commands-shell.sls
+++ b/lib/jerboa-emacs/qt/commands-shell.sls
@@ -109,7 +109,8 @@
        (and (file-exists? path) (file-directory? path)))
   (def (apply-font-size-to-all-editors! app)
        "Apply the current global font size to all open editors."
-       (let ([fr (app-state-frame app)])
+       (let ([fr (app-state-frame app)]
+             [margin-w (max 30 (* *default-font-size* 3))])
          (for-each
            (lambda (win)
              (let ([ed (qt-edit-window-editor win)])
@@ -118,7 +119,9 @@
                  SCI_STYLESETSIZE
                  STYLE_DEFAULT
                  *default-font-size*)
-               (sci-send ed SCI_STYLECLEARALL)))
+               (sci-send ed SCI_STYLECLEARALL)
+               (sci-send ed SCI_SETMARGINWIDTHN 0 margin-w)
+               (qt-apply-editor-theme! ed)))
            (qt-frame-windows fr)))
        (when *qt-app-ptr*
          (qt-app-set-style-sheet! *qt-app-ptr* (theme-stylesheet))))
diff --git a/lib/jerboa-emacs/qt/echo.sls b/lib/jerboa-emacs/qt/echo.sls
index 91c5cd0..55073ef 100644
--- a/lib/jerboa-emacs/qt/echo.sls
+++ b/lib/jerboa-emacs/qt/echo.sls
@@ -226,12 +226,14 @@
                            (take filtered (* *mb-max-visible* 3))
                            filtered)])
            (set! *mb-filtered* (list->vector shown))
+           (qt-widget-set-updates-enabled! *mb-list* #f)
            (qt-list-widget-clear! *mb-list*)
            (for-each
              (lambda (c) (qt-list-widget-add-item! *mb-list* c))
              shown)
            (when (> (vector-length *mb-filtered*) 0)
              (qt-list-widget-set-current-row! *mb-list* 0))
+           (qt-widget-set-updates-enabled! *mb-list* #t)
            (let ([total (length *mb-all-candidates*)]
                  [matched (length filtered)])
              (qt-label-set-text!
diff --git a/lib/jerboa-emacs/qt/menubar.sls b/lib/jerboa-emacs/qt/menubar.sls
index 6ce5de1..3683c05 100644
--- a/lib/jerboa-emacs/qt/menubar.sls
+++ b/lib/jerboa-emacs/qt/menubar.sls
@@ -10,6 +10,7 @@
       mutex? mutex-name)
     (std sugar) (jerboa-emacs qt sci-shim) (jerboa-emacs core)
     (jerboa core) (jerboa runtime))
+  (def *menu-command-running?* #f)
   (def (qt-setup-menubar! app win)
        "Set up the menu bar and toolbar for the main window."
        (let ([menu-bar (qt-main-window-menu-bar win)])
@@ -100,7 +101,14 @@
               [action (qt-action-create display-label win)])
          (qt-on-triggered!
            action
-           (lambda () (execute-command! app command-name)))
+           (lambda ()
+             (unless *menu-command-running?*
+               (set! *menu-command-running?* #t)
+               (with-catch
+                 (lambda (e) (set! *menu-command-running?* #f) (raise e))
+                 (lambda ()
+                   (execute-command! app command-name)
+                   (set! *menu-command-running?* #f))))))
          (qt-menu-add-action! menu action)
          action))
   (def (add-toolbar-command! toolbar win app label
diff --git a/lib/jerboa-emacs/qt/sci-shim.sls b/lib/jerboa-emacs/qt/sci-shim.sls
index 650cfb3..04a9ac7 100644
--- a/lib/jerboa-emacs/qt/sci-shim.sls
+++ b/lib/jerboa-emacs/qt/sci-shim.sls
@@ -114,22 +114,23 @@
    qt-widget-set-focus! qt-widget-set-font-size!
    qt-widget-set-maximum-height! qt-widget-set-minimum-height!
    qt-widget-set-minimum-size! qt-widget-set-size-policy!
-   qt-widget-set-style-sheet! qt-widget-show!
-   qt-widget-show-fullscreen! qt-widget-show-maximized!
-   qt-widget-show-minimized! qt-widget-show-normal!
-   qt-widget-width qt-widget-window-state QT_MOD_SHIFT
-   QT_MOD_ALT QT_MOD_META QT_MOD_CTRL QT_KEY_ESCAPE
-   QT_KEY_BACKSPACE QT_KEY_RETURN QT_KEY_ENTER QT_KEY_DELETE
-   QT_KEY_TAB QT_KEY_INSERT QT_KEY_HOME QT_KEY_END QT_KEY_LEFT
-   QT_KEY_RIGHT QT_KEY_UP QT_KEY_DOWN QT_KEY_PAGE_UP
-   QT_KEY_PAGE_DOWN QT_KEY_SPACE QT_KEY_A QT_KEY_G QT_KEY_N
-   QT_KEY_P QT_KEY_R QT_KEY_S QT_KEY_W QT_KEY_Z QT_KEY_F1
-   QT_KEY_F2 QT_KEY_F3 QT_KEY_F4 QT_KEY_F5 QT_KEY_F6 QT_KEY_F7
-   QT_KEY_F8 QT_KEY_F9 QT_KEY_F10 QT_KEY_F11 QT_KEY_F12
-   QT_CURSOR_UP QT_CURSOR_DOWN QT_CURSOR_START QT_CURSOR_END
-   QT_CURSOR_START_OF_BLOCK QT_CURSOR_END_OF_BLOCK
-   QT_CURSOR_NEXT_CHAR QT_CURSOR_NEXT_WORD
-   QT_CURSOR_PREVIOUS_CHAR QT_CURSOR_PREVIOUS_WORD)
+   qt-widget-set-style-sheet! qt-widget-set-updates-enabled!
+   qt-widget-show! qt-widget-show-fullscreen!
+   qt-widget-show-maximized! qt-widget-show-minimized!
+   qt-widget-show-normal! qt-widget-width
+   qt-widget-window-state QT_MOD_SHIFT QT_MOD_ALT QT_MOD_META
+   QT_MOD_CTRL QT_KEY_ESCAPE QT_KEY_BACKSPACE QT_KEY_RETURN
+   QT_KEY_ENTER QT_KEY_DELETE QT_KEY_TAB QT_KEY_INSERT
+   QT_KEY_HOME QT_KEY_END QT_KEY_LEFT QT_KEY_RIGHT QT_KEY_UP
+   QT_KEY_DOWN QT_KEY_PAGE_UP QT_KEY_PAGE_DOWN QT_KEY_SPACE
+   QT_KEY_A QT_KEY_G QT_KEY_N QT_KEY_P QT_KEY_R QT_KEY_S
+   QT_KEY_W QT_KEY_Z QT_KEY_F1 QT_KEY_F2 QT_KEY_F3 QT_KEY_F4
+   QT_KEY_F5 QT_KEY_F6 QT_KEY_F7 QT_KEY_F8 QT_KEY_F9 QT_KEY_F10
+   QT_KEY_F11 QT_KEY_F12 QT_CURSOR_UP QT_CURSOR_DOWN
+   QT_CURSOR_START QT_CURSOR_END QT_CURSOR_START_OF_BLOCK
+   QT_CURSOR_END_OF_BLOCK QT_CURSOR_NEXT_CHAR
+   QT_CURSOR_NEXT_WORD QT_CURSOR_PREVIOUS_CHAR
+   QT_CURSOR_PREVIOUS_WORD)
   (import
     (except (chezscheme) make-hash-table hash-table? iota \x31;+ \x31;-
       getenv path-extension path-absolute? thread? make-mutex
diff --git a/lib/jerboa-emacs/qt/window.sls b/lib/jerboa-emacs/qt/window.sls
index 50dcbe0..243b0df 100644
--- a/lib/jerboa-emacs/qt/window.sls
+++ b/lib/jerboa-emacs/qt/window.sls
@@ -235,7 +235,11 @@
        "Configure QScintilla editor: theme, margins, caret, save-point signals."
        (qt-apply-editor-theme! ed)
        (sci-send ed SCI_SETMARGINTYPEN 0 SC_MARGIN_NUMBER)
-       (sci-send ed SCI_SETMARGINWIDTHN 0 50)
+       (sci-send
+         ed
+         SCI_SETMARGINWIDTHN
+         0
+         (max 30 (* *default-font-size* 3)))
        (sci-send ed SCI_SETMARGINWIDTHN 1 0)
        (sci-send ed SCI_SETMARGINWIDTHN 2 0)
        (sci-send ed SCI_SETMARGINWIDTHN 3 0)
diff --git a/src/jerboa-emacs/core.ss b/src/jerboa-emacs/core.ss
index 5e3806a..2c80856 100644
--- a/src/jerboa-emacs/core.ss
+++ b/src/jerboa-emacs/core.ss
@@ -2169,8 +2169,8 @@
 ;; Time window in milliseconds for second key of chord.
 ;; Emacs key-chord.el uses 100ms for two-key and 200ms for same-key chords.
 ;; We use 300ms by default because Qt key event delivery adds latency.
-(def *chord-timeout* 300)
-(defvar! 'chord-timeout 300 "Milliseconds to wait for second key of a chord"
+(def *chord-timeout* 150)
+(defvar! 'chord-timeout 150 "Milliseconds to wait for second key of a chord"
          setter: (lambda (v) (set! *chord-timeout* v))
          type: 'integer type-args: '(50 . 1000) group: 'keybindings)
 
diff --git a/src/jerboa-emacs/qt/app.ss b/src/jerboa-emacs/qt/app.ss
index cee845c..d77ca0d 100644
--- a/src/jerboa-emacs/qt/app.ss
+++ b/src/jerboa-emacs/qt/app.ss
@@ -65,6 +65,7 @@
                  *abbrev-mode-enabled* *abbrev-table*)
         :jerboa-emacs/repl
         :jerboa-emacs/eshell
+        (only-in :jerboa-emacs/gsh-eshell gsh-eshell-buffer?)
         :jerboa-emacs/shell
         :jerboa-emacs/shell-history
         :jerboa-emacs/terminal
@@ -86,6 +87,50 @@
         (only-in :jerboa-emacs/debug-repl start-debug-repl! stop-debug-repl!))
 
 ;;;============================================================================
+;;; Vterm render throttle — skip intermediate renders during fast output
+;;;============================================================================
+
+;; Minimum milliseconds between vterm renders (vtscreen → set-text!)
+(def *vterm-render-interval-ms* 100)
+
+;; Maximum number of characters to keep in pre-pty scrollback text
+(def *vterm-scrollback-limit* 100000)
+
+;; Per-terminal-state: timestamp (seconds) of last render
+(def *vterm-last-render-time* (make-hash-table-eq))
+
+;; Per-terminal-state: last rendered string (skip set-text if unchanged)
+(def *vterm-last-rendered* (make-hash-table-eq))
+
+(def (vterm-render-due? ts)
+  "Return #t if enough time has elapsed since last render for this terminal."
+  (let ((last (hash-ref *vterm-last-render-time* ts 0.0))
+        (now (time->seconds (current-time))))
+    (>= (* (- now last) 1000) *vterm-render-interval-ms*)))
+
+(def (vterm-mark-rendered! ts)
+  "Record that we just rendered this terminal."
+  (hash-put! *vterm-last-render-time* ts (time->seconds (current-time))))
+
+(def (vterm-cap-scrollback! ts)
+  "Trim pre-pty-text if it exceeds the scrollback limit."
+  (let ((text (terminal-state-pre-pty-text ts)))
+    (when (and (string? text) (> (string-length text) *vterm-scrollback-limit*))
+      ;; Keep the last *vterm-scrollback-limit* chars, trim at a newline boundary
+      (let* ((start (- (string-length text) *vterm-scrollback-limit*))
+             (nl (let scan ((i start))
+                   (cond ((>= i (string-length text)) start)
+                         ((char=? (string-ref text i) #\newline) (+ i 1))
+                         (else (scan (+ i 1)))))))
+        (set! (terminal-state-pre-pty-text ts)
+          (substring text nl (string-length text)))))))
+
+(def (vterm-cleanup-state! ts)
+  "Remove throttle state for a terminal that's done."
+  (hash-remove! *vterm-last-render-time* ts)
+  (hash-remove! *vterm-last-rendered* ts))
+
+;;;============================================================================
 ;;; Qt Application
 ;;;============================================================================
 
@@ -309,7 +354,8 @@
 
 (def (qt-poll-terminal-pty-batch! fr buf ts data)
   "Handle batched PTY data for a terminal buffer.
-   Processes all accumulated data at once, rendering only once."
+   Feeds data to vtscreen immediately but throttles rendering to avoid
+   replacing the entire QScintilla document more than ~10 times/sec."
   (let ((vt (terminal-state-vtscreen ts)))
     (let loop ((wins (qt-frame-windows fr)))
       (when (pair? wins)
@@ -321,15 +367,25 @@
                 (qt-plain-text-edit-text ed)))
             (if vt
               (begin
+                ;; Always feed data to vtscreen so terminal state stays current
                 (vtscreen-feed! vt data)
-                (let* ((rendered (vtscreen-render vt))
-                       (full (if (vtscreen-alt-screen? vt)
-                               rendered
-                               (string-append (or (terminal-state-pre-pty-text ts) "")
-                                              rendered))))
-                  (qt-plain-text-edit-set-text! ed full)
-                  (qt-plain-text-edit-move-cursor! ed QT_CURSOR_END)
-                  (qt-plain-text-edit-ensure-cursor-visible! ed)))
+                ;; Cap scrollback to prevent unbounded growth
+                (vterm-cap-scrollback! ts)
+                ;; Only render to the widget if enough time has elapsed
+                (when (vterm-render-due? ts)
+                  (let* ((rendered (vtscreen-render vt))
+                         (full (if (vtscreen-alt-screen? vt)
+                                 rendered
+                                 (string-append (or (terminal-state-pre-pty-text ts) "")
+                                                rendered)))
+                         (prev (hash-ref *vterm-last-rendered* ts #f)))
+                    ;; Skip set-text if content hasn't changed
+                    (unless (and prev (string=? prev full))
+                      (hash-put! *vterm-last-rendered* ts full)
+                      (qt-plain-text-edit-set-text! ed full)
+                      (qt-plain-text-edit-move-cursor! ed QT_CURSOR_END)
+                      (qt-plain-text-edit-ensure-cursor-visible! ed))
+                    (vterm-mark-rendered! ts))))
               (begin
                 (qt-plain-text-edit-move-cursor! ed QT_CURSOR_END)
                 (qt-plain-text-edit-insert-text! ed (strip-ansi-codes data))
@@ -352,6 +408,7 @@
        (let* ((alt-screen? (and vt (vtscreen-alt-screen? vt)))
               (final-render (and vt (vtscreen-render vt)))
               (pre-text (terminal-state-pre-pty-text ts)))
+         (vterm-cleanup-state! ts)
          (terminal-cleanup-pty! ts)
          (let loop ((wins (qt-frame-windows fr)))
            (when (pair? wins)
@@ -822,12 +879,18 @@
                            (do-normal-key! code mods text)))))
 
                     ;; Case 2: Printable key that could start a chord — save and wait
+                    ;; Skip chord detection in terminal/shell buffers to avoid
+                    ;; letter doubling/dropping when typing fast
                     ((and (= (string-length text) 1)
                           (> (char->integer (string-ref text 0)) 31)
                           (zero? (bitwise-and mods QT_MOD_CTRL))
                           (zero? (bitwise-and mods QT_MOD_ALT))
                           (null? (key-state-prefix-keys (app-state-key-state app)))
-                          (chord-start-char? (string-ref text 0)))
+                          (chord-start-char? (string-ref text 0))
+                          (let ((cur-buf (qt-current-buffer fr)))
+                            (not (or (terminal-buffer? cur-buf)
+                                     (shell-buffer? cur-buf)
+                                     (gsh-eshell-buffer? cur-buf)))))
                      (set! *chord-pending-char* (string-ref text 0))
                      (set! *chord-pending-code* code)
                      (set! *chord-pending-mods* mods)
@@ -921,10 +984,15 @@
                         (cond
                           ((not msg)
                            ;; No more messages — render accumulated data
-                           (when (pair? chunks)
+                           (if (pair? chunks)
                              (let ((combined (apply string-append (reverse chunks))))
                                (verbose-log! "PTY-BATCH: " (number->string (string-length combined)) " bytes")
-                               (qt-poll-terminal-pty-batch! fr buf ts combined)))
+                               (qt-poll-terminal-pty-batch! fr buf ts combined))
+                             ;; No new data — flush any throttled render
+                             (when (and (terminal-state-vtscreen ts)
+                                        (hash-ref *vterm-last-rendered* ts #f)
+                                        (vterm-render-due? ts))
+                               (qt-poll-terminal-pty-batch! fr buf ts "")))
                            (when done-msg
                              (qt-poll-terminal-pty-msg! fr buf ts done-msg)))
                           ((eq? (car msg) 'data)
diff --git a/src/jerboa-emacs/qt/commands-config.ss b/src/jerboa-emacs/qt/commands-config.ss
index 4b4cf0a..5ea792b 100644
--- a/src/jerboa-emacs/qt/commands-config.ss
+++ b/src/jerboa-emacs/qt/commands-config.ss
@@ -24,7 +24,9 @@
         :jerboa-emacs/eshell
         :jerboa-emacs/gsh-eshell
         :jerboa-emacs/shell
+        :jerboa-emacs/shell-history
         :jerboa-emacs/terminal
+        (only-in :jsh/environment env-get)
         :jerboa-emacs/qt/buffer
         :jerboa-emacs/qt/window
         :jerboa-emacs/qt/echo
@@ -760,6 +762,16 @@ modified so the next save uses the new encoding."
                (cols (max 20 (quotient widget-w 8))))
           (verbose-log! "cmd-terminal-send: input=" input " rows=" (number->string rows)
                         " cols=" (number->string cols))
+          ;; Record command in shared history
+          (let ((trimmed-input (safe-string-trim-both input)))
+            (when (and (> (string-length trimmed-input) 0)
+                       (not (string=? trimmed-input "clear"))
+                       (not (string=? trimmed-input "exit")))
+              (gsh-history-add! trimmed-input
+                (or (env-get (terminal-state-env ts) "PWD")
+                    (current-directory)))))
+          ;; Reset history navigation on submit
+          (terminal-history-reset! buf)
           ;; Append newline after user input
           (qt-plain-text-edit-move-cursor! ed QT_CURSOR_END)
           (qt-plain-text-edit-insert-text! ed "\n")
diff --git a/src/jerboa-emacs/qt/commands-core.ss b/src/jerboa-emacs/qt/commands-core.ss
index 45a4661..36831e7 100644
--- a/src/jerboa-emacs/qt/commands-core.ss
+++ b/src/jerboa-emacs/qt/commands-core.ss
@@ -465,40 +465,79 @@ Returns #t if changed, #f if not or if no record exists."
         (qt-plain-text-edit-set-text! ed (string-append before new-input))
         (qt-plain-text-edit-move-cursor! ed QT_CURSOR_END)))))
 
+(def (terminal-current-input ed ts)
+  "Get the text after the prompt in a terminal buffer."
+  (let* ((text (qt-plain-text-edit-text ed))
+         (prompt-pos (terminal-state-prompt-pos ts)))
+    (if (< prompt-pos (string-length text))
+      (substring text prompt-pos (string-length text))
+      "")))
+
+(def (terminal-replace-input! ed ts new-input)
+  "Replace the current input (text after prompt) in a terminal buffer."
+  (let* ((text (qt-plain-text-edit-text ed))
+         (prompt-pos (terminal-state-prompt-pos ts))
+         (before (if (<= prompt-pos (string-length text))
+                   (substring text 0 prompt-pos)
+                   text)))
+    (qt-plain-text-edit-set-text! ed (string-append before new-input))
+    (qt-plain-text-edit-move-cursor! ed QT_CURSOR_END)))
+
 (def (cmd-next-line app)
   (let* ((buf (current-qt-buffer app))
          (ed (current-qt-editor app)))
-    (if (and (gsh-eshell-buffer? buf) (eshell-on-input-line? ed))
+    (cond
       ;; Eshell: navigate to newer history entry
-      (let ((cmd (eshell-history-next buf)))
-        (when cmd
-          (eshell-replace-input! ed cmd)))
+      ((and (gsh-eshell-buffer? buf) (eshell-on-input-line? ed))
+       (let ((cmd (eshell-history-next buf)))
+         (when cmd
+           (eshell-replace-input! ed cmd))))
+      ;; Terminal: navigate to newer history entry (when not PTY busy)
+      ((and (terminal-buffer? buf)
+            (let ((ts (hash-get *terminal-state* buf)))
+              (and ts (not (terminal-pty-busy? ts)))))
+       (let* ((ts (hash-get *terminal-state* buf))
+              (cmd (terminal-history-next buf)))
+         (when cmd
+           (terminal-replace-input! ed ts cmd))))
       ;; Normal: move cursor down
-      (let ((n (get-prefix-arg app)))
-        (collapse-selection-to-caret! ed)
-        (let loop ((i 0))
-          (when (< i (abs n))
-            (qt-plain-text-edit-move-cursor! ed (if (>= n 0) QT_CURSOR_DOWN QT_CURSOR_UP))
-            (loop (+ i 1))))
-        (update-mark-region! app ed)))))
+      (else
+       (let ((n (get-prefix-arg app)))
+         (collapse-selection-to-caret! ed)
+         (let loop ((i 0))
+           (when (< i (abs n))
+             (qt-plain-text-edit-move-cursor! ed (if (>= n 0) QT_CURSOR_DOWN QT_CURSOR_UP))
+             (loop (+ i 1))))
+         (update-mark-region! app ed))))))
 
 (def (cmd-previous-line app)
   (let* ((buf (current-qt-buffer app))
          (ed (current-qt-editor app)))
-    (if (and (gsh-eshell-buffer? buf) (eshell-on-input-line? ed))
+    (cond
       ;; Eshell: navigate to older history entry
-      (let* ((input (eshell-current-input ed))
-             (cmd (eshell-history-prev buf input)))
-        (when cmd
-          (eshell-replace-input! ed cmd)))
+      ((and (gsh-eshell-buffer? buf) (eshell-on-input-line? ed))
+       (let* ((input (eshell-current-input ed))
+              (cmd (eshell-history-prev buf input)))
+         (when cmd
+           (eshell-replace-input! ed cmd))))
+      ;; Terminal: navigate to older history entry (when not PTY busy)
+      ((and (terminal-buffer? buf)
+            (let ((ts (hash-get *terminal-state* buf)))
+              (and ts (not (terminal-pty-busy? ts)))))
+       (let* ((ts (hash-get *terminal-state* buf))
+              (input (terminal-current-input ed ts))
+              (cmd (terminal-history-prev buf input)))
+         (when cmd
+           (terminal-replace-input! ed ts cmd))))
       ;; Normal: move cursor up
-      (let ((n (get-prefix-arg app)))
-        (collapse-selection-to-caret! ed)
-        (let loop ((i 0))
-          (when (< i (abs n))
-            (qt-plain-text-edit-move-cursor! ed (if (>= n 0) QT_CURSOR_UP QT_CURSOR_DOWN))
-            (loop (+ i 1))))
-        (update-mark-region! app ed)))))
+      (else
+       (let ((n (get-prefix-arg app)))
+         (collapse-selection-to-caret! ed)
+         (let loop ((i 0))
+           (when (< i (abs n))
+             (qt-plain-text-edit-move-cursor! ed (if (>= n 0) QT_CURSOR_UP QT_CURSOR_DOWN))
+             (loop (+ i 1))))
+         (update-mark-region! app ed))))))
 
 (def (cmd-beginning-of-line app)
   "Smart beginning of line: toggle between first non-whitespace and column 0."
diff --git a/src/jerboa-emacs/qt/commands-shell.ss b/src/jerboa-emacs/qt/commands-shell.ss
index fdf4050..0d862e7 100644
--- a/src/jerboa-emacs/qt/commands-shell.ss
+++ b/src/jerboa-emacs/qt/commands-shell.ss
@@ -62,8 +62,7 @@
 (def (apply-font-size-to-all-editors! app)
   "Apply the current global font size to all open editors."
   (let ((fr (app-state-frame app))
-        ;; Scale margin width proportionally to font size (50px at 11pt baseline)
-        (margin-w (max 30 (inexact->exact (round (* (/ *default-font-size* 11.0) 50))))))
+        (margin-w (max 30 (* *default-font-size* 3))))
     (for-each
       (lambda (win)
         (let ((ed (qt-edit-window-editor win)))
diff --git a/src/jerboa-emacs/qt/window.ss b/src/jerboa-emacs/qt/window.ss
index d8d0556..0be6b54 100644
--- a/src/jerboa-emacs/qt/window.ss
+++ b/src/jerboa-emacs/qt/window.ss
@@ -257,7 +257,7 @@
   ;; Line number margin (margin 0) — scale width with font size
   (sci-send ed SCI_SETMARGINTYPEN 0 SC_MARGIN_NUMBER)
   (sci-send ed SCI_SETMARGINWIDTHN 0
-    (max 30 (inexact->exact (round (* (/ *default-font-size* 11.0) 50)))))
+    (max 30 (* *default-font-size* 3)))
   ;; Disable other margins (symbol, fold) to avoid white gutters
   (sci-send ed SCI_SETMARGINWIDTHN 1 0)  ; symbol margin
   (sci-send ed SCI_SETMARGINWIDTHN 2 0)  ; fold margin
diff --git a/src/jerboa-emacs/terminal.ss b/src/jerboa-emacs/terminal.ss
index f31e8a3..756917b 100644
--- a/src/jerboa-emacs/terminal.ss
+++ b/src/jerboa-emacs/terminal.ss
@@ -29,7 +29,11 @@
         color-to-style
         (struct-out text-segment)
         ;; Terminal style base
-        *term-style-base*)
+        *term-style-base*
+        ;; Terminal history navigation
+        terminal-history-prev
+        terminal-history-next
+        terminal-history-reset!)
 
 (import :std/sugar
         :std/srfi/13
@@ -43,7 +47,8 @@
         (only-in :jsh/prompt expand-prompt)
         :jerboa-emacs/core
         :jerboa-emacs/pty
-        :jerboa-emacs/vtscreen)
+        :jerboa-emacs/vtscreen
+        :jerboa-emacs/shell-history)
 
 ;;;============================================================================
 ;;; Scintilla styling message IDs (not in constants.ss)
@@ -598,6 +603,23 @@
       ;; export VAR (no value) — just mark as exported (already set)
       (values 'sync "" cwd))))
 
+(def (resolve-path-dots path)
+  "Resolve . and .. components in an absolute path.
+   Returns a clean absolute path without . or .. segments."
+  (let loop ((parts (string-split path #\/)) (acc '()))
+    (if (null? parts)
+      (if (null? acc)
+        "/"
+        (apply string-append (map (lambda (p) (string-append "/" p)) (reverse acc))))
+      (let ((p (car parts)))
+        (cond
+          ((or (string=? p "") (string=? p "."))
+           (loop (cdr parts) acc))
+          ((string=? p "..")
+           (loop (cdr parts) (if (pair? acc) (cdr acc) acc)))
+          (else
+           (loop (cdr parts) (cons p acc))))))))
+
 (def (terminal-handle-cd! trimmed ts)
   "Handle cd command in-process (no subprocess) to update jsh env PWD.
    Returns (values 'sync output cwd)."
@@ -620,9 +642,9 @@
                    ((string-prefix? "/" args) args)
                    ;; Relative path
                    (else (string-append current-pwd "/" args)))))
-    ;; Normalize path (resolve .. and .)
+    ;; Normalize path (resolve .. and . components)
     (let ((resolved (with-catch (lambda (e) #f)
-                      (lambda () (path-normalize target)))))
+                      (lambda () (resolve-path-dots target)))))
       (if (and resolved (file-exists? resolved) (file-directory? resolved))
         (begin
           (env-set! env "OLDPWD" current-pwd)
@@ -744,3 +766,48 @@
     (set! (terminal-state-pty-thread ts) #f)
     (set! (terminal-state-vtscreen ts) #f)
     (set! (terminal-state-pre-pty-text ts) #f)))
+
+;;;============================================================================
+;;; Terminal history navigation (up/down arrow)
+;;;============================================================================
+
+;; Per-buffer history navigation index (-1 = not navigating)
+(def *terminal-history-index* (make-hash-table-eq))
+;; Per-buffer saved input (what user typed before starting history navigation)
+(def *terminal-saved-input* (make-hash-table-eq))
+
+(def (terminal-history-prev buf current-input)
+  "Navigate to the previous (older) history entry for terminal buffer.
+   Returns the history command string, or #f if no more history."
+  (let* ((idx (or (hash-get *terminal-history-index* buf) -1))
+         (history *gsh-history*)
+         (hlen (length history))
+         (new-idx (+ idx 1)))
+    (if (>= new-idx hlen)
+      #f
+      (begin
+        (when (= idx -1)
+          (hash-put! *terminal-saved-input* buf current-input))
+        (hash-put! *terminal-history-index* buf new-idx)
+        (caddr (list-ref history new-idx))))))
+
+(def (terminal-history-next buf)
+  "Navigate to the next (newer) history entry for terminal buffer.
+   Returns the history command string, the saved input, or #f."
+  (let* ((idx (or (hash-get *terminal-history-index* buf) -1)))
+    (cond
+      ((< idx 0) #f)
+      ((= idx 0)
+       (hash-put! *terminal-history-index* buf -1)
+       (let ((saved (or (hash-get *terminal-saved-input* buf) "")))
+         (hash-remove! *terminal-saved-input* buf)
+         saved))
+      (else
+       (let ((new-idx (- idx 1)))
+         (hash-put! *terminal-history-index* buf new-idx)
+         (caddr (list-ref *gsh-history* new-idx)))))))
+
+(def (terminal-history-reset! buf)
+  "Reset terminal history navigation state (called when input is submitted)."
+  (hash-remove! *terminal-history-index* buf)
+  (hash-remove! *terminal-saved-input* buf))