system-manager-wakasu
1;;; portal.el --- Run processes in portals
2;;
3;; Copyright (C) 2024 Chris Done
4;;
5;; This file is free software; you can redistribute it and/or modify
6;; it under the terms of the GNU General Public License as published by
7;; the Free Software Foundation; either version 2, or (at your option)
8;; any later version.
9
10;; This file is distributed in the hope that it will be useful,
11;; but WITHOUT ANY WARRANTY; without even the implied warranty of
12;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13;; GNU General Public License for more details.
14
15;; You should have received a copy of the GNU General Public License
16;; along with GNU Emacs; see the file COPYING. If not, write to
17;; the Free Software Foundation, Inc., 59 Temple Place - Suite 330,
18;; Boston, MA 02111-1307, USA.
19
20;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
21;; Customizations
22
23(defgroup portal nil
24 "Portal group."
25 :group 'convenience)
26
27(defcustom portal-outputs-directory
28 "~/.local/share/portals/"
29 "Directory where to create output artifacts."
30 :type 'string :group 'portal)
31
32(defcustom portal-default-stdout-buffer-len
33 4096
34 "Default buffer length for the stdout preview."
35 :group 'portal :type 'number)
36
37(defcustom portal-default-stderr-buffer-len
38 4096
39 "Default buffer length for the stderr preview."
40 :group 'portal :type 'number)
41
42(defface portal-face
43 '((((class color) (background dark))
44 (:foreground "#fff" :bold t))
45 (((class color) (background light))
46 (:foreground "#000" :bold t)))
47 "Portal face."
48 :group 'portal)
49
50(defface portal-exited-stdout-face
51 '((t :foreground "#acac9e"))
52 "Portal exited stdout face."
53 :group 'portal)
54
55(defface portal-timestamp-face
56 '((t :foreground "#888888"))
57 "Portal exited stdout face."
58 :group 'portal)
59
60(defface portal-exited-stderr-face
61 '((t :foreground "#aa7070"))
62 "Portal exited stderr face."
63 :group 'portal)
64
65(defface portal-exit-success-face
66 '((t :foreground "#89b664"))
67 "Portal exit successful face."
68 :group 'portal)
69
70(defface portal-exit-failure-face
71 '((t :foreground "#ae6161"))
72 "Portal exit failure face."
73 :group 'portal)
74
75(defface portal-meta-face
76 '((t :foreground "#89b664"))
77 "Portal meta face."
78 :group 'portal)
79
80(defface portal-stdout-face
81 '((t :inherit 'default))
82 "Portal stdout face."
83 :group 'portal)
84
85(defface portal-stderr-face
86 '((t :foreground "#ae6161"))
87 "Portal stderr face."
88 :group 'portal)
89
90;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
91;; Interactive commands
92
93(defun portal-insert-shell-command (command)
94 "Launch an asynchronous shell of COMMAND, make a portal associated
95with the current buffer and insert the portal into the current
96buffer."
97 (interactive "sCommand: ")
98 (portal-insert-command
99 (list shell-file-name shell-command-switch command)))
100
101(defun portal-open-stdout ()
102 "Open the stdout of the file at point."
103 (interactive)
104 (with-current-buffer (find-file-other-window (portal-file-name (portal-at-point) "stdout"))
105 (portal-ansi-colors-minor-mode)
106 (auto-revert-tail-mode)
107 (goto-char (point-max))
108 (push-mark (point-max))))
109
110(defun portal-open-stderr ()
111 "Open the stderr of the file at point."
112 (interactive)
113 (with-current-buffer (find-file-other-window (portal-file-name (portal-at-point) "stderr"))
114 (portal-ansi-colors-minor-mode)
115 (auto-revert-tail-mode)
116 (goto-char (point-max))
117 (push-mark (point-max))))
118
119(defun portal-interrupt ()
120 "Interrupt the process at point."
121 (interactive)
122 (let ((proc (get-process (portal-process-name (portal-at-point)))))
123 (when (process-live-p proc)
124 (interrupt-process proc))))
125
126(defun portal-rerun ()
127 "Re-run portal at point."
128 (interactive)
129 (portal-jump-to-portal)
130 (let* ((portal (portal-at-point))
131 (command (portal-read-json-file portal "command"))
132 (env (portal-read-json-file portal "env"))
133 (default-directory (portal-read-json-file portal "directory")))
134 (portal-interrupt)
135 (delete-region (line-beginning-position) (line-end-position))
136 (portal-wipe-summary)
137 (portal-insert-command (append command nil))
138 (portal-refresh-soon)))
139
140(defun portal-edit ()
141 "Edit and re-run portal at point."
142 (interactive)
143 (portal-jump-to-portal)
144 (portal-interrupt)
145 (let* ((portal (portal-at-point))
146 (command
147 (vector
148 shell-file-name
149 shell-command-switch
150 (read-from-minibuffer
151 "Edit command: "
152 (portal-as-shell-command (portal-read-json-file portal "command")))))
153 (env (portal-read-json-file portal "env"))
154 (default-directory (portal-read-json-file portal "directory")))
155 (delete-region (line-beginning-position) (line-end-position))
156 (portal-wipe-summary)
157 (portal-insert-command (append command nil))
158 (portal-refresh-soon)))
159
160(defun portal-clone ()
161 "Clone the portal at point."
162 (interactive)
163 (portal-jump-to-portal)
164 (let* ((portal (portal-at-point))
165 (command (portal-read-json-file portal "command"))
166 (env (portal-read-json-file portal "env"))
167 (default-directory (portal-read-json-file portal "directory")))
168 (save-excursion (insert "\n"))
169 (portal-insert-command (append command nil))
170 (portal-refresh-soon)))
171
172;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
173;; Launching processes
174
175(defun portal-start (buffer portal stdout-path stderr-path program program-args)
176 "Run PROGRAM-PATH with ARGS, connect it to portal PORTAL in buffer
177BUFFER, and write the stdout to STDOUT-PATH and stderr to
178STDERR-PATH."
179 (let* ((stderr-pipe
180 (make-pipe-process
181 :name (portal-stderr-process-name portal)
182 :buffer buffer
183 :noquery t
184 :filter 'portal-process-filter
185 :sentinel 'portal-stderr-pipe-sentinel))
186 (main-process
187 (make-process
188 :name (portal-process-name portal)
189 :buffer buffer
190 :command (cons program program-args)
191 :noquery nil
192 :connection-type 'pipe
193 :sentinel 'portal-main-process-sentinel
194 :filter 'portal-process-filter
195 :stderr stderr-pipe)))
196
197 (process-put stderr-pipe :portal portal)
198 (process-put stderr-pipe :output-path stderr-path)
199 (process-put stderr-pipe :buffer "")
200 (process-put stderr-pipe :buffer-len portal-default-stderr-buffer-len)
201
202 (process-put main-process :portal portal)
203 (process-put main-process :output-path stdout-path)
204 (process-put main-process :buffer "")
205 (process-put main-process :buffer-len portal-default-stdout-buffer-len)
206
207 ;; Connect the two processes.
208 (process-put main-process :stderr-process stderr-pipe)
209
210 (portal-write-json-file portal "command" (apply #'vector (cons program program-args)))
211 (portal-write-json-file portal "env" (apply #'vector process-environment))
212 (portal-write-json-file portal "directory" default-directory)
213 (portal-write-json-file portal "status" (format "%S" (process-status main-process)))))
214
215;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
216;; Process filtering
217
218(defun portal-process-filter (process output)
219 (let ((filepath (process-get process :output-path)))
220 (when debug-on-error
221 (message "portal-process-filter: Writing to %s" filepath))
222 (portal-accumulate-buffer process output)
223 (with-temp-buffer
224 (insert output)
225 (write-region (point-min) (point-max) filepath :append :no-messages))))
226
227(defun portal-accumulate-buffer (process output)
228 "Accumulate some OUTPUT into PROCESS's preview buffer."
229 (process-put
230 process
231 :buffer (portal-shrink-preview
232 (process-get process :buffer-len)
233 (concat (process-get process :buffer) output))))
234
235(defun portal-shrink-preview (len string)
236 "Shrink a preview buffer STRING to the right length."
237 (if (> (length string) len)
238 (substring string (- len))
239 string))
240
241;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
242;; Sentinels
243
244(defun portal-main-process-sentinel (process event)
245 "Handles the main process's status updates."
246 (when debug-on-error
247 (message "main-process-sentinel: %S: %S" process event))
248 (portal-write-json-file
249 (process-get process :portal)
250 "status" (format "%S" (process-exit-status process))))
251
252(defun portal-stderr-pipe-sentinel (process event)
253 "Handles the stderr pipe's status updates."
254 (when debug-on-error
255 (message "stderr-pipe-sentinel: %S: %S" process event)))
256
257;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
258;; File/directory operations
259
260(defun portal-ensure-directory (portal)
261 "Create the stdout/stderr files for PORTAL in an appropriate
262location."
263 (let ((directory (concat (file-name-as-directory portal-outputs-directory) portal)))
264 (make-directory directory :including-parents)
265 directory))
266
267(defun portal-directory-exists-p (portal)
268 "Check PORTAL has a directory that exists."
269 (let ((directory (concat (file-name-as-directory portal-outputs-directory) portal)))
270 (file-exists-p directory)))
271
272(defun portal-file-exists-p (portal name)
273 "Check PORTAL has a file NAME that exists."
274 (let ((directory (concat (file-name-as-directory portal-outputs-directory) portal)))
275 (file-exists-p (concat (file-name-as-directory directory) name))))
276
277(defun portal-persist-file (portal name content)
278 "Persist CONTENT to disk with filename NAME."
279 (with-temp-buffer
280 (insert content)
281 (write-region
282 (point-min) (point-max)
283 (portal-file-name portal name)
284 nil ; no-append
285 :no-messages))
286 content)
287
288(defun portal-write-json-file (portal name expr)
289 "Print EXPR to disk with filename NAME."
290 (with-temp-buffer
291 (insert (json-serialize expr))
292 (write-region
293 (point-min) (point-max)
294 (portal-file-name portal name)
295 nil ; no-append
296 :no-messages))
297 expr)
298
299(defun portal-read-json-file (portal name)
300 "Read JSON content from file NAME for the given PORTAL."
301 (with-temp-buffer
302 (insert-file-contents (portal-file-name portal name))
303 (json-parse-string (buffer-string))))
304
305(defun portal-read-file (portal name)
306 "Read content from file NAME for the given PORTAL."
307 (with-temp-buffer
308 (let ((file (portal-file-name portal name)))
309 (when (file-exists-p file)
310 (insert-file-contents file)))
311 (buffer-string)))
312
313(defun portal-tail-file (portal n name)
314 "Tail last N lines of file NAME for the given PORTAL."
315 (with-temp-buffer
316 (let ((file (portal-file-name portal name)))
317 (if (file-exists-p file)
318 (portal-tail-n-lines n file)
319 ""))))
320
321;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
322;; Nano-IDs
323
324(defun portal-generate-nanoid ()
325 "Generate a Nano ID of the form `portal_NGMyMDVkZjZiYTVlZTVhM' using SHA-1."
326 (let* ((random-string (format "%s%s%S" (emacs-pid) (current-time-string) (random)))
327 (sha1-hash (secure-hash 'sha1 random-string))
328 (base64-encoded (base64-encode-string sha1-hash))
329 (nanoid (string-trim-right (substring base64-encoded 0 21))))
330 (concat "portal_" nanoid)))
331
332;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
333;; A minor mode for applying ansi-term colors to a buffer
334
335(define-minor-mode portal-ansi-colors-minor-mode
336 "Apply ANSI colors for terminal outputs."
337 :init-value nil
338 :lighter "ANSI"
339 (ansi-color-apply-on-region (point-min) (point-max)))
340
341;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
342;; A small minor mode that just sets up a timer that runs a thing in a
343;; given buffer every N seconds
344
345(defvar-local portal-alpha-timer
346 nil)
347
348(define-minor-mode portal-alpha-minor-mode
349 "TODO"
350 :init-value nil
351 :lighter "@"
352 (when portal-alpha-timer (cancel-timer portal-alpha-timer))
353 (when portal-alpha-minor-mode
354 (setq portal-alpha-timer
355 (run-with-timer 1 2 'portal-beta-refresh (current-buffer)))))
356
357(defun portal-refresh-soon ()
358 "Trigger a refresh within the blink of an eye, but no sooner, or
359later."
360 (run-with-timer 0.100 nil 'portal-beta-refresh (current-buffer)))
361
362(defun portal-beta-refresh (buffer)
363 "Refresh portal displays."
364 (when (buffer-live-p buffer)
365 (let ((window (get-buffer-window buffer)))
366 (when window
367 (with-current-buffer buffer
368 (let ((point (point)))
369 (save-excursion
370 (goto-char (point-min))
371 (while (and (re-search-forward portal-regexp nil t nil)
372 (<= (point) (window-end window)))
373 (when (<= (window-start window) (point) (window-end window))
374 (let* ((portal (match-string 0))
375 (process (get-process (portal-process-name portal)))
376 (summary (if (portal-directory-exists-p portal)
377 (portal-summary portal process)
378 "# Invalid portal."))
379 (match-end (match-end 0))
380 (old-summary (get-text-property (line-beginning-position) 'portal-summary)))
381 (unless (and old-summary (string= summary old-summary))
382 (put-text-property (line-beginning-position) (point)
383 'portal-summary
384 summary)
385 (put-text-property (line-beginning-position) (point)
386 'portal
387 portal)
388 (portal-wipe-summary)
389 (insert "\n" summary))))))
390 (goto-char point)))))))
391
392(defun portal-wipe-summary ()
393 "Wipe the '# summary' lines that follow the portal."
394 (save-excursion
395 (when (looking-at "\n#")
396 (forward-line 1)
397 (let ((point (point)))
398 (or (search-forward-regexp "^[^#]" nil t 1)
399 (goto-char (point-max)))
400 (delete-matching-lines "^#" point (point))))))
401
402(defun portal-summary (portal process)
403 "Generate a summary of the portal."
404 (let* ((command (portal-read-json-file portal "command"))
405 (directory (portal-read-json-file portal "directory"))
406 (status (portal-read-json-file portal "status"))
407 (stdout (if process
408 (portal-last-n-lines
409 5
410 (process-get process :buffer))
411 (portal-tail-file portal 5 "stdout")))
412 (stderr (if process
413 (portal-last-n-lines
414 5
415 (process-get (process-get process :stderr-process) :buffer))
416 (portal-tail-file portal 5 "stderr")))
417 (started-time
418 (file-attribute-modification-time (file-attributes (portal-file-name portal "command"))))
419 (exited-time
420 (file-attribute-modification-time (file-attributes (portal-file-name portal "status")))))
421 (with-temp-buffer
422 (insert (propertize
423 (concat "# (" (if (string= status "run") "🌀" status) ") " (portal-as-shell-command command))
424 'face
425 (if (string= status "run")
426 'portal-meta-face
427 (if (string= status "0")
428 'portal-exit-success-face
429 'portal-exit-failure-face))))
430 (insert "\n"
431 (concat
432 (propertize (format-time-string "# Started: %Y-%m-%d %T" started-time)
433 'face 'portal-timestamp-face)
434 (if (string= status "run")
435 ""
436 (propertize (concat
437 (format-time-string ", exited: %Y-%m-%d %T" exited-time)
438 " => "
439 (portal-display-time-difference started-time exited-time))
440 'face 'portal-timestamp-face))))
441 ;; Only show if it's different to the current directory,
442 ;; otherwise it's noise.
443 (unless (string= default-directory directory) (insert "\n# " directory))
444 (unless (= 0 (length (string-trim stdout)))
445 (insert "\n"
446 (propertize (portal-clean-output stdout)
447 'face (if (string= status "run")
448 'portal-stdout-face
449 'portal-exited-stdout-face))))
450 (unless (= 0 (length (string-trim stderr)))
451 (insert "\n"
452 (propertize (portal-clean-output stderr)
453 'face
454 (if (string= status "run")
455 'portal-stderr-face
456 'portal-exited-stderr-face))))
457 (propertize (buffer-string)
458 'portal portal))))
459
460(defun portal-display-time-difference (start-time end-time)
461 "Display the time difference between START-TIME and END-TIME in human-readable format.
462START-TIME and END-TIME should be Emacs Lisp time values as returned by `current-time'.
463The function will display the time in the most appropriate unit (from ns to days)."
464 (let* ((diff (float-time (time-subtract end-time start-time))))
465 (apply #'format
466 (cons "%.3f %s"
467 (cond
468 ((< diff 1e-6)
469 (list (* diff 1e9) "ns"))
470 ((< diff 1e-3)
471 (list (* diff 1e6) "us"))
472 ((< diff 1)
473 (list (* diff 1e3) "ms"))
474 ((< diff 60)
475 (list diff "s"))
476 ((< diff 3600)
477 (list (/ diff 60) "mins"))
478 ((< diff 86400)
479 (list (/ diff 3600) "hours"))
480 (t
481 (list (/ diff 86400) "days")))))))
482
483;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
484;; String generation
485
486(defun portal-as-shell-command (command)
487 "If the vector COMMAND is a shell run, strip the prefix, else return the whole thing joined."
488 (if (and (= 3 (length command))
489 (string= (elt command 0) shell-file-name)
490 (string= (elt command 1) shell-command-switch))
491 (elt command 2)
492 (mapconcat 'shell-quote-argument command " ")))
493
494(defun portal-clean-output (output)
495 "Clean output for previewing, prefixed with #."
496 (portal-limit-lines-to-80-columns
497 (concat "# " (replace-regexp-in-string
498 "\n" "\n# "
499 (portal-no-empty-lines output)))))
500
501(defun portal-limit-lines-to-80-columns (string)
502 "Limit all lines in STRING to 80 columns."
503 (with-temp-buffer
504 (insert string)
505 (goto-char (point-min))
506 (while (not (eobp))
507 (move-to-column 80 t)
508 (delete-region (point) (line-end-position))
509 (forward-line))
510 (buffer-string)))
511
512(defun portal-process-name (portal)
513 (concat portal "-main-process"))
514
515(defun portal-stderr-process-name (portal)
516 (concat portal "-stderr-pipe"))
517
518(defun portal-file-name (portal name)
519 (concat (file-name-as-directory (portal-ensure-directory portal)) name))
520
521(defun portal-no-empty-lines (string)
522 "Drop empty lines from a string."
523 (replace-regexp-in-string
524 ;; Drop ANSI codes from terminal output
525 ;; <https://superuser.com/questions/380772/removing-ansi-color-codes-from-text-stream>
526 "\\(\x1B\\[[0-9;]*[A-Za-z]\\|[\x00-\x09\x0B-\x1F\x7F]\\|\n$\\|^\n\\)"
527 ""
528 string))
529
530(defun portal-last-n-lines (n string)
531 "Take last N lines from STRING."
532 (mapconcat #'identity (reverse (seq-take (reverse (split-string string "[\r\n]+" t)) n)) "\n"))
533
534(defun portal-tail-n-lines (n file-path)
535 "Tail the last N lines from FILE-PATH using tail, if possible. If
536not possible (due to lack of such tool), return nil."
537 (let ((this-buffer (current-buffer)))
538 (with-temp-buffer
539 (let ((out-buffer (current-buffer)))
540 (with-current-buffer this-buffer
541 (cl-case (call-process "tail" nil out-buffer nil "-n" (format "%d" n)
542 (expand-file-name file-path))
543 (0 (with-current-buffer out-buffer (buffer-string)))
544 (t "")))))))
545
546;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
547;; Finding portals and gathering information for them
548
549(defconst portal-regexp "\\<portal_[A-Za-z0-9]\\{21\\}\\>"
550 "Match on a portal's unique ID.")
551
552(defun portal-at-point ()
553 "Return the portal at point."
554 (or (save-excursion
555 (goto-char (line-beginning-position))
556 (when (looking-at portal-regexp)
557 (buffer-substring (match-beginning 0) (match-end 0))))
558 (get-text-property (point) 'portal)
559 (error "Not at a portal.")))
560
561(defun portal-jump-to-portal ()
562 "If there's a portal at point or a summary of a portal at point,
563jump to the portal at the beginning of the line upwards within
564the same paragraph."
565 (let ((portal (portal-at-point)))
566 (goto-char
567 (save-excursion
568 (goto-char (line-end-position))
569 (re-search-backward
570 (concat "^" (regexp-quote portal))
571 (save-excursion (forward-paragraph -1))
572 nil
573 1)))))
574
575;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
576;; Notes
577
578;; Use this on a portals buffer to stop it constantly being saved:
579;
580;; (setq buffer-save-without-query t)
581
582;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
583;; Major mode
584
585(defvar-keymap portal-mode-map
586 "M-!" 'portal-dwim-execute
587 "C-c C-c" 'portal-interrupt
588 "RET" 'portal-jump-to-thing-at-point
589 "M-p" 'portal-rerun
590 )
591
592(define-derived-mode portal-mode
593 fundamental-mode "Portals"
594 "Major mode for portals."
595 (setq buffer-save-without-query t)
596 (portal-alpha-minor-mode))
597
598(defun portal-insert-command (command)
599 "Launch an asynchronous proc of COMMAND, make a portal associated
600with the current buffer and insert the portal into the current
601buffer."
602 (let* ((portal (portal-generate-nanoid)))
603 (portal-start
604 (current-buffer)
605 portal
606 (portal-file-name portal "stdout")
607 (portal-file-name portal "stderr")
608 (car command)
609 (cdr command))
610 (insert portal)))
611
612(defun portal-dwim-execute ()
613 (interactive)
614 (call-interactively
615 (if (condition-case nil
616 (portal-at-point)
617 (error nil))
618 'portal-edit
619 'portal-shell-command)))
620
621(defun portal-shell-command (command)
622 "Run a shell command and insert it at point."
623 (interactive "sCommand: ")
624 (portal-insert-command
625 (list shell-file-name shell-command-switch command)))
626
627(defun portal-jump-to-thing-at-point ()
628 "Jump to the thing at point, i.e. an stdout/stderr output jumps to
629the file."
630 (interactive)
631 (let ((face (get-text-property (point) 'face)))
632 (cond
633 ((eq face 'portal-stderr-face)
634 (portal-open-stderr))
635 ((eq face 'portal-exited-stderr-face)
636 (portal-open-stderr))
637 ((eq face 'portal-stdout-face)
638 (portal-open-stdout))
639 ((eq face 'portal-exited-stdout-face)
640 (portal-open-stdout))
641 (t (call-interactively 'newline)))))
642
643(provide 'portal)
644