diff --git a/src/application/runtime.lisp b/src/application/runtime.lisp index 4a045a1c..6a47c89e 100644 --- a/src/application/runtime.lisp +++ b/src/application/runtime.lisp @@ -1498,6 +1498,31 @@ command replaced the active conversation." :rollback-cause (nreverse rollback-failures))) directory))) +(-> application--conversation-input-history + (conversation &key (:limit (integer 0))) + list) +(defun application--conversation-input-history + (conversation &key (limit *terminal-history-limit*)) + "Return bounded editable input history recovered from durable CONVERSATION." + (let ((entries (make-deque :maximum-count limit))) + (conversation-map-records + conversation + (lambda (record) + (let ((properties (rest record))) + (case (first record) + (:message + (let ((content (getf properties :content))) + (when (and (eq (getf properties :role) ':user) + (non-empty-string-p content)) + (deque-push-back entries (copy-seq content))))) + (:user-operation + (let ((source (getf properties :source))) + (when (and (member (getf properties :kind) '(:command :lisp)) + (non-empty-string-p source)) + (deque-push-back entries (copy-seq source))))))))) + (deque->list entries))) + + (-> application--install-owned-conversation (application conversation &key (:conversation-lease conversation-lease)) application) @@ -1508,6 +1533,14 @@ command replaced the active conversation." (application--configuration-for-conversation (application-configuration application) conversation)) + (ui (application-ui application)) + (history-limit + (if ui + (line-editor-history-limit (terminal-ui-editor ui)) + *terminal-history-limit*)) + (input-history + (application--conversation-input-history + conversation :limit history-limit)) (previous-configuration (application-configuration application)) (previous-conversation (application-conversation application)) (previous-conversation-lease @@ -1572,6 +1605,8 @@ command replaced the active conversation." (application--load-goal application) (context-runtime-reset) (application-publish-recovery-session application) + (when ui + (terminal-ui-load-history ui input-history)) (setf committed-p t)) (serious-condition (condition) (setf failure condition))) diff --git a/src/configuration/settings.lisp b/src/configuration/settings.lisp index e4e6bd3b..6ef70faf 100644 --- a/src/configuration/settings.lisp +++ b/src/configuration/settings.lisp @@ -777,13 +777,25 @@ reasoning effort only when that effort is supported by the selected model." (-> make-identifier () string) (defun make-identifier () "Return a process-independent identifier suitable for conversations and requests." - (handler-case - (with-open-file (stream #P"/proc/sys/kernel/random/uuid" - :direction ':input - :external-format ':utf-8) - (string-trim '(#\Space #\Tab #\Newline #\Return) - (read-line stream))) - (error () - (format nil "~36R-~16,'0X" - (get-universal-time) - (random (ash 1 64)))))) + (flet ((fallback () + (format nil "~36R-~16,'0X" + (get-universal-time) + (random (ash 1 64))))) + (handler-case + (cond + #+linux + (t + (with-open-file (stream #P"/proc/sys/kernel/random/uuid" + :direction ':input + :external-format ':utf-8) + (string-trim '(#\Space #\Tab #\Newline #\Return) + (read-line stream)))) + #+(and (not linux) (or darwin macos macosx bsd)) + (t + (string-trim + '(#\Space #\Tab #\Newline #\Return) + (uiop:run-program '("/usr/bin/uuidgen") :output :string))) + (t + (fallback))) + (error () + (fallback))))) diff --git a/src/core/package.lisp b/src/core/package.lisp index d77b0530..6321efc6 100644 --- a/src/core/package.lisp +++ b/src/core/package.lisp @@ -100,6 +100,7 @@ #:line-editor-text #:line-editor-cursor #:line-editor-history + #:line-editor-history-limit #:line-editor-set-text #:line-editor-clear #:line-editor-handle-event diff --git a/src/terminal/protocol.lisp b/src/terminal/protocol.lisp index f236b2de..b092d574 100644 --- a/src/terminal/protocol.lisp +++ b/src/terminal/protocol.lisp @@ -169,6 +169,17 @@ :accessor terminal-ui-completion-prefix :type (option string) :documentation "Input restored when active completion selection is cancelled.") + (completion-history-state + :initform nil + :accessor terminal-ui-completion-history-state + :type (option list) + :documentation + "Clinedi history traversal state restored when completion is cancelled.") + (completion-dismissed-p + :initform nil + :accessor terminal-ui-completion-dismissed-p + :type boolean + :documentation "Whether Escape has hidden passive completion suggestions.") (selector :initform nil :accessor terminal-ui-selector diff --git a/src/terminal/responsive-input.lisp b/src/terminal/responsive-input.lisp index 59d76375..a4c0c119 100644 --- a/src/terminal/responsive-input.lisp +++ b/src/terminal/responsive-input.lisp @@ -1645,6 +1645,17 @@ new input resumes them." (application-input-controller-forced-exit-message controller) message))))) (when accepted-p + (let ((application + (application-input-controller-application controller))) + (when (slot-boundp application 'ui) + (handler-case + (terminal-ui-set-notice + (application-ui application) + "Interrupting request." + :duration-seconds + *application-interrupted-queue-notice-seconds*) + (error () + nil)))) (handler-case (application-input-controller--interrupt-main-for-turn-cancellation controller) @@ -2346,6 +2357,11 @@ may execute immediately; other Lisp waits for the idle boundary." :force-exit-window-p t :pause-queued-work-p t)) nil) + ((and (eq event ':escape) + (terminal-ui-completion-menu-present-p ui)) + (terminal-ui-process-event + ui event :queue-editing-p follow-up-editing-p) + nil) ((and (eq event ':escape) (or (application-input-controller--turn-cancellation-active-p diff --git a/src/terminal/ui.lisp b/src/terminal/ui.lisp index 471953ed..68eb8104 100644 --- a/src/terminal/ui.lisp +++ b/src/terminal/ui.lisp @@ -149,6 +149,7 @@ :visible-count *terminal-ui-visible-completions* :arrangement ':vertical)))) + (defmacro with-terminal-ui-locked ((ui) &body body) "Run BODY while holding UI's recursive presentation lock." (let ((locked-ui (gensym "UI"))) @@ -156,6 +157,33 @@ (with-recursive-lock-held ((terminal-ui-lock ,locked-ui)) ,@body)))) + +(-> terminal-ui-load-history (terminal-ui list) terminal-ui) +(defun terminal-ui-load-history (ui entries) + "Replace UI's editable history while preserving its current draft and cursor." + (with-terminal-ui-locked (ui) + (let* ((editor (terminal-ui-editor ui)) + (navigating-p + (not + (null (slot-value editor 'clinedi::history-index)))) + (text + (copy-seq + (if navigating-p + (slot-value editor 'clinedi::history-stash) + (line-editor-text editor)))) + (cursor + (if navigating-p + (slot-value editor 'clinedi::history-stash-cursor) + (line-editor-cursor editor))) + (history-editor + (line-editor-create + :history (mapcar #'sanitize-text entries) + :history-limit (line-editor-history-limit editor)))) + (line-editor-set-text editor text :cursor cursor) + (setf (slot-value editor 'clinedi::history) + (slot-value history-editor 'clinedi::history)))) + ui) + (-> terminal-ui--call-with-lock-if-available (terminal-ui function) (values t boolean)) @@ -673,11 +701,62 @@ columns: name, tally, and description." row-width))) (setf previous-group group))))))) +(-> terminal-ui--editor-history-navigating-p (line-editor) boolean) +(defun terminal-ui--editor-history-navigating-p (editor) + "Return true when EDITOR is currently traversing history." + (and (slot-boundp editor 'clinedi::history-index) + (not (null (slot-value editor 'clinedi::history-index))) + t)) + + +(-> terminal-ui--snapshot-history-state (line-editor) (option list)) +(defun terminal-ui--snapshot-history-state (editor) + "Return EDITOR's history traversal state, or NIL outside history recall." + (when (terminal-ui--editor-history-navigating-p editor) + (list (copy-seq (line-editor-text editor)) + (line-editor-cursor editor) + (slot-value editor 'clinedi::history-index) + (copy-seq (slot-value editor 'clinedi::history-stash)) + (slot-value editor 'clinedi::history-stash-cursor)))) + + +(-> terminal-ui--restore-history-state (line-editor list) null) +(defun terminal-ui--restore-history-state (editor state) + "Restore EDITOR's history traversal STATE after completion is cancelled." + (destructuring-bind (text cursor index stash stash-cursor) state + (line-editor-set-text editor text :cursor cursor) + (setf (slot-value editor 'clinedi::history-index) index + (slot-value editor 'clinedi::history-stash) stash + (slot-value editor 'clinedi::history-stash-cursor) stash-cursor)) + nil) + +(-> terminal-ui--completion-offered-p (terminal-ui) boolean) +(defun terminal-ui--completion-offered-p (ui) + "Return true when UI may paint or begin command completion." + (or (terminal-ui-completion-active-p ui) + (and (not (terminal-ui-completion-dismissed-p ui)) + (not (terminal-ui--editor-history-navigating-p + (terminal-ui-editor ui)))))) + + +(-> terminal-ui-completion-menu-present-p (terminal-ui) boolean) +(defun terminal-ui-completion-menu-present-p (ui) + "Return true when UI currently renders command completion candidates." + (and (terminal-ui--completion-offered-p ui) + (not + (null + (selector-items (terminal-ui-completion-selector ui)))))) + + (-> terminal-ui--completion-rows (terminal-ui integer) list) (defun terminal-ui--completion-rows (ui row-width) "Return styled rows for UI's matching command completions." - (terminal-ui--reconcile-completions ui) - (terminal-ui--choice-rows (terminal-ui-completion-selector ui) row-width)) + (block nil + (unless (terminal-ui--completion-offered-p ui) + (selector-set-items (terminal-ui-completion-selector ui) nil) + (return nil)) + (terminal-ui--reconcile-completions ui) + (terminal-ui--choice-rows (terminal-ui-completion-selector ui) row-width))) (-> terminal-ui--accept-completion (terminal-ui list) null) (defun terminal-ui--accept-completion (ui entry) @@ -696,26 +775,37 @@ columns: name, tally, and description." (defun terminal-ui--begin-completion (ui) "Begin choosing among UI's current command completion candidates." (unless (terminal-ui-completion-active-p ui) - (setf (terminal-ui-completion-prefix ui) - (line-editor-text (terminal-ui-editor ui)) - (terminal-ui-completion-active-p ui) t)) + (let ((editor (terminal-ui-editor ui))) + (setf (terminal-ui-completion-prefix ui) + (line-editor-text editor) + (terminal-ui-completion-history-state ui) + (terminal-ui--snapshot-history-state editor) + (terminal-ui-completion-dismissed-p ui) nil + (terminal-ui-completion-active-p ui) t))) nil) (-> terminal-ui--end-completion (terminal-ui) null) (defun terminal-ui--end-completion (ui) "Leave UI's active command completion selection without changing input." (setf (terminal-ui-completion-active-p ui) nil - (terminal-ui-completion-prefix ui) nil) + (terminal-ui-completion-prefix ui) nil + (terminal-ui-completion-history-state ui) nil) nil) (-> terminal-ui--cancel-completion (terminal-ui) null) (defun terminal-ui--cancel-completion (ui) - "Cancel UI's completion selection and restore its original command prefix." - (let ((prefix (terminal-ui-completion-prefix ui))) - (when prefix - (line-editor-set-text (terminal-ui-editor ui) prefix))) + "Cancel completion and restore its original input or history traversal state." + (let ((prefix (terminal-ui-completion-prefix ui)) + (history-state (terminal-ui-completion-history-state ui)) + (editor (terminal-ui-editor ui))) + (cond + (history-state + (terminal-ui--restore-history-state editor history-state)) + (prefix + (line-editor-set-text editor prefix)))) (selector-set-items (terminal-ui-completion-selector ui) nil) (terminal-ui--end-completion ui) + (setf (terminal-ui-completion-dismissed-p ui) t) nil) (-> terminal-ui--handle-completion-event @@ -723,18 +813,20 @@ columns: name, tally, and description." (values (option keyword) (option string))) (defun terminal-ui--handle-completion-event (ui event) "Apply EVENT to UI's completion suggestions and return its action when consumed." - (terminal-ui--reconcile-completions ui) - (let ((selector (terminal-ui-completion-selector ui))) - (block nil + (block nil + (unless (or (terminal-ui--completion-offered-p ui) + (member event '(:complete :complete-previous))) + (return (values nil nil))) + (terminal-ui--reconcile-completions ui) + (let ((selector (terminal-ui-completion-selector ui))) (unless (selector-items selector) (return (values nil nil))) - (unless (or (terminal-ui-completion-active-p ui) - (member event - '(:up :down :history-previous :history-next - :complete :complete-previous :submit))) - (return (values nil nil))) - (when (member event '(:up :down :history-previous :history-next - :complete :complete-previous)) + (unless (or (terminal-ui-completion-active-p ui) + (member event + '(:up :down :complete :complete-previous + :submit :escape))) + (return (values nil nil))) + (when (member event '(:up :down :complete :complete-previous)) (terminal-ui--begin-completion ui)) (multiple-value-bind (selector-action entry) (selector-handle-event selector event) @@ -2310,6 +2402,7 @@ frames, so this function never paints directly from a child thread." "Replace UI's editable input with INPUT and repaint it." (with-terminal-ui-locked (ui) (terminal-ui--set-draft-input ui input) + (setf (terminal-ui-completion-dismissed-p ui) nil) (terminal-ui--paint-live ui)) ui) @@ -2462,6 +2555,9 @@ frames, so this function never paints directly from a child thread." (terminal-ui--apply-editor-event ui effective-event) (when (eq action :changed) (terminal-ui--restore-history-images ui)) + (when (and (eq action :changed) + (not (string= text-before (line-editor-text editor)))) + (setf (terminal-ui-completion-dismissed-p ui) nil)) (when (and (member action '(:submit :queue)) (stringp payload)) (terminal-ui--remember-image-submission diff --git a/tests/application-tests.lisp b/tests/application-tests.lisp index 88cc74b4..3f25da34 100644 --- a/tests/application-tests.lisp +++ b/tests/application-tests.lisp @@ -1483,22 +1483,27 @@ (application-input-controller-interrupt-hint-time controller))) (eq event ':interrupt)) "only active Ctrl-C schedules the transient force-exit notice") - (test-assert (null (terminal-ui-notice ui)) - "no stop key shows the force-exit notice immediately") + (test-assert + (string= (terminal-ui-notice ui) "Interrupting request.") + "an accepted stop immediately announces request interruption") (application-input-controller--refresh-interrupt-hint controller) - (test-assert (null (terminal-ui-notice ui)) - "a cancellation inside the hint delay stays silent") + (test-assert + (string= (terminal-ui-notice ui) "Interrupting request.") + "a cancellation inside the hint delay keeps its interruption notice") (setf now 21/2) (application-input-controller--refresh-interrupt-hint controller) (test-assert - (eq (not (null (terminal-ui-notice ui))) - (eq event ':interrupt)) - "only active Ctrl-C announces forced exit once cancellation persists") + (string= + (terminal-ui-notice ui) + (if (eq event ':interrupt) + "Press Ctrl-C again within 2.5 seconds to force exit." + "Interrupting request.")) + "a persistent Ctrl-C replaces the interruption notice with its force hint") (test-assert (or (eq event ':escape) (= (terminal-ui-notice-deadline ui) (application-input-controller-interrupt-deadline controller))) - "the visible notice expires exactly with the force-exit window") + "the visible Ctrl-C force hint expires exactly with the force-exit window") (test-assert (application-input-controller--consume-turn-cancellation-delivery-p controller) @@ -1536,6 +1541,88 @@ "new explicit input resumes held queued work and clears its notice")))) nil) +(-> test-active-turn-cancellation-side-effects () null) +(defun test-active-turn-cancellation-side-effects () + "Test cancellation delivery survives notice failure and follows menu dismissal." + (let* ((terminal (make-instance 'recording-terminal :columns 80)) + (ui (terminal-ui-create :terminal terminal)) + (application (make-instance 'application :ui ui)) + (controller + (make-instance + 'application-input-controller + :application application + :later-state (make-instance 'later-state) + :main-thread (current-thread))) + (interrupt-count 0)) + (setf (application-input-controller application) controller + (application-input-controller-active-p controller) t) + (test-call-with-function-replacements + (list + (list 'terminal-ui-set-notice + (lambda (&rest arguments) + (declare (ignore arguments)) + (error "notice rendering failed"))) + (list 'application-input-controller--interrupt-main-for-turn-cancellation + (lambda (observed-controller) + (test-assert (eq observed-controller controller) + "cancellation interrupts the requesting controller") + (incf interrupt-count)))) + (lambda () + (test-assert + (application-input-controller--request-active-turn-cancellation controller) + "notice failure does not reject an active cancellation request"))) + (test-assert (= interrupt-count 1) + "notice failure does not block cancellation interrupt delivery") + (test-assert + (application-input-controller--consume-turn-cancellation-delivery-p controller) + "notice failure preserves pending cancellation delivery")) + (let* ((terminal (make-instance 'recording-terminal :columns 80)) + (ui + (terminal-ui-create + :terminal terminal + :completions + '((:name "/help" :argument nil :description "show help")))) + (application (make-instance 'application :ui ui)) + (controller + (make-instance + 'application-input-controller + :application application + :later-state (make-instance 'later-state) + :main-thread (current-thread))) + (interrupt-count 0)) + (setf (application-input-controller application) controller + (application-input-controller-active-p controller) t) + (terminal-ui-start ui) + (unwind-protect + (progn + (terminal-ui-process-event ui '(:insert "/h")) + (test-assert (terminal-ui-completion-menu-present-p ui) + "typed command input presents a passive completion menu") + (test-call-with-function-replacements + (list + (list + 'application-input-controller--interrupt-main-for-turn-cancellation + (lambda (observed-controller) + (test-assert (eq observed-controller controller) + "Escape interrupts the active controller") + (incf interrupt-count)))) + (lambda () + (application-input-controller--process-event controller ':escape) + (test-assert + (and + (zerop interrupt-count) + (not + (application-input-controller-turn-cancellation-p controller)) + (not (terminal-ui-completion-menu-present-p ui))) + "the first Escape dismisses the passive completion menu only") + (application-input-controller--process-event controller ':escape))) + (test-assert + (and (= interrupt-count 1) + (application-input-controller-turn-cancellation-p controller)) + "the second Escape interrupts the active request")) + (terminal-ui-stop ui))) + nil) + (-> test-idle-interrupt-exits-without-force-hint () null) (defun test-idle-interrupt-exits-without-force-hint () "Test idle Ctrl-C clears a draft or exits directly without force state." @@ -2105,8 +2192,9 @@ (loop until holding-p do (condition-wait gate-condition gate :timeout 2))) (application-input-controller--refresh-interrupt-hint controller) - (test-assert (null (terminal-ui-notice ui)) - "contended presentation drops the due hint") + (test-assert + (string= (terminal-ui-notice ui) "Interrupting request.") + "contended presentation retains the earlier interruption notice") (test-assert (application-input-controller-interrupt-hint-time controller) "a dropped hint stays due for a later reader pass")) @@ -6634,6 +6722,43 @@ :detach-function (lambda () nil))) (task-augment-tool-registry registry))) +(-> test-application-conversation-input-history () null) +(defun test-application-conversation-input-history () + "Test durable editable history filtering, order, and bounds." + (let* ((configuration (test-configuration)) + (root (test-configuration-root configuration)) + (conversation + (conversation-create configuration :identifier "input-history"))) + (unwind-protect + (progn + (conversation-append-user-message conversation "first user") + (conversation-append-record + conversation + '(:message :role :assistant :content "ignored assistant")) + (conversation-append-user-operation + conversation + :kind ':command + :source "/help" + :status ':ok + :result "shown") + (conversation-append-summary conversation "ignored summary") + (conversation-append-user-operation + conversation + :kind ':lisp + :source "(+ 1 2)" + :status ':ok + :result "3") + (conversation-append-user-message conversation "last user") + (test-assert + (equal + (application--conversation-input-history conversation :limit 3) + '("/help" "(+ 1 2)" "last user")) + "editable history keeps only bounded user inputs in durable order")) + (uiop:delete-directory-tree + root :validate t :if-does-not-exist :ignore))) + nil) + + (-> test-application-runtime-replacement-transactions () null) (defun test-application-runtime-replacement-transactions () "Test workspace and conversation switches prepare fresh runtimes atomically." @@ -6660,6 +6785,9 @@ (third-conversation (conversation-create configuration :identifier "runtime-transaction-third")) + (terminal (make-instance 'recording-terminal :columns 80)) + (editor (line-editor-create :history-limit 4)) + (ui (terminal-ui-create :terminal terminal :editor editor)) (provider (provider-create configuration)) (application (make-instance @@ -6675,7 +6803,7 @@ :conversation first-conversation :tool-registry old-registry :worker nil) - :ui nil)) + :ui ui)) (owned-registries (list old-registry))) (ensure-directories-exist workspace-one) (ensure-directories-exist workspace-two) @@ -6799,13 +6927,26 @@ (workspace-orchestrator (application--task-orchestrator application))) (push conversation-registry owned-registries) + (conversation-append-user-message + second-conversation "second conversation history") + (terminal-ui-load-history ui '("workspace history")) + (terminal-ui-set-input ui "draft before install") (test-call-with-function-replacements (list (list 'application--create-tool-registry (lambda (configuration) (declare (ignore configuration)) - conversation-registry))) + conversation-registry)) + (list + 'application-publish-recovery-session + (lambda (installed-application) + (test-assert + (eq installed-application application) + "the late installation hook receives the active application") + (terminal-ui-set-input ui "concurrent draft") + (terminal-ui-process-event ui :left) + nil))) (lambda () (application-install-conversation application second-conversation))) @@ -6840,6 +6981,15 @@ (tool-registry-provider-schemas conversation-registry)))) "conversation installation advertises only fresh tool schemas") + (test-assert + (and + (equalp + (line-editor-history editor) + #("second conversation history")) + (string= (line-editor-text editor) "concurrent draft") + (= (line-editor-cursor editor) 15) + (not (terminal-ui--editor-history-navigating-p editor))) + "conversation installation preserves input entered before its final history commit") (test-assert (and (eq @@ -6854,6 +7004,56 @@ (eq workspace-orchestrator conversation-orchestrator))) "conversation installation replaces and restarts task runtimes") + (terminal-ui-load-history ui '("old one" "old two")) + (terminal-ui-set-input ui "draft before failed install") + (terminal-ui-process-event ui :left) + (let* ((expected-history + (copy-seq (line-editor-history editor))) + (failed-close-count 0) + (failed-registry + (application-tests--replacement-registry + "conversation-failed" + (lambda () (incf failed-close-count))))) + (push failed-registry owned-registries) + (conversation-append-user-message + third-conversation "replacement history") + (test-call-with-function-replacements + (list + (list + 'application--create-tool-registry + (lambda (configuration) + (declare (ignore configuration)) + failed-registry)) + (list + 'context-runtime-reset + (lambda () + (terminal-ui-set-input + ui "input during failed install") + (terminal-ui-process-event ui :left) + (error "late conversation install failure")))) + (lambda () + (test-assert + (handler-case + (progn + (application-install-conversation + application third-conversation) + nil) + (application-runtime-replacement-error (condition) + (eq + (application-runtime-replacement-error-stage condition) + ':install))) + "late conversation failure enters installation rollback"))) + (test-assert + (and + (= failed-close-count 1) + (equalp (line-editor-history editor) expected-history) + (string= + (line-editor-text editor) + "input during failed install") + (= (line-editor-cursor editor) + (1- (length "input during failed install"))) + (not (terminal-ui--editor-history-navigating-p editor))) + "conversation rollback leaves history and concurrent input untouched")) (let ((active-configuration (application-configuration application)) (active-provider @@ -6864,6 +7064,7 @@ (application-agent application)) (active-rendered-sequence (application-rendered-sequence application)) + (active-close-count conversation-close-count) (attempted-lease nil) (lease-acquire-function (symbol-function 'conversation-lease-acquire))) @@ -6915,7 +7116,7 @@ (conversation-identifier second-conversation)) attempted-lease (not (conversation-lease-held-p attempted-lease)) - (zerop conversation-close-count) + (= conversation-close-count active-close-count) (eq (job-pool-lifecycle-state (task-orchestrator-pool conversation-orchestrator)) @@ -8937,6 +9138,7 @@ (test-forced-exit-without-durable-conversation) (test-graceful-shutdown-retains-interrupt-escape) (test-active-turn-interrupt-events) + (test-active-turn-cancellation-side-effects) (test-idle-interrupt-exits-without-force-hint) (test-active-turn-stop-keys) (test-active-command-stop-key) @@ -8976,6 +9178,7 @@ (test-application-busy-conversation-resume) (test-application-fresh-conversation-lease-collision) (test-application-tool-runtime-lifecycle) + (test-application-conversation-input-history) (test-application-runtime-replacement-transactions) (test-application-runtime-retirement-failures) (test-application-create-unwind-safety) diff --git a/tests/release-server-tests.lisp b/tests/release-server-tests.lisp index f82d563a..a9652801 100644 --- a/tests/release-server-tests.lisp +++ b/tests/release-server-tests.lisp @@ -89,7 +89,7 @@ deployment '("config" "user.email" "release-test@invalid")) (release-server-tests--git deployment '("add" ".")) (release-server-tests--git - deployment '("commit" "--quiet" "-m" "Create release fixture")) + deployment '("commit" "--quiet" "--no-gpg-sign" "-m" "Create release fixture")) (if annotated-p (release-server-tests--git deployment (list "tag" "-a" tag "-m" "Annotated fixture")) @@ -1010,7 +1010,7 @@ '("add" "autolith.asd")) (release-server-tests--git (merge-pathnames "v0.18.0/" remote-root) - '("commit" "--quiet" "-m" "Advance release fixture")) + '("commit" "--quiet" "--no-gpg-sign" "-m" "Advance release fixture")) (release-server-tests--git (merge-pathnames "v0.18.0/" remote-root) '("tag" "v0.18.1")) diff --git a/tests/self-tool-tests.lisp b/tests/self-tool-tests.lisp index ec2b3f38..788be2d1 100644 --- a/tests/self-tool-tests.lisp +++ b/tests/self-tool-tests.lisp @@ -990,7 +990,7 @@ '("config" "user.email" "autolith-test@example.invalid")) (self-git-command configuration '("add" "src/definitions.lisp")) (self-git-command configuration - '("commit" "--quiet" "-m" "Create baseline")) + '("commit" "--quiet" "--no-gpg-sign" "-m" "Create baseline")) (let* ((conversation (conversation-create configuration :identifier "durable-mutation")) @@ -1522,7 +1522,7 @@ '("config" "user.email" "autolith-test@example.invalid")) (self-git-command configuration '("add" "src/baseline.lisp")) (self-git-command configuration - '("commit" "--quiet" "-m" "Create baseline")) + '("commit" "--quiet" "--no-gpg-sign" "-m" "Create baseline")) (when (fboundp 'test-self-published-definition) (fmakunbound 'test-self-published-definition)) (remhash target *exploratory-definitions*) diff --git a/tests/terminal-tests.lisp b/tests/terminal-tests.lisp index fdd110dc..70afe8af 100644 --- a/tests/terminal-tests.lisp +++ b/tests/terminal-tests.lisp @@ -530,6 +530,39 @@ "Ctrl-D exits when the editor is empty")))) nil) +(-> test-terminal-history-replacement () null) +(defun test-terminal-history-replacement () + "Test bounded history loading preserves the active draft and cursor." + (let* ((terminal (make-instance 'recording-terminal :columns 40)) + (editor + (line-editor-create + :history '("older" "newer") + :history-limit 2)) + (ui (terminal-ui-create :terminal terminal :editor editor))) + (terminal-ui-set-input ui "draft") + (terminal-ui-process-event ui :history-previous) + (terminal-ui-process-event ui :left) + (terminal-ui-load-history ui '("one" "two" "three")) + (test-assert + (equalp (line-editor-history editor) #("two" "three")) + "history loading honors the target editor's custom limit") + (test-assert + (and (string= (line-editor-text editor) "draft") + (= (line-editor-cursor editor) 5) + (not (terminal-ui--editor-history-navigating-p editor))) + "history loading restores the draft and leaves obsolete traversal") + (terminal-ui-process-event ui :left) + (terminal-ui-load-history ui '("alpha" "beta")) + (test-assert + (and (string= (line-editor-text editor) "draft") + (= (line-editor-cursor editor) 4)) + "history loading preserves an ordinary draft cursor exactly") + (terminal-ui-process-event ui :submit) + (test-assert + (equalp (line-editor-history editor) #("beta" "draft")) + "replacement history remains extendable and bounded")) + nil) + (-> test-terminal-lisp-draft () null) (defun test-terminal-lisp-draft () "Test Lisp prompt projection, live ColorLisp spans, and exact mode switching." @@ -2000,6 +2033,11 @@ "every matching command is suggested") (test-assert (not (search "/quit" painted)) "commands outside the typed prefix are not suggested")) + (recording-terminal-reset terminal) + (terminal-ui-process-event active-ui :escape) + (test-assert (not (search "/resume ID" + (recording-terminal-output terminal))) + "escape hides a passive completion menu") (terminal-ui-process-event active-ui :complete) (test-assert (string= (line-editor-text editor) "/rollback ") "tab cycles to and previews the next command") @@ -2024,12 +2062,59 @@ (terminal-ui-process-event active-ui :interrupt) (terminal-ui-process-event active-ui '(:insert "/r")) (terminal-ui-process-event active-ui :history-next) + (test-assert (not (terminal-ui-completion-active-p active-ui)) + "history keys do not hijack an unbegun completion") + (terminal-ui-process-event active-ui :down) + (test-assert (terminal-ui-completion-active-p active-ui) + "arrow keys begin completion for a typed command prefix") (test-assert (string= (line-editor-text editor) "/rollback ") "arrow keys move the completion selection") (terminal-ui-process-event active-ui :escape) (test-assert (string= (line-editor-text editor) "/r") "escape restores the prefix from before completion") (terminal-ui-process-event active-ui :interrupt) + (terminal-ui-process-event active-ui '(:insert "/help")) + (terminal-ui-process-event active-ui :submit) + (terminal-ui-process-event active-ui '(:insert "/quit")) + (terminal-ui-process-event active-ui :submit) + (recording-terminal-reset terminal) + (terminal-ui-process-event active-ui :history-previous) + (test-assert (string= (line-editor-text editor) "/quit") + "history recall restores the newest command") + (test-assert (not (terminal-ui-completion-active-p active-ui)) + "history recall does not begin completion") + (test-assert (not (search "leave Autolith" + (recording-terminal-output terminal))) + "history recall does not paint command suggestions") + (test-assert + (not (terminal-ui-completion-menu-present-p active-ui)) + "history traversal makes retained completion state non-renderable") + (test-assert + (null (selector-items (terminal-ui-completion-selector active-ui))) + "history traversal clears stale completion selector items") + (recording-terminal-reset terminal) + (terminal-ui-process-event active-ui :up) + (test-assert (string= (line-editor-text editor) "/help") + "arrows continue history through recalled commands") + (test-assert (not (search "show this reference" + (recording-terminal-output terminal))) + "continued history recall still hides command suggestions") + (terminal-ui-process-event active-ui :complete) + (test-assert (terminal-ui-completion-active-p active-ui) + "tab can still begin completion on a recalled command") + (recording-terminal-reset terminal) + (terminal-ui-process-event active-ui :escape) + (test-assert (not (search "show this reference" + (recording-terminal-output terminal))) + "escape hides the completion menu") + (test-assert (string= (line-editor-text editor) "/help") + "escape restores the recalled history entry") + (test-assert (terminal-ui--editor-history-navigating-p editor) + "escape restores history traversal after completion") + (terminal-ui-process-event active-ui :down) + (test-assert (string= (line-editor-text editor) "/quit") + "history navigation continues after completion is cancelled") + (terminal-ui-process-event active-ui :interrupt) (terminal-ui-process-event active-ui '(:insert "/q")) (multiple-value-bind (action payload) (terminal-ui-process-event active-ui :submit) @@ -2893,6 +2978,7 @@ sources keeps the tests deterministic under an interactive terminal." (test-terminal-finalized-scrollback) (test-terminal-resize-frame) (test-terminal-line-editor) + (test-terminal-history-replacement) (test-terminal-lisp-draft) (test-terminal-image-attachments) (test-terminal-input-decoding) diff --git a/tests/tests.lisp b/tests/tests.lisp index 1f0ecd2c..784119be 100644 --- a/tests/tests.lisp +++ b/tests/tests.lisp @@ -2,6 +2,37 @@ ;;;; -- Test Entry -- +(-> test-configuration-source-platform-reading () null) +(defun test-configuration-source-platform-reading () + "Test settings source reads under each supported platform feature set." + (let ((settings-path + (merge-pathnames + "src/configuration/settings.lisp" + (asdf:system-source-directory :autolith))) + (native-features + (remove-if + (lambda (feature) + (member feature + '(:linux :darwin :macos :macosx :bsd + :freebsd :netbsd :openbsd))) + *features*))) + (dolist (platform-features + '((:linux) (:darwin :bsd) (:bsd) nil)) + (test-assert + (handler-case + (let ((*features* (append platform-features native-features)) + (*read-eval* nil)) + (with-open-file (stream settings-path + :direction ':input + :external-format ':utf-8) + (loop until (eq (read stream nil ':eof) ':eof))) + t) + (error () + nil)) + "configuration source reads with each supported platform feature set"))) + nil) + + (-> run-tests () boolean) (defun run-tests () "Run Autolith's dependency-free unit tests and return true on success." @@ -64,6 +95,7 @@ "the default reasoning effort is ultra") (test-assert (not (configuration-immutable-p configuration)) "ordinary configuration enables active-image mutation tools") + (test-configuration-source-platform-reading) (test-assert (configuration-immutable-p (configuration-with-model