Fix terminal bugs: cd path resolution, history navigation, chord interference
ober
140aa197e9756ad71d5e8b0a8fbbc59a04cc74e0
--- 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 --- 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) --- 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)] --- 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)))) --- 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! --- 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 --- 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 --- 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) --- 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) --- 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) --- 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") --- 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." --- 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))) --- 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 --- 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))