fix: add SIGWINCH resize handling for /dev/tty input path

Main loop now checks cl-tty.input::*terminal-resized-p* on every
iteration. When set (by SIGWINCH handler), re-queries backend-size
and marks all regions dirty for re-render.
This commit is contained in:
2026-05-14 08:56:00 -04:00
parent 226f979d38
commit 11cb466d4f
2 changed files with 52 additions and 91 deletions

View File

@@ -866,43 +866,49 @@
(cl-tty.backend:with-terminal (be w h)
(let ((tty (open "/dev/tty" :direction :input)))
;; Initial render
(cl-tty.backend:backend-clear be)
(view-status be w h)
(view-chat be w h)
(view-input be w h)
(cl-tty.backend:draw-text be 0 (- h 4) (make-string w :initial-element #\─)
(theme-color :separator) nil)
(loop while (st :running) do
(dolist (ev (drain-queue))
(cond
((eq (getf ev :type) :daemon)
(on-daemon-msg (getf ev :payload)))
((eq (getf ev :type) :disconnected)
(setf (st :connected) nil
(st :busy) nil)
(add-msg :system "* Connection lost — type /reconnect to retry *"))))
;; Read key input from /dev/tty (non-blocking)
(let ((raw-ch (read-char-no-hang tty nil nil)))
(when raw-ch
(let ((code (char-code raw-ch)))
(let ((ch (cond
((= code 13) :enter)
((= code 10) :enter)
((= code 27) :escape)
((= code 9) :tab)
((or (= code 127) (= code 8)) :backspace)
((and (>= code 1) (<= code 26))
(intern (string-upcase (format nil "CTRL-~a"
(code-char (+ #x60 code))))
:keyword))
(t raw-ch))))
(case ch
(:CTRL-Q (setf (st :running) nil))
(:CTRL-P (command-palette-show-commands))
(:CTRL-B (setf (st :sidebar-visible) (not (st :sidebar-visible)))
(setf (st :dirty) (list t t nil)))
(:CTRL-L (setf (st :dirty) (list t t t)))
(t (on-key ch)))))))
(cl-tty.backend:backend-clear be)
(view-status be w h)
(view-chat be w h)
(view-input be w h)
(cl-tty.backend:draw-text be 0 (- h 4) (make-string w :initial-element #\─)
(theme-color :separator) nil)
(loop while (st :running) do
(dolist (ev (drain-queue))
(cond
((eq (getf ev :type) :daemon)
(on-daemon-msg (getf ev :payload)))
((eq (getf ev :type) :disconnected)
(setf (st :connected) nil
(st :busy) nil)
(add-msg :system "* Connection lost — type /reconnect to retry *"))))
;; Check for terminal resize (SIGWINCH sets this flag)
(when (boundp 'cl-tty.input::*terminal-resized-p*)
(when cl-tty.input::*terminal-resized-p*
(setf cl-tty.input::*terminal-resized-p* nil)
(multiple-value-setq (w h) (cl-tty.backend:backend-size be))
(setf (st :dirty) (list t t t))))
;; Read key input from /dev/tty (non-blocking)
(let ((raw-ch (read-char-no-hang tty nil nil)))
(when raw-ch
(let ((code (char-code raw-ch)))
(let ((ch (cond
((= code 13) :enter)
((= code 10) :enter)
((= code 27) :escape)
((= code 9) :tab)
((or (= code 127) (= code 8)) :backspace)
((and (>= code 1) (<= code 26))
(intern (string-upcase (format nil "CTRL-~a"
(code-char (+ #x60 code))))
:keyword))
(t raw-ch))))
(case ch
(:CTRL-Q (setf (st :running) nil))
(:CTRL-P (command-palette-show-commands))
(:CTRL-B (setf (st :sidebar-visible) (not (st :sidebar-visible)))
(setf (st :dirty) (list t t nil)))
(:CTRL-L (setf (st :dirty) (list t t t)))
(t (on-key ch)))))))
(when (or (first (st :dirty)) (second (st :dirty)) (third (st :dirty)))
(cl-tty.backend:backend-clear be)
(view-status be w h)
@@ -944,29 +950,23 @@
(sel-p (theme-color :accent))
(t (theme-color :agent-fg)))
nil :bold sel-p)
(incf y-off)))))))
(sleep 0.1)))
(incf y-off)))))))
(sleep 0.1))))
(close tty))
(disconnect-daemon))))
(disconnect-daemon)))
#+passepartout-tests
(eval-when (:compile-toplevel :load-toplevel :execute)
(ql:quickload :fiveam :silent t))
#+passepartout-tests
(defpackage :passepartout-tui-tests
(:use :cl :passepartout :passepartout.channel-tui)
(:export #:tui-suite))
#+passepartout-tests
(in-package :passepartout-tui-tests)
#+passepartout-tests
(fiveam:def-suite tui-suite :description "Verification of the TUI model and event handling")
#+passepartout-tests
(fiveam:in-suite tui-suite)
#+passepartout-tests
(fiveam:test test-init-state
"Contract model.1: init-state returns fresh state plist with required keys."
(init-state)
@@ -978,7 +978,6 @@
(fiveam:is (eq 0 (st :scroll-offset)))
(fiveam:is (eq nil (st :busy))))
#+passepartout-tests
(fiveam:test test-add-msg
"Contract model.2: add-msg appends a message with role, content, and time."
(init-state)
@@ -990,7 +989,6 @@
(fiveam:is (stringp (getf msg :time)))
(fiveam:is (= 5 (length (getf msg :time))))))
#+passepartout-tests
(fiveam:test test-add-msg-dirty-flag
"Contract model.2: add-msg sets dirty flags for status and chat."
(init-state)
@@ -1001,7 +999,6 @@
(fiveam:is (eq t (second dirty)))
(fiveam:is (eq nil (third dirty)))))
#+passepartout-tests
(fiveam:test test-queue-event-roundtrip
"Contract model.3: queue-event + drain-queue preserves events in order."
(init-state)
@@ -1013,7 +1010,6 @@
(fiveam:is (equal '(:type :daemon :payload (:text "hi")) (second evs)))
(fiveam:is (null (drain-queue)))))
#+passepartout-tests
(fiveam:test test-on-key-enter-sends-user-message
"Contract 1: on-key with Enter extracts input, adds user message, clears buffer."
(init-state)
@@ -1032,7 +1028,6 @@
(fiveam:is (eq :user (getf last :role)))
(fiveam:is (string= "test" (getf last :content))))))
#+passepartout-tests
(fiveam:test test-on-key-eval-command
"Contract 1: on-key handles /eval command and displays result."
(init-state)
@@ -1046,7 +1041,6 @@
(fiveam:is (eq :system (getf last-msg :role)))
(fiveam:is (search "=> 3" (getf last-msg :content))))))
#+passepartout-tests
(fiveam:test test-on-key-backspace
"Contract 1: on-key with Backspace removes last character from buffer."
(init-state)
@@ -1057,7 +1051,6 @@
(on-key 263)
(fiveam:is (string= "ab" (input-string))))
#+passepartout-tests
(fiveam:test test-on-key-focus-command
"Contract 1: /focus command parses project name."
(init-state)
@@ -1067,7 +1060,6 @@
(let ((msg (aref (st :messages) 0)))
(fiveam:is (eq :system (getf msg :role)))))
#+passepartout-tests
(fiveam:test test-on-key-scope-command
"Contract 1: /scope command with valid argument."
(init-state)
@@ -1077,7 +1069,6 @@
(let ((msg (aref (st :messages) 0)))
(fiveam:is (eq :system (getf msg :role)))))
#+passepartout-tests
(fiveam:test test-on-key-unfocus-command
"Contract 1: /unfocus command dispatches correctly."
(init-state)
@@ -1087,7 +1078,6 @@
(let ((msg (aref (st :messages) 0)))
(fiveam:is (eq :system (getf msg :role)))))
#+passepartout-tests
(fiveam:test test-on-key-tab-completion
"Contract 1: Tab completes / commands when input starts with /."
(init-state)
@@ -1096,7 +1086,6 @@
(on-key 9)
(fiveam:is (string= "/eval " (input-string))))
#+passepartout-tests
(fiveam:test test-on-key-tab-no-slash
"Contract 1: Tab does nothing when input doesn't start with /."
(init-state)
@@ -1105,7 +1094,6 @@
(on-key 9)
(fiveam:is (string= "hello" (input-string))))
#+passepartout-tests
(fiveam:test test-on-key-multiline
"Contract 1: \\ + Enter inserts newline instead of sending."
(init-state)
@@ -1116,7 +1104,6 @@
(fiveam:is (search "line1" (input-string)))
(fiveam:is (search (string #\Newline) (input-string))))
#+passepartout-tests
(fiveam:test test-on-key-help
"Contract 1: /help displays command list."
(init-state)
@@ -1127,7 +1114,6 @@
(fiveam:is (>= (length msgs) 3))
(fiveam:is (some (lambda (m) (search "/eval" (getf m :content))) msgs))))
#+passepartout-tests
(fiveam:test test-activity-indicator
"Contract model: :busy flag is set on send and cleared on agent response."
(init-state)
@@ -1141,7 +1127,6 @@
(on-daemon-msg '(:type :event :payload (:text "hi back")))
(fiveam:is (eq nil (st :busy))))
#+passepartout-tests
(fiveam:test test-theme
"Contract view: *tui-theme* provides warm color mappings."
(fiveam:is (string= "#FFB347" (getf *tui-theme* :user-fg)))
@@ -1150,7 +1135,6 @@
(fiveam:is (string= "#E8D5B7" (getf *tui-theme* :input-fg)))
(fiveam:is (string= "#FFFFFF" (theme-color :unknown-role))))
#+passepartout-tests
(fiveam:test test-on-key-ctrl-u-clears
"Contract v0.9.0: Ctrl+U (via dispatch-key-event) clears the input buffer."
(init-state)
@@ -1159,7 +1143,6 @@
(cl-tty.input:make-key-event :key :u :ctrl t :code 21))
(fiveam:is (string= "" (input-string))))
#+passepartout-tests
(fiveam:test test-on-key-ctrl-l-redraws
"Contract v0.9.0: Ctrl+L (via dispatch-key-event) sets all dirty flags."
(init-state)
@@ -1170,7 +1153,6 @@
(fiveam:is (eq t (first d)))
(fiveam:is (eq t (second d)))))
#+passepartout-tests
(fiveam:test test-scroll-notify
"Contract/v0.7.0: add-msg sets scroll-notify when scrolled up."
(init-state)
@@ -1181,7 +1163,6 @@
(add-msg :agent "hi2")
(fiveam:is (eq nil (st :scroll-notify))))
#+passepartout-tests
(fiveam:test test-tab-subcommand
"Contract/v0.7.0: Tab completes subcommand for /theme."
(init-state)
@@ -1191,7 +1172,6 @@
;; ── v0.7.1 Streaming ──
#+passepartout-tests
(fiveam:test test-stream-chunk-appends
"Contract/v0.7.1: stream-chunk frame appends to last message."
(init-state)
@@ -1204,7 +1184,6 @@
(fiveam:is (string= "Hello world" (getf msg :content)))
(fiveam:is (eq t (getf msg :streaming))))))
#+passepartout-tests
(fiveam:test test-stream-chunk-final
"Contract/v0.7.1: final empty chunk stamps timestamp and clears streaming flag."
(init-state)
@@ -1215,7 +1194,6 @@
(fiveam:is (string= "Hi" (getf msg :content)))
(fiveam:is (null (st :streaming-text)))))
#+passepartout-tests
(fiveam:test test-stream-interrupt
"Contract/v0.7.1: Esc during streaming appends [interrupted] and finalizes."
(init-state)
@@ -1227,7 +1205,6 @@
(fiveam:is (null (st :streaming-text)))
(fiveam:is (null (st :busy)))))
#+passepartout-tests
(fiveam:test test-stream-check-skip
"Contract/v0.7.1: Esc without active streaming does nothing."
(init-state)
@@ -1235,7 +1212,6 @@
(fiveam:is (null (st :streaming-text)))
(fiveam:is (= 0 (length (st :messages)))))
#+passepartout-tests
(fiveam:test test-tab-open-url
"Contract/v0.7.1: Tab on empty input with URL message extracts URL."
(init-state)
@@ -1245,7 +1221,6 @@
;; ── v0.7.2 HITL Panels ──
#+passepartout-tests
(fiveam:test test-hitl-panel-in-on-daemon-msg
"Contract v0.7.2: approval-required messages render as HITL panels."
(init-state)
@@ -1258,7 +1233,6 @@
(fiveam:is (getf m :panel))
(fiveam:is (search "rm -rf" (getf m :content)))))
#+passepartout-tests
(fiveam:test test-hitl-panel-after-approve
"Contract v0.7.2: /approve adds confirmation and marks panel resolved."
(init-state)
@@ -1275,7 +1249,6 @@
(let ((m (aref (st :messages) (1- (length (st :messages))))))
(fiveam:is (search "Approved" (getf m :content)))))
#+passepartout-tests
(fiveam:test test-hitl-panel-after-deny
"Contract v0.7.2: /deny marks panel as denied."
(init-state)
@@ -1288,7 +1261,6 @@
(fiveam:is (getf m :panel))
(fiveam:is (eq :denied (getf m :panel-resolved)))))
#+passepartout-tests
(fiveam:test test-hitl-approve-parsed
"Contract v0.7.2: /approve HITL-xxxx sends structured event, not raw text."
(init-state)
@@ -1302,7 +1274,6 @@
(fiveam:is (eq :system (getf m :role)))
(fiveam:is (search "Approved" (getf m :content))))))
#+passepartout-tests
(fiveam:test test-hitl-deny-parsed
"Contract v0.7.2: /deny HITL-xxxx sends structured denial."
(init-state)
@@ -1315,7 +1286,6 @@
;; ── v0.7.2 Undo/Redo ──
#+passepartout-tests
(fiveam:test test-undo-command
"Contract v0.7.2: /undo sends undo event."
(init-state)
@@ -1326,7 +1296,6 @@
(fiveam:is (eq :system (getf m :role)))
(fiveam:is (search "Undo" (getf m :content)))))
#+passepartout-tests
(fiveam:test test-redo-command
"Contract v0.7.2: /redo sends redo event."
(init-state)
@@ -1339,7 +1308,6 @@
;; ── v0.7.2 Self-help ──
#+passepartout-tests
(fiveam:test test-why-command
"Contract v0.7.2: /why shows gate trace from last message."
(init-state)
@@ -1353,7 +1321,6 @@
(fiveam:is (search "[BLOCKED]" (getf m :content)))
(fiveam:is (search "shell" (getf m :content)))))
#+passepartout-tests
(fiveam:test test-why-no-trace
"Contract v0.7.2: /why with no gate trace shows fallback message."
(init-state)
@@ -1366,7 +1333,6 @@
;; ── v0.7.2 Gate Trace Toggle (Ctrl+G) ──
#+passepartout-tests
(fiveam:test test-ctrlg-toggle-gate-trace
"Contract v0.9.0: Ctrl+G (via dispatch-key-event) toggles gate-trace collapse state."
(init-state)
@@ -1382,7 +1348,6 @@
(m (aref msgs (1- (length msgs)))))
(fiveam:is (search "shown" (getf m :content)))))
#+passepartout-tests
(fiveam:test test-ctrlg-no-gate-trace
"Contract v0.9.0: Ctrl+G (via dispatch-key-event) with no gate trace shows fallback."
(init-state)
@@ -1393,7 +1358,6 @@
;; ── v0.7.2 Message Search Mode ──
#+passepartout-tests
(fiveam:test test-search-mode-activate
"Contract v0.7.2: /search enters search mode."
(init-state)
@@ -1406,7 +1370,6 @@
(fiveam:is (string= "hello" (st :search-query)))
(fiveam:is (= 1 (length (st :search-matches)))))
#+passepartout-tests
(fiveam:test test-search-mode-escape-exits
"Contract v0.7.2: Escape exits search mode."
(init-state)
@@ -1418,7 +1381,6 @@
(on-key 27) ;; Escape
(fiveam:is (null (st :search-mode))))
#+passepartout-tests
(fiveam:test test-search-mode-up-down-nav
"Contract v0.7.2: Up/Down navigates between search matches."
(init-state)
@@ -1436,7 +1398,6 @@
(on-key 259) ;; Up (clamped)
(fiveam:is (= 0 (st :search-match-idx))))
#+passepartout-tests
(fiveam:test test-context-sections
"Contract v0.7.2: /context shows section breakdown with IDENTITY, TOOLS, LOGS."
(init-state)
@@ -1449,7 +1410,6 @@
(fiveam:is (some (lambda (m) (search "LOGS" (getf m :content))) msgs))
(fiveam:is (some (lambda (m) (search "TOOLS" (getf m :content))) msgs))))
#+passepartout-tests
(fiveam:test test-help-topic-lookup
"Contract v0.7.2: /help <topic> reads and searches USER_MANUAL.org."
(init-state)
@@ -1459,7 +1419,6 @@
(let ((msgs (st :messages)))
(fiveam:is (some (lambda (m) (search ".env" (getf m :content))) msgs))))
#+passepartout-tests
(fiveam:test test-pads-page-up
"Contract v0.7.2: PageUp scrolls by page size (> 5 lines)."
(init-state)
@@ -1468,7 +1427,6 @@
(on-key :ppage)
(fiveam:is (> (st :scroll-offset) 5) "Should scroll by more than 5 lines"))
#+passepartout-tests
(fiveam:test test-pads-page-down-clamp
"Contract v0.7.2: PageDown clamps to 0."
(init-state)
@@ -1479,7 +1437,6 @@
;; ── v0.8.0 Minibuffer ──
#+passepartout-tests
(fiveam:test test-slash-commands-defined
"Contract v0.8.0: *slash-commands* is non-nil list of option plists."
(fiveam:is (listp passepartout.channel-tui::*slash-commands*))
@@ -1488,14 +1445,12 @@
(and (getf opt :title) (getf opt :value) (getf opt :category)))
passepartout.channel-tui::*slash-commands*)))
#+passepartout-tests
(fiveam:test test-minibuffer-state
"Contract v0.8.0: init-state has :dialog-stack and :minibuffer-active fields."
(init-state)
(fiveam:is (null (st :dialog-stack)))
(fiveam:is (null (st :minibuffer-active))))
#+passepartout-tests
(fiveam:test test-command-palette-state
"Contract v0.8.0: init-state has :command-palette-active and :command-palette-dialog as nil."
(init-state)

View File

@@ -925,6 +925,12 @@ Event handlers + daemon I/O + main loop.
(setf (st :connected) nil
(st :busy) nil)
(add-msg :system "* Connection lost — type /reconnect to retry *"))))
;; Check for terminal resize (SIGWINCH sets this flag)
(when (boundp 'cl-tty.input::*terminal-resized-p*)
(when cl-tty.input::*terminal-resized-p*
(setf cl-tty.input::*terminal-resized-p* nil)
(multiple-value-setq (w h) (cl-tty.backend:backend-size be))
(setf (st :dirty) (list t t t))))
;; Read key input from /dev/tty (non-blocking)
(let ((raw-ch (read-char-no-hang tty nil nil)))
(when raw-ch