;;; emacs-web.el --- Browse the running Emacs's buffers over the web -*- lexical-binding: t; -*- ;; Author: Pavel Popov ;; Version: 0.1 ;; Package-Requires: ((emacs "29.1") (web-server "0.1.2") (htmlize "1.59")) ;; Keywords: hypermedia, tools ;;; Commentary: ;; A read-only buffer browser for the Emacs it runs in. The index shows ;; the live buffers as folders, one per major mode; each buffer opens as ;; a page coloured the way Emacs shows it. ;; ;; The server listens on 127.0.0.1 only and authenticates nobody: it is ;; meant to sit behind a reverse proxy that does. Every link it emits is ;; relative, so it works under any path prefix. ;; ;; Start it with `emacs-web-start', stop it with `emacs-web-stop'. ;;; Code: (require 'cl-lib) (require 'subr-x) (require 'url-util) (require 'json) (require 'web-server) (require 'htmlize) (require 'mailcap) (declare-function ghostel-paste-string "ghostel" (string)) (declare-function ghostel-status--shown "ghostel-status" ()) (declare-function ghostel-send-key "ghostel" (key-name &optional mods)) (declare-function ghostel--event-key-spec "ghostel" (event &optional meta)) (declare-function ghostel--redraw-now "ghostel" (buffer &optional force)) (declare-function ghostel--set-size-with-cell-dims "ghostel" (term rows cols)) (declare-function ghostel--adjust-size "ghostel" (window &optional force)) (declare-function ghostel--mouse-event "ghostel-module") (defvar ghostel--term) (defvar ghostel--term-rows) (defvar ghostel--term-cols) (defgroup emacs-web nil "Browse the running Emacs's buffers over the web." :group 'hypermedia) (defcustom emacs-web-port 4678 "Port the server listens on." :type 'integer) (defcustom emacs-web-favorite-modes '(ghostel-mode) "Major modes the home page lists beside All Buffers, not among the modes." :type '(repeat symbol)) (defcustom emacs-web-shared-port nil "Port of a second server showing only the shared buffers, or nil for none. It lists and opens the buffers shared with `emacs-web-toggle-share' and takes text, dictation and keys for them, and nothing else: no buffer's menu, no Emacs prompt, no forms to evaluate." :type '(choice (const :tag "None" nil) integer)) (defcustom emacs-web-max-chars 500000 "Most characters of a buffer one page shows." :type 'integer) (defcustom emacs-web-id-modes '(ghostel-mode) "Major modes whose buffers are linked by an id as well as by name. For buffers that get renamed while they live, so a link to one keeps leading to it." :type '(repeat symbol)) (defvar-local emacs-web--buffer-id nil "The id this buffer is linked by, once it has one.") (put 'emacs-web--buffer-id 'permanent-local t) (defconst emacs-web--manifest "{ \"name\": \"Emacs\", \"short_name\": \"Emacs\", \"start_url\": \"./\", \"scope\": \"./\", \"display\": \"standalone\", \"background_color\": \"#000000\", \"theme_color\": \"#000000\", \"icons\": [ {\"src\": \"apple-touch-icon.png\", \"sizes\": \"180x180\", \"type\": \"image/png\"}, {\"src\": \"icon-512.png\", \"sizes\": \"512x512\", \"type\": \"image/png\", \"purpose\": \"any\"} ] }" "The web app manifest, which lets a phone install the pages as an app.") (defconst emacs-web--icon-180 "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 AABJRU5ErkJggg==" "The app's 180px icon, a PNG in base64.") (defconst emacs-web--icon-512 "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAIAAAB7GkOtAAAdRklEQVR4nO3deXhcVd3A8XPv7JlJ JlvTJt3bUGhKa1lq01IoyFKRRZYCCsKrIKhYtT6uvKKCiKLg0ldBHxBQdrEgKFvZrGwthAKFthS6 ULqkbbZmkpnMPvd9xlahNW2TdDL3zPy+n4enf7iQ+0xn5ptz7rnnGIceOkUBAORxWpZl9zUAAGxg 2vFDAQD2IwAAIBQBAAChCAAACEUAAEAoAgAAQhEAABCKAACAUAQAAIQiAAAgFAEAAKEIAAAIRQAA QCgCAABCEQAAEIoAAIBQBAAAhCIAACAUR0ICgFCMAABAKAIAAEIRAAAQigAAgFAEAACEIgAAIBQB AAChCAAACEUAAEAoAgAAQhEAABCKAACAUAQAAIQiAAAgFAEAAKEIAAAIRQAAQCgCAABCEQAAEIoz gQFAKEYAACAUAQAAoQgAAAhFAABAKAIAAEIRAAAQigAAgFAEAACEIgAAIBQBAAChCAAACEUAAEAo AgAAQhEAABCKAACAUAQAAIQiAAAgFAEAAKEIAAAIxZnAACAUIwAAEIoAAIBQBAAAhCIAACAUAQAA oQgAAAhFAABAKAIAAEIRAAAQigAAgFAEAACEIgAAIBQBAAChCAAACEUAAEAoAgAAQhEAABCKAACA UBwJCQBCMQIAAKEIAAAIRQAAQCgCAABCEQAAEIoAAIBQBAAAhCIAACAUAQAAoQgAAAhFAABAKAIA AEIRAAAQigAAgFAEAACEIgAAIBQBAAChCAAACEUAAEAozgQGAKEYAQCAUAQAAIQiAAAgFAEAAKEI AAAIRQAAQCgCAABCEQAAEIoAAIBQBAAAhCIAACAUAQAAoQgAAAhFAABAKAIAAEIRAAAQigAAgFAE AACEIgAAIBRnAgOAUIwAAEAoAgAAQhEAABCKAACAUAQAAIQiAAAgFAEAAKEIAAAIRQAAQCgCAABC EQAAEIoAAIBQBAAAhCIAACAUAQAAoQgAAAhFAABAKAIAAEJxJCQACMUIAACEIgAAIBQBAAChCAAA CEUAAEAoAgAAQhEAABCKAACAUAQAAIQiAAAgFAEAAKEIAAAIRQAAQCgCAABCEQAAEIoAAIBQBAAA hCIAACAUAQAAoTgTGACEYgQAAEIRAAAQigAAgFAEAACEIgAAIJTT7gsABsWQoDliiLM6aFaUmuWB 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 bnss9LcXw6k0D/di0BEAaOHwCd7PnhycKfjJ4bfWx+99puuZZT3pDF/9yBMCAI1MGOH+1PFlJ03z e91SVoum0tbTy3ruebpr1QbWdyLfCAC0E/CZn2gMnH1MYPzwYl4wumpD/LGlkUVNEQ7UhF0IAPR1 8Cj3nGn+k6b5h1U6VbHY0ppa1BR5dGn4/W2s7ITNCAB0Zxhq8jjPcYeVHDW5ZFytSxWgTEYtXxd7 4a3o82/2rG/mex+6IAAoJLVVzlmTfdMbfFPrPeUBh9KYZan3tiXfWBNb9m5syYpoFwelQT8EAIVq bK1rar33I/WeiaPcY2pdDtP++8ahSGbN5sSK9+LL18aXr43xpQ/NEQAUA7fTGFfnmjDSPb7OPbLG ObLGNXyI0z2YG89lLNUeSm/tSG3cnly7ObF2S3LtlkRbKD14PxHIOQKA4mQaqqbCWVPhGFLurA46 qoOOqjJHaYkZ8Jl+n5H902u6HIbToRw7/zSNdMZKprLrMpMpK5lWqZTVE890RTKhyM4/012RTHtX emt7amtHantHmme1UOgIAAAIxTlNACAUAQAAoQgAAAhFAABAKAIAAEIRAAAQymlZbD4OABIxAgAA oQgAAAhFAABAKAIAAEIRAAAQigAAgFAEAACEIgAAIBQBAAChCAAACEUAAEAoAgAAQhEAABCKAACA UAQAAIQiAAAgFAEAAKEIAAAIRQAAQCjOBAYAoRgBAIBQBAAAhCIAACAUAQAAoQgAAAhFAABAKAIA AEIRAAAQigAAgFAEAACEIgAAIBQBAAChCAAACEUAAEAoAgAAQhEAABCKAACAUAQAAITiSEgAEIoR AAAIRQAAQCgCAABCEQAAEIoAAIBQBAAAhCIAACAUAQAAoQgAAAhFAABAKAIAAEIRAAAQigAAgFAE AACEIgAAIBQBAAChCAAACEUAAEAoAgAAQnEmMAAIxQgAAIQiAAAgFAEAAKEIAAAIRQAAQCgCAABC EQAAEIoAAIBQBAAAhCIAACAUAQAAoQgAAAhFAABAKAIAAEIRAAAQigAAgFAEAACEIgAAIBQBAACh OBMYAIRiBAAAQhEAABCKAACAUAQAAIQiAAAgFAEAAKEIAAAIRQAAQCgCAABCEQAAEIoAAIBQBAAA hCIAACAUAQAAoQgAAAhFAABAKAIAAEIRAAAQiiMhAUAoRgAAIBQBAAChCAAACEUAAEAoAgAAQhEA ABCKAACAUAQAAIQiAAAgFAEAAKEIAAAIRQAAQCgCAABCEQAAEIoAAIBQBAAAhCIAACAUAQAAoQgA AAjFmcAAIBQjAAAQigAAgFAEAACEIgAAIBQBAAChCAAACEUAAEAoAgAAQhEAABCKAACAUAQAAIQi AAAgFAEAAKEIAAAIRQAAQCgCAABCEQAAEIoAAIBQBAAAhOJMYAAQihEAAAhFAABAKAIAAEIRAAAQ igAAgFAEAACEIgAAIBQBAAChCAAACEUAAEAoAgAAQhEAABCKAACAUAQAAIQiAAAgFAEAAKEIAAAI RQAAQMn0/77VifTzbZLjAAAAAElFTkSuQmCC" "The app's 512px icon, a PNG in base64.") (defconst emacs-web--asset-types '(("manifest.webmanifest" "application/manifest+json" emacs-web--manifest) ("apple-touch-icon.png" "image/png" emacs-web--icon-180 base64) ("icon-512.png" "image/png" emacs-web--icon-512 base64)) "Files that make the pages an installable app: (NAME TYPE VARIABLE ENCODING).") (defcustom emacs-web-transcribe-command (list (expand-file-name "~/.local/share/uv/tools/mlx-whisper/bin/python")) "Python that has mlx_whisper, to turn recorded speech into text." :type '(repeat string)) (defcustom emacs-web-transcribe-model "mlx-community/whisper-large-v3-turbo" "The Whisper model dictation goes through." :type 'string) (defcustom emacs-web-transcribe-languages '("en" "ru" "nl") "Languages dictation may be in, as Whisper's codes. Whisper picks the likeliest of them for each recording." :type '(repeat string)) (defcustom emacs-web-list-actions nil "Commands the home page's menu offers, as (LABEL . FUNCTION). FUNCTION is called with no arguments, interactively if it is a command, as `this-command', without any window here changing; when it returns a buffer, the phone opens that buffer's page. What FUNCTION asks in the minibuffer the phone asks too." :type '(alist :key-type string :value-type function)) (defcustom emacs-web-completion-actions '((emacs-web-embark-collect-label . emacs-web-embark-collect)) "Commands offered while Emacs asks in the minibuffer with completions. Each is (LABEL . FUNCTION); LABEL is a string, or a function giving one or nil to leave the command out. FUNCTION is called in the minibuffer; when it returns a buffer, the question is left and the phone opens that buffer's page." :type '(alist :key-type (choice string function) :value-type function)) (defcustom emacs-web-icons '(("pin" . "") ("unpin" . "") ("link" . "") ("save" . "") ("share" . "") ("folder" . "") ("trash" . "")) "Icons a swipe action may show, as (NAME . SVG). SVG is what goes inside an with a 24 by 24 viewBox, drawn in strokes of the current colour." :type '(alist :key-type string :value-type string)) (defcustom emacs-web-buffer-actions '((buffer-name emacs-web-copy-name :note emacs-web-unsaved-note) (emacs-web-pin-label emacs-web-toggle-pin :swipe right :swipe-index 1 :swipe-color "#f09a37" :swipe-icon emacs-web-pin-icon) ("Copy link" emacs-web-copy-link :swipe left :swipe-index 1 :swipe-label "Link" :swipe-color "#3b82f6" :swipe-icon "link") ("Upload a file" emacs-web-upload) (emacs-web-save-label emacs-web-save :anyway t :swipe left :swipe-index 2 :swipe-color "#7c6cf0" :swipe-icon "save") (emacs-web-revert-label emacs-web-revert) ("Scroll to top" emacs-web-scroll-to-top) ("Scroll to bottom" emacs-web-scroll-to-bottom) (emacs-web-share-label emacs-web-toggle-share) (emacs-web-send-keys-label emacs-web-send-keys :input "Keys: RET ESC TAB SPC …; Shift-Tab is S-") ("Rename" emacs-web-rename :input emacs-web-rename-question) ("Kill" emacs-web-kill :confirm emacs-web-kill-question :swipe left :swipe-index 3 :swipe-color "#ff453a" :swipe-icon "trash")) "Commands offered for a buffer, as (LABEL FUNCTION . PROPERTIES). A buffer page's menu offers them all; FUNCTION is called with the buffer. LABEL is a string, or a function of the buffer returning one, or nil to leave the command out for that buffer. When FUNCTION returns another buffer the phone opens its page; when the buffer is gone after, the phone goes home. A FUNCTION with an `emacs-web-phone' property is done by the phone instead, as copying the buffer's link or name is. PROPERTIES is a plist: :confirm the question asked before running, a string or a function of the buffer giving one or nil :input the question asking for text, which FUNCTION then gets after the buffer :anyway non-nil to run it even once LABEL leaves it out, as a page shown before the buffer changed still offers it :note a word shown beside the label, a string or a function of the buffer giving one or nil :swipe `left' or `right': the command also shows when a row of the list is swiped that way, as a pill :swipe-index where on that side, the greater the nearer the edge; swiping all the way runs the one nearest it :swipe-label the pill's text, as LABEL is, LABEL if not given :swipe-color the pill's colour, a CSS colour :swipe-icon the name of an icon in `emacs-web-icons', or a function of the buffer giving one: the pill is then a round button showing the icon, its text under it" :type '(repeat (cons (choice string function) (cons function plist)))) (defcustom emacs-web-term-replacements '(("⏺" . ".")) "Text a terminal's page shows otherwise, as (FROM . TO). For characters a phone draws out of place, as an emoji, say." :type '(alist :key-type string :value-type string)) (defcustom emacs-web-display-alist '(((derived-mode . calendar-mode) (min-columns . 76)) ((derived-mode . tabulated-list-mode) (rows . t))) "How buffers' pages show them, much as `display-buffer-alist' places buffers. Each entry is (CONDITION . PROPERTIES): CONDITION as `buffer-match-p' takes it, PROPERTIES an alist; the first entry whose condition matches a buffer gives its properties. They are: `min-columns' columns the buffer needs: its page keeps lines whole and shrinks the text until that many fit. `rows' non-nil for a tabulated list to show as rows to tap, each entry's first column over its others; a tap presses RET on that entry." :type '(alist :key-type sexp :value-type (alist :key-type symbol :value-type sexp))) (defun emacs-web--display (buffer property) "Return PROPERTY of how BUFFER shows, from `emacs-web-display-alist'." (alist-get property (cdr (seq-find (lambda (entry) (buffer-match-p (car entry) buffer)) emacs-web-display-alist)))) (defcustom emacs-web-transcribe-at "" "Which Emacs turns dictation into text, as a link relative to the home page. Empty for this one; \"../\" from the M4 Pro's pages sends it to the Mac mini's, so only the Mini needs Whisper." :type 'string) (defcustom emacs-web-transcribe-prompt "Talking about Emacs and Elisp: buffers, the minibuffer, major and \ minor modes, Org mode, Magit, Ghostel, Claude Code, OpenCode, defun, \ setq, let, lambda, use-package, hooks, advice, keybindings like M-x, \ C-c and C-x C-f, init.el, emacsclient, VELPA and Vulpea." "Text Whisper is told it has just heard, so it expects these words. It steers spelling and vocabulary, not what was said; keep it short, as only its last couple of hundred words count. It is English, so it is given only for English speech: for other languages it would turn what was said into English." :type 'string) (defcustom emacs-web-title "Emacs" "Name of this Emacs, heading its home page. Worth setting where the app reaches several, to tell them apart." :type 'string) (defcustom emacs-web-computers nil "The Emacs instances the home page offers as tabs, as (NAME . LINK). LINK is relative to this home page, like \"pro/\" or \"../\". This Emacs is the entry named `emacs-web-title', linked \"./\"; listed or not, it has a tab, first unless listed elsewhere." :type '(alist :key-type string :value-type string)) (defcustom emacs-web-claude-dir "~/.claude" "Where Claude Code keeps its sessions and their transcripts." :type 'directory) (defcustom emacs-web-claude-resume-function nil "Function resuming a closed Claude Code session, or nil. It is called with the session's directory, id and name, and returns the buffer of the terminal it goes on in." :type '(choice (const nil) function)) (defcustom emacs-web-claude-archive-file (locate-user-emacs-file "emacs-web-claude-archive.eld") "File keeping the ids of archived Claude Code sessions. An archived session is left out of the sessions offered to resume; its transcript stays, and `claude --resume' still takes it." :type 'file) (defcustom emacs-web-claude-closed-count 20 "How many of the latest closed Claude Code sessions are offered to resume." :type 'natnum) (defvar emacs-web-after-send-functions nil "Functions called in a buffer after text sent from the phone went into it. Each is called with where the text starts. Point is on the line after the text, and windows showing the buffer move to where they leave it. `emacs-web-sent-columns' is how wide the phone shows the buffer.") (defvar emacs-web-sent-columns nil "How many columns the page that sent the text shows in a line, or nil. It is set while `emacs-web-after-send-functions' run.") (defcustom emacs-web-pi-dir "~/.pi/agent" "Where Pi keeps its sessions." :type 'directory) (defcustom emacs-web-opencode-url "http://127.0.0.1:4096" "Where the OpenCode server that terminals attach to listens." :type 'string) (defcustom emacs-web-chat-inline-filters '(emacs-web--md-code emacs-web--md-strong emacs-web--md-em emacs-web--md-link emacs-web--md-autolink) "Functions marking up a line of a chat message, in order. Each takes the line as HTML and returns it as HTML; the first gets it escaped, each next one what the one before returned. To change only plain text, leaving code and links be, use `emacs-web--md-replace'." :type '(repeat function)) (defcustom emacs-web-chat-items 200 "Most entries of a conversation a chat page shows." :type 'integer) (defcustom emacs-web-busy-seconds 3 "A terminal that printed within this many seconds counts as busy." :type 'number) (defcustom emacs-web-upload-directory (file-name-as-directory (expand-file-name "emacs-web/uploads" (or (getenv "XDG_DATA_HOME") "~/.local/share"))) "Directory holding the files uploaded from the phone. Each is served at u/NAME to anyone who has the link." :type 'directory) (defcustom emacs-web-pins-file (expand-file-name "emacs-web/pins.eld" (or (getenv "XDG_STATE_HOME") "~/.local/state")) "File keeping which buffers are pinned." :type 'file) (defvar emacs-web--pins nil "Keys of the pinned buffers, as `emacs-web--pin-key' makes them.") (defvar emacs-web--pins-loaded nil "Non-nil once `emacs-web--pins' holds what the pins file says.") (defvar emacs-web--render-frame nil "An invisible frame for bringing hidden terminals up to date, or nil.") (defvar emacs-web--activity (make-hash-table :test #'eq :weakness 'key) "For each process buffer seen, (TICK . TIME): its text's last tick and when that tick was first seen, nil when it has not changed since.") (defvar emacs-web--activity-timer nil "Timer noting when process buffers change, or nil.") (defvar emacs-web--resized nil "Terminals sized for a phone while Emacs shows them, to give back.") (defvar emacs-web--process nil "The running server, or nil.") (defvar emacs-web--shared-process nil "The running server of the shared buffers, or nil.") (defvar emacs-web--shared nil "Non-nil while answering a request to the server of the shared buffers.") ;;;; Buffers (defun emacs-web--visible-p (buffer) "Non-nil if BUFFER belongs on the index. Buffers whose names start with a space are internal to Emacs." (not (string-prefix-p " " (buffer-name buffer)))) (defun emacs-web--buffers () "Return the buffers the index lists, most recently used first." (seq-filter #'emacs-web--visible-p (buffer-list))) (defun emacs-web--id (buffer) "Return the id BUFFER is linked by, or nil if it is linked by name only." (with-current-buffer buffer (when (apply #'derived-mode-p emacs-web-id-modes) (or emacs-web--buffer-id (setq emacs-web--buffer-id (let (id) (while (or (null id) (emacs-web--find-id id)) (setq id (format "%08x" (random #x100000000)))) id)))))) (defun emacs-web--find-id (id) "Return the live buffer linked by ID, or nil." (seq-find (lambda (b) (equal id (buffer-local-value 'emacs-web--buffer-id b))) (buffer-list))) (defun emacs-web--pin-key (buffer) "Return the key BUFFER is pinned under: its id, or else its name." (if-let* ((id (emacs-web--id buffer))) (concat "i:" id) (concat "b:" (buffer-name buffer)))) (defun emacs-web--load-pins () "Return the pinned keys, reading the pins file the first time." (unless emacs-web--pins-loaded (setq emacs-web--pins (ignore-errors (with-temp-buffer (insert-file-contents emacs-web-pins-file) (read (current-buffer)))) emacs-web--pins-loaded t)) emacs-web--pins) (defun emacs-web--pinned-p (buffer) "Non-nil if BUFFER is pinned." (member (emacs-web--pin-key buffer) (emacs-web--load-pins))) (defun emacs-web--set-pinned (buffer pinned) "Pin BUFFER if PINNED is non-nil, else unpin it, and save the pins. Pins by id die with their buffer, so they are dropped then." (let ((key (emacs-web--pin-key buffer))) (setq emacs-web--pins (seq-filter (lambda (k) (or (string-prefix-p "b:" k) (emacs-web--find-id (substring k 2)))) (if pinned (cons key (delete key (emacs-web--load-pins))) (delete key (emacs-web--load-pins))))) (make-directory (file-name-directory emacs-web-pins-file) t) (with-temp-file emacs-web-pins-file (prin1 emacs-web--pins (current-buffer))))) (defun emacs-web--note-activity (&optional now) "Note which process buffers changed since last time, as of NOW." (let ((now (or now (current-time)))) (dolist (proc (process-list)) (when-let* ((buffer (process-buffer proc)) ((buffer-live-p buffer))) (let ((tick (buffer-chars-modified-tick buffer)) (seen (gethash buffer emacs-web--activity))) (unless (eql tick (car seen)) ;; a buffer seen for the first time has no known last change (puthash buffer (cons tick (and seen now)) emacs-web--activity))))))) (defun emacs-web--last-output (buffer) "Return when BUFFER's process last printed, or nil if not known." (if (with-current-buffer buffer (or (derived-mode-p 'ghostel-mode) (local-variable-p 'ghostel--last-output-time))) ;; a terminal's text changes only when it is redrawn (buffer-local-value 'ghostel--last-output-time buffer) (cdr (gethash buffer emacs-web--activity)))) (defun emacs-web--program-status (buffer) "Return the program status ghostel shows for BUFFER, or nil. It is `busy' while the program works, else `idle', `done', `blocked' or `error' as the program reported." (when (fboundp 'ghostel-status--shown) (pcase (with-current-buffer buffer (plist-get (cdar (ghostel-status--shown)) :state)) ('working 'busy) ((and (or 'idle 'done 'blocked 'error) state) state)))) (defun emacs-web--process-state (buffer &optional now) "Return the state of BUFFER's process, or nil if it has none. The state is `exited' once a process seen in it has ended. While it lives, a terminal program's own status wins; otherwise it is `busy' if it printed within `emacs-web-busy-seconds' before NOW, else `idle'." (let ((proc (get-buffer-process buffer))) (cond ((null proc) ;; Emacs forgets a process once it ends; the buffer had one (and (gethash buffer emacs-web--activity) 'exited)) ((memq (process-status proc) '(exit signal closed failed)) 'exited) ((emacs-web--program-status buffer)) ((when-let* ((last (emacs-web--last-output buffer))) (< (float-time (time-subtract (or now (current-time)) last)) emacs-web-busy-seconds)) 'busy) (t 'idle)))) (defun emacs-web--mode-line (buffer) "Return the modes part of BUFFER's mode line as plain text." (or (ignore-errors (string-trim (replace-regexp-in-string (rx (+ (any " \t"))) " " (substring-no-properties (format-mode-line mode-line-modes nil nil buffer))))) "")) (defun emacs-web--vc (buffer) "Return what BUFFER's mode line says of its version control, or nil. For Git it reads like Git-main, with a colon for Git:main once the file has changes not yet committed." (when-let* ((vc (buffer-local-value 'vc-mode buffer))) (let ((text (ignore-errors (string-trim (substring-no-properties (format-mode-line vc nil nil buffer)))))) (and text (not (string-empty-p text)) text)))) (defun emacs-web--infos () "Return descriptions of the buffers the index lists." (mapcar #'emacs-web--describe (emacs-web--buffers))) (defun emacs-web--describe (buffer) "Return a plist describing BUFFER for the index." (with-current-buffer buffer (list :name (buffer-name) :buffer buffer :id (emacs-web--id buffer) :mode (format-mode-line mode-name) :modeline (emacs-web--mode-line buffer) :vc (emacs-web--vc buffer) :process (emacs-web--process-state buffer) :agent (emacs-web--agent buffer) :major (string-remove-suffix "-mode" (symbol-name major-mode)) :size (buffer-size) :time buffer-display-time :pinned (and (emacs-web--pinned-p buffer) t) :modified (and buffer-file-name (buffer-modified-p) t) :file (or buffer-file-name (and (derived-mode-p 'dired-mode) (bound-and-true-p dired-directory) (format "%s" dired-directory)))))) ;;;; HTML (defun emacs-web--escape (string) "Return STRING with the characters HTML gives meaning escaped." (replace-regexp-in-string (rx (any "&<>\"")) (lambda (c) (pcase c ("&" "&") ("<" "<") (">" ">") (_ """))) string t t)) (defun emacs-web--href (info) "Return the link to the page of the buffer described by INFO. The link is relative to the index." (concat "b/" (url-hexify-string (plist-get info :name)) (when-let* ((id (plist-get info :id))) (concat "/i/" id)))) (defun emacs-web--link (buffer) "Return the link to BUFFER's page, relative to the index." (emacs-web--href (list :name (buffer-name buffer) :id (emacs-web--id buffer)))) (defun emacs-web--up (id) "Return the relative link to the index from a buffer page. ID is the id in the page's link, or nil." (if id "../../../" "../")) (defun emacs-web--size (n) "Return N characters as a short human-readable size." (cond ((< n 1000) (format "%d" n)) ((< n 1000000) (format "%.1fk" (/ n 1000.0))) (t (format "%.1fM" (/ n 1000000.0))))) (defun emacs-web--date (time &optional now) "Return TIME as a row shows it: the clock today, the date before. NOW defaults to the current time." (let ((now (or now (current-time)))) (if (equal (format-time-string "%F" time) (format-time-string "%F" now)) (format-time-string "%H:%M" time) (format-time-string "%d/%m/%Y" time)))) (defun emacs-web--section (time &optional now) "Return the heading of the section a buffer last shown at TIME goes in. TIME may be nil, for a buffer never shown. NOW defaults to the current time." (let* ((now (or now (current-time))) (today (let ((d (decode-time now))) (encode-time (list 0 0 0 (nth 3 d) (nth 4 d) (nth 5 d) nil -1 (nth 8 d))))) (days (and time (/ (float-time (time-subtract today time)) 86400)))) (cond ((null time) "Earlier") ((<= days 0) "Today") ((<= days 7) "Previous 7 Days") ((<= days 30) "Previous 30 Days") (t "Earlier")))) (defconst emacs-web--sections '("Today" "Previous 7 Days" "Previous 30 Days" "Earlier") "Section headings of a list of buffers, in the order they appear.") (defconst emacs-web--style ":root{color-scheme:light dark;--bg:#f2f2f7;--card:#fff;--text:#000;--sub:#8a8a8e;--sep:#c6c6c8;--edge:#8e8e93;--press:#e5e5ea;--pill:rgba(255,255,255,.72)} @media (prefers-color-scheme:dark){:root{--bg:#000;--card:#1c1c1e;--text:#fff;--sub:#98989f;--sep:#38383a;--edge:#636366;--press:#2c2c2e;--pill:rgba(44,44,46,.72)}} *{box-sizing:border-box;-webkit-tap-highlight-color:transparent} [hidden]{display:none!important} html{overscroll-behavior-y:none;touch-action:manipulation} .ptr{position:fixed;left:50%;z-index:5;width:28px;height:28px;margin-left:-14px;border:2.5px solid var(--sub);border-top-color:transparent;border-radius:50%;opacity:0;pointer-events:none} .ptr.top{top:calc(env(safe-area-inset-top) + 14px)} .ptr.bottom{bottom:calc(env(safe-area-inset-bottom) + 24px)} body:has(.dock) .ptr.bottom{bottom:calc(env(safe-area-inset-bottom) + 130px)} .ptr.armed{border-color:var(--text);border-top-color:transparent} .ptr.spin{opacity:1;animation:ptr .8s linear infinite} @keyframes ptr{to{transform:rotate(360deg)}} body::before{content:\"\";position:fixed;top:0;left:0;right:0;height:env(safe-area-inset-top);background:var(--bg);z-index:4} body{margin:0;background:var(--bg);color:var(--text);font:17px/1.3 -apple-system,BlinkMacSystemFont,system-ui,\"Helvetica Neue\",sans-serif;-webkit-font-smoothing:antialiased} main{max-width:720px;margin:0 auto;padding:0 0 calc(110px + env(safe-area-inset-bottom))} a{color:inherit;text-decoration:none} .bar{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:12px;min-height:84px;padding:calc(28px + env(safe-area-inset-top)) 16px 12px} .back{flex:none;display:grid;place-items:center;width:48px;height:48px;border-radius:50%;background:var(--pill);-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);border:1px solid var(--edge);padding-right:2px} .back:active{background:var(--press)} .titlebar{position:relative;display:flex;align-items:center;gap:12px;margin-right:16px} .titlebar h1{flex:1;min-width:0} .more{flex:none;display:grid;place-items:center;width:44px;height:44px;margin-top:6px;border-radius:50%;border:1px solid var(--edge);background:var(--pill);-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);color:var(--text);cursor:pointer} .bar .more{margin:0 0 0 auto} .bar .menu{top:calc(100% - 6px);right:16px} .menu{position:absolute;top:56px;right:0;z-index:6;min-width:240px;max-width:calc(100vw - 32px);border-radius:14px;background:var(--card);box-shadow:0 10px 40px rgba(0,0,0,.35);overflow:hidden;border:1px solid var(--edge)} .menu button{display:block;width:100%;padding:13px 16px;border:0;border-top:.5px solid var(--sep);background:none;color:var(--text);font:inherit;text-align:left;cursor:pointer} .menu button:first-child{border-top:0} .menu button:active{background:var(--press)} .menu button.busy{color:var(--sub)} .menu .ps{margin-left:8px} h1.computers{white-space:nowrap;overflow-x:auto;scrollbar-width:none} h1.computers .other{margin-left:10px;color:var(--sub)} h1.computers .other.down{opacity:.45} h1.computers .other.up::after{content:\"\";display:inline-block;width:9px;height:9px;margin-left:5px;border-radius:50%;background:#30d158;vertical-align:super} .bar .name{flex:1;min-width:0;padding:0 8px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} h1{font-size:34px;font-weight:700;letter-spacing:.01em;margin:6px 20px 0} .count{color:var(--sub);margin:4px 20px 0} h2{font-size:22px;font-weight:700;margin:30px 20px 10px} .folders{margin-top:30px} .card{background:var(--card);border-radius:26px;margin:0 16px;overflow:hidden;border:1px solid var(--edge)} h1+.card,.count+.card{margin-top:24px} .row{position:relative;display:flex;align-items:center;gap:10px;min-height:56px;padding:12px 24px} .row+.row::before,.sw+.sw>.row::before{content:\"\";position:absolute;top:0;left:24px;right:24px;border-top:.5px solid var(--sep)} .sw.open>.row::before,.sw.drag>.row::before,.sw.open+.sw>.row::before,.sw.drag+.sw>.row::before{display:none} .sw{position:relative} .sw>.row{z-index:1;background:var(--card);touch-action:pan-y;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none;transition:transform .3s cubic-bezier(.2,.9,.3,1),border-radius .3s,background .3s} .sw.drag>.row{transition:none} .sw.open>.row,.sw.drag>.row{background:var(--press);border-radius:26px} .sw.open>.row:hover,.sw.drag>.row:hover{background:var(--press)} .acts{position:absolute;top:0;bottom:0;display:flex;align-items:center;gap:8px;padding:0 10px} .acts.lead{left:0}.acts.trail{right:0} .acts button{border:0;background:none;padding:0;color:#fff;font:inherit;cursor:pointer} .acts .p{display:grid;place-items:center;min-width:64px;height:40px;padding:0 12px;border-radius:20px;font-size:15px;font-weight:600;white-space:nowrap} .acts .p{transition:transform .15s} .sw.full .acts .p{transform:scale(1.12)} .acts .p{background:var(--c,#636366)} .acts .i{display:flex;flex-direction:column;align-items:center;gap:3px;width:52px} .acts .i .p{min-width:0;width:44px;height:44px;padding:0;border-radius:50%} .acts .l{font-size:12px;font-weight:500;color:var(--text);white-space:nowrap} .row:active,.row:hover{background:var(--press)} .row .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .row .n{color:var(--sub)} .row .c{color:var(--sub);font-size:22px;line-height:1;opacity:.7} .lines .row{flex-direction:column;align-items:stretch;justify-content:center;gap:2px;width:100%;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer} .lines .row .t{flex:none} .lines .row .n{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .lines .row.at{background:var(--press)} .buf{display:block;padding:14px 24px 14px 16px} .buf .h{display:flex;align-items:baseline;gap:10px} .buf.agent{padding-right:64px} .sw+.sw>.buf::before{left:16px}.sw+.sw>.buf.agent::before{right:0} .buf .chat{position:absolute;top:0;right:0;bottom:0;width:64px;display:grid;place-items:center;border-left:.5px solid var(--sep);color:#0a84ff;font-size:15px;font-weight:600} .buf .chat:active{background:var(--press)} .log{margin:16px 16px 0;font-size:19px} .msg{margin:14px 0;line-height:1.45;overflow-wrap:anywhere} .msg.you{background:var(--card);border-radius:18px;padding:8px 14px;margin-left:12%;border:1px solid var(--edge)} .msg.you.pending{opacity:.55;border-style:dashed} .msg p{margin:.45em 0}.msg h4{margin:.9em 0 .3em;font-size:19px}.msg ul,.msg ol{margin:.4em 0;padding-left:1.3em} .msg a{color:#0a84ff} .msg code{font:.86em ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--press);border-radius:5px;padding:1px 4px} .tool pre.cmd{color:var(--sub)} .msg .tbl{overflow-x:auto;margin:8px 0} .msg table{border-collapse:collapse;font-size:15px} .msg th,.msg td{border:1px solid var(--sub);padding:4px 8px;text-align:left;vertical-align:top;overflow-wrap:normal} .msg th{background:var(--card);white-space:nowrap} .msg pre,.tool pre{margin:8px 0;background:var(--card);border-radius:12px;padding:10px 12px;font:14px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere;max-height:50vh;overflow:auto} .tool{margin:6px 0;color:var(--sub);font-size:17px} .event{margin:18px 0;color:var(--sub);font-size:15px;text-align:center} .tool summary{cursor:pointer;list-style:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tool summary::-webkit-details-marker{display:none} .tool summary b{color:var(--text);font-weight:600} .tool.mcp summary b{color:#0a84ff} .tool summary span{font:15px ui-monospace,SFMono-Regular,Menlo,monospace} .tool.err summary b{color:#ff453a} .msg.ask .q{background:var(--card);border-radius:18px;padding:10px 14px;margin:10px 0;border:1px solid var(--edge)} .msg.ask .hd{display:inline-block;margin-top:4px;color:var(--sub);font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.04em} .msg.ask .opts{list-style:none;margin:8px 0 4px;padding:0} .msg.ask .opts li{margin:6px 0;padding:8px 12px;border:1px solid var(--edge);border-radius:12px} .msg.ask .opts li.on{border-color:#0a84ff;background:color-mix(in srgb,#0a84ff 14%,transparent)} .msg.ask .opts b{font-weight:600} .msg.ask .opts span{display:block;color:var(--sub);font-size:15px;line-height:1.35} .msg.ask .own{border-left:3px solid #0a84ff;padding-left:10px} .msg.ask .wait{color:var(--sub);font-size:15px} .buf .t{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;font-weight:600;white-space:normal;overflow:hidden;overflow-wrap:anywhere;line-height:1.25} .ps{flex:none;font-size:13px;font-weight:600;color:var(--sub)} .ps::before{content:\"\";display:inline-block;width:8px;height:8px;margin-right:5px;border-radius:50%;background:currentColor;vertical-align:1px} .ps.busy{color:#30d158}.ps.busy::before{animation:ps 1s ease-in-out infinite alternate} .ps.exited,.ps.error{color:#ff453a}.ps.unsaved,.ps.blocked{color:#ff9f0a}.ps.done{color:#0a84ff} @keyframes ps{to{opacity:.25}} .buf .s{display:block;margin-top:3px;color:var(--sub);font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .buf .s span+span{margin-left:10px} .buf .s .ml{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px} .search{position:fixed;left:0;right:0;bottom:calc(16px + max(env(safe-area-inset-bottom),var(--kb,0px)));z-index:3;max-width:720px;margin:0 auto;padding:0 16px} .search input{width:100%;height:50px;border:1px solid var(--edge);border-radius:25px;padding:0 22px;background:var(--pill);-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);color:var(--text);font:inherit;outline:none;-webkit-appearance:none} .search input::placeholder{color:var(--sub)} .empty{color:var(--sub);text-align:center;margin:40px 20px} h1.doc{font-size:30px;line-height:1.15;margin:28px 20px 20px;overflow-wrap:anywhere} .code{overflow:hidden} .dock{position:fixed;left:0;right:0;bottom:calc(12px + max(env(safe-area-inset-bottom),var(--kb,0px)));z-index:3;max-width:720px;margin:0 auto;padding:0 16px} .compose{display:flex;align-items:flex-end;gap:10px} .keys{display:flex;overflow:hidden;margin:0 0 10px;border:1px solid var(--edge);border-radius:18px;background:var(--pill);-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px)} .keys button{flex:1 1 0;min-width:0;height:40px;padding:0;border:0;border-left:.5px solid var(--sep);background:none;color:var(--text);font:inherit;font-size:16px;font-weight:600;cursor:pointer} .keys button:first-child{border-left:0} .keys button:active{background:var(--press)} body:has(.dock) main{padding-bottom:calc(170px + env(safe-area-inset-bottom))} .code.term pre{width:calc(var(--cols,80) * 1ch + 12px);max-width:100%;white-space:pre-wrap;word-break:break-all;overflow-wrap:anywhere;padding:6px;font-size:14px;line-height:1.2} .compose .field{position:relative;flex:1;min-width:0;display:flex} .compose textarea{flex:1;min-width:0;min-height:50px;max-height:40vh;resize:none;border:1px solid var(--edge);border-radius:25px;padding:14px 22px;background:var(--pill);-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);color:var(--text);font:inherit;line-height:1.3;outline:none;-webkit-appearance:none} .compose textarea::placeholder{color:var(--sub)} .field:has(.mic:not([hidden])) textarea{padding-right:52px} .field:has(.clear:not([hidden])) textarea{padding-right:52px} .field:has(.mic:not([hidden])):has(.clear:not([hidden])) textarea{padding-right:84px} .compose .clear{position:absolute;right:12px;bottom:12px;display:grid;place-items:center;width:26px;height:26px;padding:0;border:0;border-radius:50%;background:none;color:var(--sub);opacity:.55;cursor:pointer} .field:has(.mic:not([hidden])) .clear{right:46px} .compose .mic{position:absolute;right:6px;bottom:6px;display:grid;place-items:center;width:38px;height:38px;padding:0;border:0;border-radius:50%;background:none;color:var(--sub);cursor:pointer} .compose .mic.on{color:#fff;background:#ff453a;animation:ps 1s ease-in-out infinite alternate} .compose .mic.busy{color:var(--text);background:var(--press);animation:ps .6s ease-in-out infinite alternate} .compose .send{flex:none;display:grid;place-items:center;height:50px;width:50px;padding:0;border:1px solid var(--edge);border-radius:25px;background:var(--pill);-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);color:var(--text);font:inherit;font-weight:600;cursor:pointer} .compose button:disabled{opacity:.5} .code pre{margin:0;padding:16px 18px;font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere} .code.fixed pre{white-space:pre;overflow-wrap:normal;overflow-x:auto;padding:16px 12px;font-size:min(13px,calc((min(100vw,720px) - 24px) / var(--mincols) / .63))} .note{color:var(--sub);font-size:15px;margin:0 20px 12px} .cc{opacity:.5} dialog.dlg{position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:0;border:0;background:transparent;color:var(--text);overflow:hidden} dialog.dlg:focus{outline:none} dialog.dlg::backdrop{background:rgba(0,0,0,.45)} dialog.dlg .sheet{position:absolute;left:0;right:0;top:var(--top,calc(env(safe-area-inset-top) + 92px));width:min(680px,calc(100vw - 24px));max-height:calc(100dvh - var(--top,calc(env(safe-area-inset-top) + 92px)) - var(--kb,0px) - 16px);margin:0 auto;display:flex;flex-direction:column;padding:14px 16px 12px;box-sizing:border-box;border:1px solid var(--edge);border-radius:22px;background:var(--card);box-shadow:0 12px 40px rgba(0,0,0,.4);overscroll-behavior:contain} dialog.dlg>.more,dialog.dlg>.menu{position:absolute;margin:0} dialog.dlg .q{flex:none;font-weight:600;margin-bottom:10px;overflow-wrap:anywhere} dialog.dlg pre{flex:0 1 auto;min-height:0;margin:0 0 10px;overflow:auto;font:13px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere} dialog.dlg input{flex:none;width:100%;height:44px;margin-bottom:10px;padding:0 14px;border:1px solid var(--edge);border-radius:12px;background:var(--bg);color:var(--text);font:inherit;outline:none;box-sizing:border-box} dialog.dlg .list{flex:1 1 auto;min-height:0;margin:0 -16px 10px;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;border-top:.5px solid var(--sep)} dialog.dlg .list button{display:block;width:100%;padding:11px 16px;border:0;border-bottom:.5px solid var(--sep);background:none;color:var(--text);font:inherit;text-align:left;cursor:pointer} dialog.dlg .list button:active{background:var(--press)} dialog.dlg .list small{display:block;margin-top:2px;color:var(--sub);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} dialog.dlg .answers{flex:none;display:flex;gap:10px} dialog.dlg .answers button{flex:1;height:44px;border:0;border-radius:12px;background:var(--press);color:var(--text);font:inherit;font-weight:600;cursor:pointer} dialog.dlg .answers button.ok{background:#0a84ff;color:#fff} .pt{position:relative;display:inline-block;width:0;height:1em;vertical-align:baseline} .pt::after{content:\"\";position:absolute;left:0;bottom:-.25em;width:1ch;height:1.3em;opacity:.7;border-radius:1px;animation:pt 1.1s steps(2,jump-none) infinite} @keyframes pt{50%{opacity:0}}" "Style shared by every page.") (defconst emacs-web--dialog-script "
'; const mb=d.querySelector('.more'),mm=d.querySelector('.menu'); mb.addEventListener('click',e=>{e.stopPropagation();mm.hidden=!mm.hidden}); d.addEventListener('click',e=>{if(!mm.hidden&&!mm.contains(e.target)&&e.target!==mb)mm.hidden=true},true); document.body.append(d); d.addEventListener('cancel',e=>{e.preventDefault();d.done&&d.done(null)}); d.addEventListener('click',e=>{if(e.target===d&&d.done)d.done(null)}); d.querySelector('form').addEventListener('submit',e=>{e.preventDefault();d.done&&d.done({button:'ok',text:d.querySelector('input').value})})} // one replaced by another resolves to undefined, as it was not answered if(d.done)d.done(undefined); const q=d.querySelector('.q'),pre=d.querySelector('pre'),inp=d.querySelector('input'),list=d.querySelector('.list'),ans=d.querySelector('.answers'), choices=o.choices||[]; q.textContent=o.title||'';q.hidden=!o.title; pre.textContent=o.text||'';pre.hidden=!o.text; inp.hidden=!(o.input||choices.length);inp.type=o.password?'password':'text';inp.value=o.value||''; inp.placeholder=choices.length?'Filter':''; const show=()=>{const ws=inp.value.toLowerCase().split(' ').filter(Boolean); list.replaceChildren(...choices.filter(c=>ws.every(w=>(c.label+' '+(c.note||'')).toLowerCase().includes(w))).slice(0,300).map(c=>{ const b=document.createElement('button');b.type='button';b.append(c.label); if(c.note){const n=document.createElement('small');n.textContent=c.note;b.append(n)} b.addEventListener('click',()=>d.done&&d.done({choice:c.label}));return b}))}; list.hidden=!choices.length;inp.oninput=choices.length?show:null;if(choices.length)show();else list.replaceChildren(); ans.replaceChildren(...(o.buttons||[{label:'OK',value:'ok',primary:true}]).map(x=>{const b=document.createElement('button'); b.type='button';b.textContent=x.label;if(x.primary)b.className='ok'; b.addEventListener('click',()=>d.done&&d.done({button:x.value,text:inp.value}));return b})); // it opens under the page's round menu button, which offers what may be // done with the question in place of the page's actions const more=document.querySelector('main .more,.bar .more'),r=more&&more.getBoundingClientRect(),top=r?Math.round(r.bottom)+12:0; d.style.setProperty('--top',top>12?top+'px':''); const mb=d.querySelector('.more'),mm=d.querySelector('.menu'),acts=o.actions||[];mm.hidden=true; mb.hidden=!(acts.length&&top>12); if(!mb.hidden){mb.innerHTML=more.innerHTML; mb.style.cssText=`top:${r.top}px;left:${r.left}px`; mm.style.cssText=`top:${r.bottom+6}px;right:${innerWidth-r.right}px`; mm.replaceChildren(...acts.map(a=>{const b=document.createElement('button');b.type='button';b.textContent=a.label; b.addEventListener('click',()=>d.done&&d.done({action:a.index}));return b}))} return new Promise(res=>{d.done=v=>{d.done=null;if(d.open)d.close();res(v)}; d.showModal();if(!inp.hidden&&!choices.length)inp.focus();else d.focus()})} const emacsWebAlert=text=>emacsWebDialog({text}); const emacsWebConfirm=title=>emacsWebDialog({title,buttons:[{label:'Cancel',value:'cancel'},{label:'OK',value:'ok',primary:true}]}) .then(r=>!!r&&r.button==='ok'); const emacsWebAsk=title=>emacsWebDialog({title,input:true,buttons:[{label:'Cancel',value:'cancel'},{label:'OK',value:'ok',primary:true}]}) .then(r=>r&&r.button==='ok'?r.text:null); \n" "Script with the dialog a page asks and tells in, and its uses.") (defconst emacs-web--prompt-script "\n" "Script showing what Emacs asks in its minibuffer, and answering it.") (defconst emacs-web--back-script "\n" "Script taking the back button to the previous page, or to its link when there is none to go back to.") (defun emacs-web--page (root title body &optional script) "Return a whole page titled TITLE showing BODY, with SCRIPT after it. ROOT is the relative link to the index from the page." (concat "\n\n\n\n" "\n" "" (emacs-web--escape title) "\n" "\n" "\n" "\n" "\n" "\n" "\n" "\n" "\n" "\n" emacs-web--back-script emacs-web--dialog-script "\n\n
\n" body "\n
\n" ;; Emacs's prompts are the owner's to answer (unless emacs-web--shared (concat (format "\n" (emacs-web--escape root)) emacs-web--prompt-script)) (or script "") "\n\n")) (defun emacs-web--back (up) "Return the back button, leading to UP when there is no page to go back to." (format "" up)) (defconst emacs-web--search "
" "The search box floating at the bottom of a list.") (defconst emacs-web--refresh-script "\n" "Script bringing a buffer page's text up to date in place.") (defconst emacs-web--rows-script "\n" "Script making a page of rows act on taps.") (defconst emacs-web--pull-script "\n" "Script updating the page when it is pulled past its top, or past its end when it shows a buffer.") (defconst emacs-web--search-script "\n" "Script narrowing a list to the rows matching the search box. Where the page has a results section, as the home page has, that alone shows while there is something to search for.") (defconst emacs-web--swipe-script "\n" "Script letting buffer rows swipe open onto their actions and run them.") (defun emacs-web--modes (infos) "Return the major modes of INFOS with their counts, commonest first." (let (counts) (dolist (info infos) (let ((cell (assoc (plist-get info :major) counts))) (if cell (cl-incf (cdr cell)) (push (cons (plist-get info :major) 1) counts)))) (sort (nreverse counts) (lambda (a b) (> (cdr a) (cdr b)))))) (defun emacs-web--folder-row (href label &optional count) "Return a row leading to HREF, called LABEL, holding COUNT buffers if given." (format "%s%s›" (emacs-web--escape href) (emacs-web--escape (downcase label)) (emacs-web--escape label) (if count (format "%d" count) ""))) (defun emacs-web--buffer-section (heading infos root) "Return a section titled HEADING of the buffers described by INFOS. ROOT is the relative link to the index. Return nil when INFOS is empty." (when infos (concat "
\n

" heading "

\n
" (mapconcat (lambda (i) (emacs-web--buffer-row i root)) infos "\n") "
\n
\n"))) (defun emacs-web--menu (items root api) "Return a button opening a menu of ITEMS, and the menu, or nil. ITEMS are (INDEX LABEL CONFIRM PHONE INPUT NOTE); choosing one posts its index to API, with the text asked for by INPUT if given, or, with PHONE, has the page do that itself. NOTE, if given, shows beside LABEL. ROOT is the relative link to the index from the page." (when items (concat "" (format ""))) (defun emacs-web--action-prop (action prop) "Return PROP of ACTION, an entry of `emacs-web-buffer-actions'." (plist-get (nthcdr 2 action) prop)) (defun emacs-web-copy-link (_buffer) "Copy the buffer's link; done on the phone, which has the clipboard." nil) (put 'emacs-web-copy-link 'emacs-web-phone "link") (defun emacs-web-unsaved-note (buffer) "Return \"unsaved\" if BUFFER has changes to save to its file." (and (buffer-file-name buffer) (buffer-modified-p buffer) "unsaved")) (defun emacs-web-copy-name (_buffer) "Copy the menu item's label, the buffer's name; done on the phone." nil) (put 'emacs-web-copy-name 'emacs-web-phone "copy") (defun emacs-web-upload (_buffer) "Upload a file the phone picks; the phone gets its link." nil) (put 'emacs-web-upload 'emacs-web-phone "upload") (defun emacs-web--upload-name (name &optional time) "Return the name an upload called NAME is stored under. It is NAME in lower case with every run of other characters than letters and digits made a dash, its stem ending in TIME, or now, to the millisecond." (let* ((name (downcase (file-name-nondirectory (or name "")))) (clean (lambda (s) (string-trim (replace-regexp-in-string (rx (+ (not (any "a-z0-9")))) "-" s) (rx (+ "-")) (rx (+ "-"))))) (stem (funcall clean (file-name-sans-extension name))) (ext (funcall clean (or (file-name-extension name) "")))) (concat (if (string-empty-p stem) "upload" stem) "-" (format-time-string "%Y%m%dT%H%M%S.%3N" time) (if (string-empty-p ext) "" (concat "." ext))))) (defun emacs-web--upload (args) "Store the file ARGS carry in `emacs-web-upload-directory'. ARGS holds its name and its content in base64. Return the name it is stored under." (let ((data (alist-get 'data args))) (unless (stringp data) (user-error "No file")) (let ((name (emacs-web--upload-name (alist-get 'name args))) (coding-system-for-write 'binary)) (make-directory emacs-web-upload-directory t) (with-temp-file (expand-file-name name emacs-web-upload-directory) (set-buffer-multibyte nil) (insert (base64-decode-string data))) name))) (defun emacs-web--set-point (buffer pos) "Move BUFFER's point to POS where `emacs-web--point' finds it." (let ((window (if (eq (window-buffer (selected-window)) buffer) (selected-window) (get-buffer-window buffer t)))) (if window (set-window-point window pos) (with-current-buffer buffer (goto-char pos))))) (defun emacs-web-scroll-to-top (buffer) "Move BUFFER's point to its start; its page opens there." (emacs-web--set-point buffer (with-current-buffer buffer (point-min))) nil) (defun emacs-web-scroll-to-bottom (buffer) "Move BUFFER's point to its end; its page opens there." (emacs-web--set-point buffer (with-current-buffer buffer (point-max))) nil) (defun emacs-web--phone-act (action) "Return what the phone does for ACTION itself, or nil if Emacs does it." (and (symbolp (nth 1 action)) (get (nth 1 action) 'emacs-web-phone))) (defun emacs-web--resolve (spec buffer) "Return SPEC, a string or a function of BUFFER giving one, as a string." (if (functionp spec) (funcall spec buffer) spec)) (defun emacs-web--buffer-menu (buffer root) "Return the menu of `emacs-web-buffer-actions' for BUFFER's page, or nil. A shared page has none. ROOT is the relative link to the index from the page." (let ((n -1) items) (dolist (action (unless emacs-web--shared emacs-web-buffer-actions)) (cl-incf n) (when-let* ((label (emacs-web--resolve (nth 0 action) buffer))) (push (list n label (emacs-web--resolve (emacs-web--action-prop action :confirm) buffer) (emacs-web--phone-act action) (emacs-web--resolve (emacs-web--action-prop action :input) buffer) (emacs-web--resolve (emacs-web--action-prop action :note) buffer)) items))) (emacs-web--menu (nreverse items) root (concat root "api/ACT/" (emacs-web--href (emacs-web--describe buffer)))))) (defconst emacs-web--menu-script "\n" "Script opening a page's menu of actions and running what is chosen.") (defun emacs-web--title () "Return the home page's title: this computer's name, the others' beside it, and the menu of actions." (concat "
" (emacs-web--title-names) (or (emacs-web--menu (let ((n -1)) (mapcar (lambda (a) (list (cl-incf n) (car a) nil)) emacs-web-list-actions)) "" "api/run") "") "
")) (defun emacs-web--title-names () "Return the heading naming this computer, with the others beside it." (if (not emacs-web-computers) (concat "

" (emacs-web--escape emacs-web-title) "

") (let ((computers (if (assoc emacs-web-title emacs-web-computers) emacs-web-computers (cons (cons emacs-web-title "./") emacs-web-computers)))) (concat "

" (mapconcat (lambda (c) (if (equal (car c) emacs-web-title) (format "%s" (emacs-web--escape (car c))) (format "%s" (emacs-web--escape (cdr c)) (emacs-web--escape (car c))))) computers " ") "

")))) (defconst emacs-web--tabs-script "\n" "Script marking which of the other computers in the title answer.") (defun emacs-web--index-page (infos) "Return the index page: the computers in its title, then the pinned buffers of INFOS, All Buffers with the favorite modes, and the other modes." (let* ((favorites (mapcar (lambda (m) (string-remove-suffix "-mode" (symbol-name m))) emacs-web-favorite-modes)) (modes (emacs-web--modes infos)) (mode-row (lambda (m) (emacs-web--folder-row (concat "m/" (url-hexify-string (car m))) (car m) (cdr m)))) (others (seq-remove (lambda (m) (member (car m) favorites)) modes))) (emacs-web--page "" (concat emacs-web-title " | Emacs") (concat "
\n" (emacs-web--title) "\n" (emacs-web--buffer-section "Pinned" (seq-filter (lambda (i) (plist-get i :pinned)) infos) "") "
\n
" (emacs-web--folder-row "all" "All Buffers" (length infos)) ;; in the order they are named, those with buffers (mapconcat (lambda (f) (if-let* ((m (assoc f modes))) (concat "\n" (funcall mode-row m)) "")) favorites "") "
\n
\n" "
\n

Modes

\n
" (mapconcat mode-row others "\n") "
\n
\n" ;; every buffer, shown while searching "\n" emacs-web--search) (concat emacs-web--search-script emacs-web--swipe-script emacs-web--pull-script emacs-web--tabs-script emacs-web--menu-script)))) (defun emacs-web--swipe-pills (buffer side) "Return the pills BUFFER's row shows when swiped toward SIDE. They are the `emacs-web-buffer-actions' with that :swipe, ordered so the greatest :swipe-index sits at the row's edge. A shared row has none." (let ((n -1) pills) (when (and (buffer-live-p buffer) (not emacs-web--shared)) (dolist (action emacs-web-buffer-actions) (cl-incf n) (when-let* (((eq (emacs-web--action-prop action :swipe) side)) (label (emacs-web--resolve (or (emacs-web--action-prop action :swipe-label) (nth 0 action)) buffer)) ((emacs-web--resolve (nth 0 action) buffer))) (push (list (or (emacs-web--action-prop action :swipe-index) 0) n label (emacs-web--resolve (emacs-web--action-prop action :confirm) buffer) (emacs-web--action-prop action :swipe-color) (emacs-web--phone-act action) (cdr (assoc (emacs-web--resolve (emacs-web--action-prop action :swipe-icon) buffer) emacs-web-icons))) pills)))) ;; a left swipe uncovers the right edge, where the greatest goes last (setq pills (sort pills (lambda (a b) (if (eq side 'left) (< (car a) (car b)) (> (car a) (car b)))))) (mapconcat (lambda (p) (format "" (if at " at" "") pos (emacs-web--escape name) (if (string-empty-p note) "" (format "%s" (emacs-web--escape note))))) rows "") ""))) (defun emacs-web--press-row (pos) "Press RET on the line at POS of the current buffer, as if typed there. Return what the command RET runs returns." (let ((buffer (current-buffer)) (window (emacs-web--keys-window))) (unless (<= (point-min) pos (point-max)) (user-error "No such line")) (set-window-buffer window buffer) (unwind-protect (with-selected-window window (goto-char pos) (set-window-point window pos) (let* ((command (or (key-binding (kbd "RET")) (user-error "RET does nothing here"))) (this-command command) (real-this-command command)) (call-interactively command))) (when (window-live-p window) (set-window-buffer window (get-buffer-create " *emacs-web*")))))) (defun emacs-web--scope-style (style) "Return htmlize's STYLE confined to the text it styles. htmlize writes rules for a page of its own, `body' among them." (replace-regexp-in-string (rx bol (group (* " ")) (group (not (any " \n{}")) (* (not (any "\n{}")))) " {" eol) (lambda (m) (let ((indent (match-string 1 m)) (selector (match-string 2 m))) (concat indent (if (equal selector "body") ".code pre" (concat ".code " selector)) " {"))) style t t)) (defun emacs-web--json (string) "Return STRING parsed as JSON, with objects as alists, or nil." (ignore-errors (json-parse-string string :object-type 'alist :array-type 'list :null-object nil :false-object nil))) (defun emacs-web--pid (buffer) "Return the process id of the program BUFFER's terminal runs, or nil." (and (buffer-live-p buffer) (with-current-buffer buffer (and (derived-mode-p 'ghostel-mode) (bound-and-true-p ghostel--pid))))) (defun emacs-web--claude-session (buffer) "Return what Claude Code says of the session in BUFFER's terminal, or nil. The value is an alist; its sessionId, cwd, name and status matter here." (when-let* ((pid (emacs-web--pid buffer)) (file (expand-file-name (format "sessions/%d.json" pid) emacs-web-claude-dir)) ((file-readable-p file))) (emacs-web--json (with-temp-buffer (insert-file-contents file) (buffer-string))))) (defun emacs-web--claude-transcript (session) "Return the transcript file of SESSION, an `emacs-web--claude-session'. A session may go on in a transcript its parkedJobId begins the name of, leaving the one its sessionId names behind: the one with the latest entry is the conversation." (let* ((dir (expand-file-name (format "projects/%s/" (replace-regexp-in-string (rx (not (any "A-Za-z0-9"))) "-" (alist-get 'cwd session))) emacs-web-claude-dir)) (named (expand-file-name (concat (alist-get 'sessionId session) ".jsonl") dir)) (parked (alist-get 'parkedJobId session)) (files (cons named (and (stringp parked) (file-directory-p dir) (directory-files dir t (rx bos (literal parked) (* nonl) ".jsonl" eos)))))) (car (sort files (lambda (a b) (string< (emacs-web--transcript-time b) (emacs-web--transcript-time a))))))) (defun emacs-web--transcript-time (file) "Return when the latest entry of the transcript FILE was written, or \"\". The value is an ISO 8601 time in UTC, so they compare as strings." (or (and (file-readable-p file) (let ((tail (emacs-web--file-tail file 65536)) (time nil) (start 0)) (while (string-match (rx "\"timestamp\":\"" (group (+ (not (any "\"")))) "\"") tail start) (setq time (match-string 1 tail) start (match-end 0))) time)) "")) (defun emacs-web--claude-live-transcripts () "Return the transcript files of the Claude Code sessions running now." (let ((dir (expand-file-name "sessions/" emacs-web-claude-dir))) (delq nil (mapcar (lambda (file) (when-let* ((pid (string-to-number (file-name-base file))) ((process-attributes pid)) (session (ignore-errors (emacs-web--json (with-temp-buffer (insert-file-contents file) (buffer-string))))) ((alist-get 'cwd session))) (emacs-web--claude-transcript session))) (and (file-directory-p dir) (directory-files dir t (rx bos (+ digit) ".json" eos))))))) (defun emacs-web--claude-describe (file) "Return the session the transcript FILE holds, as a plist, or nil. It has the :id, :name, :dir and :time of the session; nil for a transcript with nothing typed in it, or of a session run headless." (let* ((head (with-temp-buffer (insert-file-contents file nil 0 65536) (buffer-string))) (titles (concat head "\n" (emacs-web--file-tail file 65536))) dir typed name entrypoint (start 0)) (dolist (line (split-string head "\n" t)) (when-let* (((not (and dir typed entrypoint))) (entry (ignore-errors (emacs-web--json line)))) (setq dir (or dir (alist-get 'cwd entry)) entrypoint (or entrypoint (alist-get 'entrypoint entry))) (when-let* (((not typed)) ((equal (alist-get 'type entry) "user")) ((not (alist-get 'isMeta entry))) (content (alist-get 'content (alist-get 'message entry))) ((stringp content))) (setq typed (emacs-web--claude-typed content))))) (while (string-match (rx "\"customTitle\":\"" (group (+ (not (any "\"")))) "\"") titles start) (setq name (match-string 1 titles) start (match-end 0))) (when (and dir (or name typed) (member entrypoint '(nil "cli"))) (list :id (file-name-base file) :name (or name (truncate-string-to-width (replace-regexp-in-string (rx (+ (syntax whitespace))) " " typed) 60 nil nil "…")) :dir dir :time (file-attribute-modification-time (file-attributes file)))))) (defun emacs-web-claude-archived () "Return the ids of the archived Claude Code sessions." (and (file-readable-p emacs-web-claude-archive-file) (with-temp-buffer (insert-file-contents emacs-web-claude-archive-file) (ignore-errors (read (current-buffer)))))) (defun emacs-web-claude-archive (ids &optional restore) "Archive the Claude Code sessions with IDS, or with RESTORE take them back." (let ((archived (if restore (seq-difference (emacs-web-claude-archived) ids) (seq-union (emacs-web-claude-archived) ids)))) (with-temp-file emacs-web-claude-archive-file (prin1 archived (current-buffer)) (insert "\n")) archived)) (defun emacs-web--claude-closed () "Return the latest closed Claude Code sessions, newest first. Each is a plist as `emacs-web--claude-describe' gives; at most `emacs-web-claude-closed-count' of them, none archived, and of sessions sharing a name and directory only the latest." (let* ((live (mapcar #'file-truename (emacs-web--claude-live-transcripts))) (archived (emacs-web-claude-archived)) (files (seq-remove (lambda (f) (or (member (file-name-base f) archived) (member (file-truename f) live))) (file-expand-wildcards (expand-file-name "projects/*/*.jsonl" emacs-web-claude-dir)))) (files (sort files (lambda (a b) (time-less-p (file-attribute-modification-time (file-attributes b)) (file-attribute-modification-time (file-attributes a)))))) sessions) (while (and files (< (length sessions) emacs-web-claude-closed-count)) (when-let* ((session (emacs-web--claude-describe (pop files))) ((not (seq-find (lambda (s) (and (equal (plist-get s :name) (plist-get session :name)) (equal (plist-get s :dir) (plist-get session :dir)))) sessions)))) (push session sessions))) (nreverse sessions))) (defun emacs-web--claude-resume (session) "Resume SESSION, as `emacs-web--claude-describe' gives it. Return the buffer of the terminal it goes on in." (unless emacs-web-claude-resume-function (user-error "No way to resume set: emacs-web-claude-resume-function")) (funcall emacs-web-claude-resume-function (plist-get session :dir) (plist-get session :id) (plist-get session :name))) (defun emacs-web-claude-resume-session () "Resume a closed Claude Code session, chosen among the latest. Return the buffer of the terminal it goes on in." (interactive) (let* ((sessions (emacs-web--claude-closed)) (choices (mapcar (lambda (s) (cons (if (cdr (seq-filter (lambda (o) (equal (plist-get o :name) (plist-get s :name))) sessions)) (format "%s (%s)" (plist-get s :name) (abbreviate-file-name (plist-get s :dir))) (plist-get s :name)) s)) sessions)) (note (lambda (choice) (when-let* ((s (cdr (assoc choice choices)))) (concat " " (abbreviate-file-name (plist-get s :dir)) " · " (emacs-web--date (plist-get s :time)))))) (choice (completing-read "Resume Claude session: " (lambda (string pred action) (if (eq action 'metadata) `(metadata (display-sort-function . identity) (annotation-function . ,note)) (complete-with-action action choices string pred))) nil t))) (emacs-web--claude-resume (cdr (assoc choice choices))))) (defun emacs-web--opencode-p (buffer) "Non-nil if BUFFER's terminal runs OpenCode attached to its server." (when-let* ((pid (emacs-web--pid buffer)) (args (with-temp-buffer (call-process "ps" nil t nil "-o" "args=" "-p" (number-to-string pid)) (buffer-string)))) (string-match-p (rx "opencode" (* nonl) " attach ") args))) (defun emacs-web--pi-p (buffer) "Non-nil if BUFFER's terminal runs Pi." (when-let* ((pid (emacs-web--pid buffer)) (args (alist-get 'args (process-attributes pid)))) (string-match-p (rx bos "pi" symbol-end) args))) (defun emacs-web--pi-transcript (buffer) "Return the session file of the Pi in BUFFER's terminal, or nil. It is the one of its directory written to last since that Pi started." (when-let* ((pid (emacs-web--pid buffer)) (start (alist-get 'start (process-attributes pid))) (cwd (directory-file-name (expand-file-name (buffer-local-value 'default-directory buffer)))) (dir (expand-file-name (format "sessions/--%s--/" (replace-regexp-in-string (rx (any "/\\:")) "-" (string-remove-prefix "/" cwd))) emacs-web-pi-dir)) ((file-directory-p dir))) (let (latest latest-time) (dolist (file (directory-files dir t (rx ".jsonl" eos))) (let ((time (file-attribute-modification-time (file-attributes file)))) (when (and (not (time-less-p time start)) (or (null latest-time) (time-less-p latest-time time))) (setq latest file latest-time time)))) latest))) (defun emacs-web--agent (buffer) "Return the coding agent BUFFER's terminal runs: `claude', `opencode', `pi' or nil." (cond ((emacs-web--claude-session buffer) 'claude) ((emacs-web--opencode-p buffer) 'opencode) ((emacs-web--pi-p buffer) 'pi))) (defun emacs-web--opencode-get (path &rest query) "Return the OpenCode server's JSON answer to a GET of PATH, or nil. QUERY alternates parameter names and values." (with-temp-buffer (set-buffer-multibyte nil) (when (eql 0 (call-process "curl" nil t nil "-sf" "-m" "3" (concat emacs-web-opencode-url path (and query (concat "?" (url-build-query-string (seq-partition query 2))))))) (emacs-web--json (decode-coding-string (buffer-string) 'utf-8))))) (defun emacs-web--opencode-session (buffer) "Return the OpenCode session BUFFER's terminal shows: its directory's latest." (let ((dir (directory-file-name (expand-file-name (buffer-local-value 'default-directory buffer))))) (car (sort (seq-remove (lambda (s) (alist-get 'parentID s)) (emacs-web--opencode-get "/session" "directory" dir)) (lambda (a b) (> (alist-get 'updated (alist-get 'time a)) (alist-get 'updated (alist-get 'time b)))))))) (defun emacs-web--without-setup (command) "Return COMMAND from what it runs, without where and what it sets up." (let ((setup (rx bos (* space) (or (seq "cd" (+ space) (+ (not (any ";&|\n"))) (or "&&" ";")) (seq (any alpha "_") (* (any alnum "_")) "=" (or (seq "'" (* (not "'")) "'") (seq "\"" (* (not "\"")) "\"") (* (not (any space ";&|")))) (or (seq (* space) (or "&&" ";")) (+ space)))) (* space)))) (while (and (string-match setup command) (< (match-end 0) (length command))) (setq command (substring command (match-end 0)))) command)) (defun emacs-web--tool-summary (input) "Return the gist of a tool call's INPUT, an alist, as one line." (let ((value (seq-some (lambda (key) (let ((v (alist-get key input))) (and (stringp v) (not (string-empty-p v)) v))) '(command file_path filePath path pattern url query description prompt)))) (when (and value (eq value (alist-get 'command input))) (setq value (emacs-web--without-setup value)) ;; a file is told by its name (setq value (replace-regexp-in-string (rx (or bos (any space "\"'=(<>:|;&")) (? (or "~" "." ".." (seq "$" (+ (any alnum "_"))))) "/" (+ (+ (not (any space "/\"'`;&|<>()="))) "/") (group (+ (not (any space "/\"'`;&|<>()=")))) (? "/")) (lambda (path) (concat (if (string-match-p (rx bos (any space "\"'=(<>:|;&")) path) (substring path 0 1) "") (match-string 1 path))) value t t))) (truncate-string-to-width (string-replace "\n" " " (or value (if input (json-encode input) ""))) 200 nil nil "…"))) (defconst emacs-web--heredoc-programs `(("Eval" . ,(rx "emacsclient-eval")) ("Py3" . ,(rx "python3" (* (+ space) "-" (+ alpha)) (+ space) "-"))) "Programs a command may hand code in a heredoc: (LABEL . REGEXP). A call that only does that shows under LABEL, as the code.") (defun emacs-web--heredoc (command) "Return (LABEL . CODE) when COMMAND only hands CODE to a heredoc program. LABEL names the program, as in `emacs-web--heredoc-programs'." (let ((command (emacs-web--without-setup command))) (seq-some (pcase-lambda (`(,label . ,program)) (when (string-match (rx bos (regexp program) (* space) "<<" (? "-") (* space) (? (any "'\"")) (group (+ (any alnum "_"))) (? (any "'\"")) (* (any " \t")) "\n") command) (let ((end (match-string 1 command)) (lines (split-string (substring command (match-end 0)) "\n"))) (while (and lines (string-blank-p (car (last lines)))) (setq lines (butlast lines))) (when (equal (string-trim (or (car (last lines)) "")) end) (setq lines (butlast lines))) (cons label (string-join lines "\n"))))) emacs-web--heredoc-programs))) (defun emacs-web--tool-entry (name id input) "Return the conversation entry of a call, ID, to the tool NAME with INPUT. A shell command that only hands code to a program shows as the code." (let ((command (alist-get 'command input))) (if-let* (((stringp command)) (heredoc (emacs-web--heredoc command))) (emacs-web--chat-item 'tool :name (car heredoc) :id id :command (cdr heredoc) :input (truncate-string-to-width (string-trim (replace-regexp-in-string (rx (+ (any " \t\n"))) " " (cdr heredoc))) 200 nil nil "…")) (pcase-let ((`(,server ,tool) (and (stringp name) (string-match (rx bos "mcp__" (group (+? nonl)) "__" (group (+ nonl)) eos) name) (list (match-string 1 name) (match-string 2 name))))) (emacs-web--chat-item 'tool :id id :mcp (and server t) ;; a server's tool goes by the server's own name :name (if server (concat (string-join (delete "mcp" (split-string server "-")) "-") " " tool) name) :input (emacs-web--tool-summary input) :command command))))) (defun emacs-web--tool-output (output &optional whole) "Return a tool's OUTPUT, text or a list of content blocks, as text. It is cut short past 4000 characters unless WHOLE is non-nil." (let ((text (string-trim (cond ((stringp output) output) ((listp output) (mapconcat (lambda (b) (or (and (listp b) (alist-get 'text b)) "")) output "\n")) (t ""))))) (if whole text (truncate-string-to-width text 4000 nil nil "\n…")))) (defun emacs-web--file-tail (file bytes) "Return the last BYTES of FILE as text, from its first whole line." (with-temp-buffer (let* ((size (file-attribute-size (file-attributes file))) (beg (max 0 (- size bytes)))) (insert-file-contents file nil beg size) (goto-char (point-min)) (when (> beg 0) (forward-line 1)) (buffer-substring-no-properties (point) (point-max))))) (defun emacs-web--chat-item (kind &rest props) "Return a conversation entry of KIND, with PROPS. KIND is `user', `assistant', `tool', `question', the agent asking the user its :questions, answered by :answers, or `event', something that happened to the session, told by :text. A `tool' entry may carry its :id and the :command it ran." (append (list :kind kind) props (list :text nil :name nil :input nil :output nil :error nil :questions nil :answers nil))) (defconst emacs-web--claude-markup (rx "<" (group (or "system-reminder" "command-name" "command-message" "command-args" "local-command-caveat" "local-command-stdout" "local-command-stderr" "bash-input" "bash-stdout" "bash-stderr" "task-notification")) (*? anything) "") "What Claude Code adds to a user's message that the user did not type.") (defun emacs-web--claude-typed (text) "Return what the user typed of TEXT, a user message in a transcript, or nil." (let ((typed (string-trim (replace-regexp-in-string emacs-web--claude-markup "" ;; pasted text shows as it was pasted (replace-regexp-in-string (rx "")) ">" (? "\n") (group (*? anything)) (? "\n") "")) ">") "\\1" text t) t t)))) (unless (string-empty-p typed) typed))) (cl-defstruct (emacs-web--transcript (:constructor emacs-web--transcript-create)) "What has been read of an agent's transcript, to read on from." (inode nil :documentation "The file's inode, telling a new file from the one read.") (offset 0 :documentation "Bytes read, up to the end of the last whole line.") (items nil :documentation "Entries so far, latest first.") (tools (make-hash-table :test #'equal) :documentation "Tool-call entries by id, for their results to land in.") (queued nil :documentation "Messages the user queued, oldest first.")) (defvar emacs-web--transcripts (make-hash-table :test #'equal) "Transcripts read so far, by file name, as `emacs-web--transcript'.") (defconst emacs-web--transcript-start 2000000 "Bytes from its end a transcript is first read from. It grows without end; its tail holds the latest entries.") (defun emacs-web--claude-unqueue (text queued) "Return QUEUED, a list of queued messages, without TEXT. A message may leave the queue with the attributes of its tags changed." (cl-flet ((bare (text) (and text (replace-regexp-in-string (rx "<" (group (+ (any alnum "_-"))) (+ space) (* (not ">")) ">") "<\\1>" text t)))) (let ((text (bare text))) (seq-remove (lambda (queued) (equal (bare queued) text)) queued)))) (defun emacs-web--claude-entry (state entry) "Take ENTRY, a line of a transcript, into STATE." (pcase (alist-get 'type entry) ("queue-operation" (let ((text (alist-get 'content entry))) (pcase (alist-get 'operation entry) ;; a message without its text queues all the same ("enqueue" (setf (emacs-web--transcript-queued state) (append (emacs-web--transcript-queued state) (list text)))) ("remove" (setf (emacs-web--transcript-queued state) (emacs-web--claude-unqueue text (emacs-web--transcript-queued state)))) ("dequeue" (setf (emacs-web--transcript-queued state) (if text (emacs-web--claude-unqueue text (emacs-web--transcript-queued state)) (cdr (emacs-web--transcript-queued state)))))))) ("attachment" ;; a message sent while the agent works joins its turn (when-let* ((attachment (alist-get 'attachment entry)) ((equal (alist-get 'type attachment) "queued_command")) ((equal (alist-get 'commandMode attachment) "prompt")) (text (emacs-web--claude-typed (or (alist-get 'prompt attachment) "")))) (push (emacs-web--chat-item 'user :text text) (emacs-web--transcript-items state)))) ("api-request-blob" ;; what Claude Code tells the agent besides the user, once a turn, folded (when-let* ((message (alist-get 'message entry)) ((equal (alist-get 'role message) "system")) (text (emacs-web--tool-output (alist-get 'content message) t)) ((not (string-empty-p text))) ((not (seq-find (lambda (item) (plist-get item :system)) (seq-take-while (lambda (item) (not (eq (plist-get item :kind) 'user))) (emacs-web--transcript-items state)))))) (push (emacs-web--chat-item 'tool :system t :name "System" :id (alist-get 'hash entry) :input (truncate-string-to-width (car (split-string (replace-regexp-in-string (rx "<" (* (not ">")) ">") "" text) "\n" t (rx (+ (any " \t"))))) 200 nil nil "…") :output text) (emacs-web--transcript-items state)))) ((guard (alist-get 'isCompactSummary entry)) ;; the summary is for the agent; the user learns it happened (push (emacs-web--chat-item 'event :text "Session was compacted") (emacs-web--transcript-items state))) (_ (when-let* ((message (alist-get 'message entry)) ((not (alist-get 'isMeta entry))) ((not (alist-get 'isSidechain entry)))) (let ((user (equal (alist-get 'type entry) "user")) (content (alist-get 'content message)) (tools (emacs-web--transcript-tools state))) (cl-flet ((say (text) (when-let* ((text (if user (emacs-web--claude-typed text) (and (not (string-blank-p text)) text)))) (push (emacs-web--chat-item (if user 'user 'assistant) :text text) (emacs-web--transcript-items state))))) (if (stringp content) (say content) (dolist (block content) (pcase (alist-get 'type block) ("text" (say (or (alist-get 'text block) ""))) ("tool_use" (let* ((input (alist-get 'input block)) (item (if (equal (alist-get 'name block) "AskUserQuestion") (emacs-web--chat-item 'question :questions (alist-get 'questions input)) (emacs-web--tool-entry (alist-get 'name block) (alist-get 'id block) input)))) (puthash (alist-get 'id block) item tools) (push item (emacs-web--transcript-items state)))) ("tool_result" (when-let* ((item (gethash (alist-get 'tool_use_id block) tools))) (plist-put item :output (emacs-web--tool-output (alist-get 'content block))) (plist-put item :error (alist-get 'is_error block)) (when-let* ((result (alist-get 'toolUseResult entry)) ((listp result))) (plist-put item :answers (alist-get 'answers result)))))))))))))) (defun emacs-web--pi-entry (state entry) "Take ENTRY, a line of a Pi session, into STATE." (when-let* (((equal (alist-get 'type entry) "message")) (message (alist-get 'message entry))) (let ((role (alist-get 'role message)) (content (alist-get 'content message)) (tools (emacs-web--transcript-tools state))) (pcase role ((or "user" "assistant") (dolist (block (if (stringp content) `(((type . "text") (text . ,content))) content)) (pcase (alist-get 'type block) ("text" (let ((text (or (alist-get 'text block) ""))) (unless (string-blank-p text) (push (emacs-web--chat-item (intern role) :text text) (emacs-web--transcript-items state))))) ("toolCall" (let ((item (emacs-web--tool-entry (alist-get 'name block) (alist-get 'id block) (alist-get 'arguments block)))) (puthash (alist-get 'id block) item tools) (push item (emacs-web--transcript-items state))))))) ("toolResult" (when-let* ((item (gethash (alist-get 'toolCallId message) tools))) (plist-put item :output (emacs-web--tool-output content)) (plist-put item :error (alist-get 'isError message)))))))) (defun emacs-web--transcript-read-on (file state take) "Take into STATE the whole lines FILE gained since STATE last read it. TAKE takes each line's entry into the state, as `emacs-web--claude-entry'. Return STATE, or a fresh one when FILE is another file or got shorter." (let* ((attrs (file-attributes file)) (size (file-attribute-size attrs)) (inode (file-attribute-inode-number attrs))) (when (or (null state) (not (equal inode (emacs-web--transcript-inode state))) (< size (emacs-web--transcript-offset state))) (setq state (emacs-web--transcript-create :inode inode :offset (max 0 (- size emacs-web--transcript-start))))) (when (> size (emacs-web--transcript-offset state)) (with-temp-buffer (set-buffer-multibyte nil) (insert-file-contents-literally file nil (emacs-web--transcript-offset state) size) ;; a line still being written is read once it is whole (goto-char (point-max)) (when (search-backward "\n" nil t) (let ((end (1+ (point)))) (dolist (line (split-string (decode-coding-string (buffer-substring-no-properties (point-min) end) 'utf-8) "\n" t)) ;; the first line read mid-file is a part of one, and no JSON (when-let* ((entry (emacs-web--json line))) (funcall take state entry))) (cl-incf (emacs-web--transcript-offset state) (1- end)))))) ;; older entries no page shows are let go, and their tools with them (let ((items (emacs-web--transcript-items state))) (when (> (length items) (* 2 emacs-web-chat-items)) (let ((kept (seq-take items emacs-web-chat-items)) (tools (emacs-web--transcript-tools state))) (setf (emacs-web--transcript-items state) kept) (maphash (lambda (id item) (unless (memq item kept) (remhash id tools))) tools)))) state)) (defun emacs-web--transcript-state (file take) "Return what has been read of the transcript FILE, read on to its end. TAKE takes each new line's entry in, as `emacs-web--claude-entry'." (puthash file (emacs-web--transcript-read-on file (gethash file emacs-web--transcripts) take) emacs-web--transcripts)) (defun emacs-web--pi-items (file) "Return the latest entries of the Pi session FILE. Only what FILE gained since it was last read is read." (reverse (seq-take (emacs-web--transcript-items (emacs-web--transcript-state file #'emacs-web--pi-entry)) emacs-web-chat-items))) (defun emacs-web--claude-items (file) "Return the latest entries of the Claude Code transcript FILE. Messages the user queued that the agent has yet to take come last, marked :pending. Only what FILE gained since it was last read is read." (let ((state (emacs-web--transcript-state file #'emacs-web--claude-entry)) (pending nil)) (dolist (text (emacs-web--transcript-queued state)) (when-let* ((text (and text (emacs-web--claude-typed text)))) (push (emacs-web--chat-item 'user :text text :pending t) pending))) (last (append (reverse (seq-take (emacs-web--transcript-items state) emacs-web-chat-items)) (nreverse pending)) emacs-web-chat-items))) (defun emacs-web--opencode-items (session dir) "Return the latest entries of the OpenCode SESSION, in directory DIR." (let (items) (dolist (m (emacs-web--opencode-get (format "/session/%s/message" (alist-get 'id session)) "directory" dir "limit" (number-to-string emacs-web-chat-items))) (let ((user (equal (alist-get 'role (alist-get 'info m)) "user"))) (dolist (part (alist-get 'parts m)) (pcase (alist-get 'type part) ("text" (let ((text (or (alist-get 'text part) ""))) (unless (or (alist-get 'synthetic part) (string-blank-p text)) (push (emacs-web--chat-item (if user 'user 'assistant) :text text) items)))) ("tool" (let ((state (alist-get 'state part))) (push (emacs-web--chat-item 'tool :name (alist-get 'tool part) :input (emacs-web--tool-summary (alist-get 'input state)) :output (emacs-web--tool-output (or (alist-get 'output state) (alist-get 'error state))) :error (equal (alist-get 'status state) "error")) items))))))) (last (nreverse items) emacs-web-chat-items))) (defun emacs-web--chat (buffer) "Return the conversation in BUFFER's terminal as (TITLE STATUS ITEMS), or nil." (pcase (emacs-web--agent buffer) ('claude (let* ((session (emacs-web--claude-session buffer)) (file (emacs-web--claude-transcript session))) (list (or (alist-get 'name session) (buffer-name buffer)) (concat "Claude Code · " (or (alist-get 'status session) "running")) (and (file-readable-p file) (emacs-web--claude-items file))))) ('opencode (let* ((dir (directory-file-name (expand-file-name (buffer-local-value 'default-directory buffer)))) (session (emacs-web--opencode-session buffer))) (list (or (alist-get 'title session) (buffer-name buffer)) "OpenCode" (and session (emacs-web--opencode-items session dir))))) ('pi (let ((file (emacs-web--pi-transcript buffer))) (list (buffer-name buffer) "Pi" (and file (emacs-web--pi-items file))))))) (defconst emacs-web--md-kept (rx (or (seq "" (*? nonl) "") (seq ""))) ">" (*? nonl) "") (seq "<" (* (not (any ">"))) ">"))) "HTML that marking up leaves be: code, links and tags.") (defun emacs-web--md-replace (regexp rep html) "Return HTML with REGEXP replaced by REP, as `replace-regexp-in-string' does, in its text only: code, links and tags stay as they are." (let ((start 0) out) (while (string-match emacs-web--md-kept html start) (push (replace-regexp-in-string regexp rep (substring html start (match-beginning 0))) out) (push (match-string 0 html) out) (setq start (match-end 0))) (push (replace-regexp-in-string regexp rep (substring html start)) out) (apply #'concat (nreverse out)))) (defun emacs-web--md-code (html) "Return HTML with `code' marked as code." (emacs-web--md-replace (rx "`" (group (+ (not (any "`")))) "`") "\\1" html)) (defun emacs-web--md-strong (html) "Return HTML with **text** marked strong." (emacs-web--md-replace (rx "**" (group (+ (not (any "*")))) "**") "\\1" html)) (defun emacs-web--md-em (html) "Return HTML with *text* emphasized, leaving 2*3*4 be." (emacs-web--md-replace (rx (group (or bos (not (any "*" alnum)))) "*" (group (not (any "* ")) (* (not (any "*")))) "*") "\\1\\2" html)) (defun emacs-web--md-link (html) "Return HTML with [text](url) a link, for web URLs only." (emacs-web--md-replace (rx "[" (group (+ (not (any "]")))) "](" (group "http" (? "s") "://" (+ (not (any ") &")))) ")") "\\1" html)) (defun emacs-web--md-autolink (html) "Return HTML with bare web URLs made links. Punctuation ending a sentence after one is not part of it." (emacs-web--md-replace (rx "http" (? "s") "://" (* (not (any " \t<>\"'"))) (not (any " \t<>\"'.,;:!?)"))) "\\&" html)) (defun emacs-web--md-inline (line) "Return LINE of plain text as HTML, marked up by `emacs-web-chat-inline-filters'." (seq-reduce (lambda (html filter) (funcall filter html)) emacs-web-chat-inline-filters (emacs-web--escape line))) (defun emacs-web--md-table (lines) "Return LINES, the rows of a table as agents write it, as HTML. Rows above a rule are its head; colons in the rule align its columns." (let* ((rows (mapcar (lambda (line) ;; a cell may hold \| for a bar of its own (mapcar (lambda (cell) (string-replace "\0" "|" (string-trim cell))) (split-string (string-replace "\\|" "\0" (string-trim (string-trim line) (rx "|") (rx "|"))) "|"))) lines)) (rule-p (lambda (row) (seq-every-p (lambda (cell) (string-match-p (rx bos (? ":") (+ (any "-+")) (? ":") eos) cell)) row))) (rule (cl-position-if rule-p rows)) (align (when rule (mapcar (lambda (cell) (cond ((string-match-p (rx bos ":" (* nonl) ":" eos) cell) "center") ((string-suffix-p ":" cell) "right"))) (nth rule rows))))) (cl-flet ((row (cells tag) (concat "" (let ((i -1)) (mapconcat (lambda (cell) (let ((a (nth (cl-incf i) align))) (format "<%s%s>%s" tag (if a (format " style=\"text-align:%s\"" a) "") (emacs-web--md-inline cell) tag))) cells "")) ""))) (concat "
" (if rule (concat "" (mapconcat (lambda (r) (row r "th")) (seq-take rows rule) "") "") "") (if-let* ((body (seq-remove rule-p (if rule (seq-drop rows rule) rows)))) (concat "" (mapconcat (lambda (r) (row r "td")) body "") "") "") "
")))) (defun emacs-web--md (text) "Return TEXT, Markdown as agents write it, as HTML." (let (out para items kind code block) (cl-flet* ((flush () (when para (push (concat "

" (mapconcat #'emacs-web--md-inline (nreverse para) " ") "

") out) (setq para nil)) (when items (push (format "<%s>%s" kind (mapconcat (lambda (i) (concat "
  • " (emacs-web--md-inline i) "
  • ")) (nreverse items) "") kind) out) (setq items nil))) (pre (lines) (push (concat "
    " (emacs-web--escape (string-join (reverse lines) "\n"))
                                   "
    ") out)) (table (lines) (push (emacs-web--md-table (reverse lines)) out))) (dolist (line (split-string text "\n")) (cond (code (if (string-match-p (rx bos (* (any " \t")) "```") line) (progn (pre block) (setq code nil block nil)) (push line block))) ((string-match-p (rx bos (* (any " \t")) "```") line) (flush) (setq code t block nil)) ((string-match-p (rx bos (* (any " \t")) "|") line) (unless (eq kind 'table) (flush) (setq kind 'table block nil)) (push line block)) (t (when (eq kind 'table) (table block) (setq kind nil block nil)) (cond ((string-blank-p line) (flush)) ((string-match (rx bos (+ "#") (+ (any " \t")) (group (* nonl))) line) (flush) (push (concat "

    " (emacs-web--md-inline (match-string 1 line)) "

    ") out)) ((string-match (rx bos (* (any " \t")) (or (group (any "-*+")) (seq (+ digit) (any ".)"))) (+ (any " \t")) (group (* nonl))) line) (let ((k (if (match-string 1 line) "ul" "ol")) (item (match-string 2 line))) (when para (let ((i items)) (setq items nil) (flush) (setq items i))) (unless (equal k kind) (flush) (setq kind k)) (push item items))) (items (setcar items (concat (car items) " " (string-trim line)))) (t (push line para)))))) (when code (pre block)) (when (eq kind 'table) (table block)) (flush)) (string-join (nreverse out) "\n"))) (defun emacs-web--question-answers (output) "Return the answers OUTPUT, a declined question's reply, still gives. The value is an alist from question, as a symbol, to answer." (let ((start 0) answers) (while (string-match (rx bol "- \"" (group (* nonl)) "\"\n" (+ " ") "Answer: " (group (* nonl)) eol) (or output "") start) (push (cons (intern (match-string 1 output)) (match-string 2 output)) answers) (setq start (match-end 0))) (nreverse answers))) (defun emacs-web--chat-question (item) "Return the questions of ITEM, a `question' entry, as HTML. The options answered are marked; an answer of the user's own words is shown after them." (let* ((output (plist-get item :output)) (answers (or (plist-get item :answers) (and (plist-get item :error) (emacs-web--question-answers output)))) (done (or answers output))) (concat "
    " (mapconcat (lambda (q) (let* ((question (or (alist-get 'question q) "")) (answer (alist-get (intern question) answers)) (labels (mapcar (lambda (o) (alist-get 'label o)) (alist-get 'options q))) ;; several options chosen are answered as one, joined by commas (chosen (cond ((member answer labels) (list answer)) (answer (split-string answer ", " t))))) (concat "
    " (if-let* ((header (alist-get 'header q))) (concat "" (emacs-web--escape header) "") "") (emacs-web--md question) "
      " (mapconcat (lambda (o) (format "%s%s" (if (member (alist-get 'label o) chosen) " class=\"on\"" "") (emacs-web--escape (or (alist-get 'label o) "")) (if-let* ((d (alist-get 'description o))) (concat "" (emacs-web--escape d) "") ""))) (alist-get 'options q) "") "
    " (if (and answer (not (seq-every-p (lambda (c) (member c labels)) chosen))) (concat "

    " (emacs-web--escape answer) "

    ") "") "
    "))) (plist-get item :questions) "") (cond ((not done) "

    Waiting for an answer in the terminal

    ") ((plist-get item :error) "

    Not answered in full

    ") ((not answers) (concat "

    " (emacs-web--escape output) "

    ")) (t "")) "
    "))) (defun emacs-web--chat-log (items) "Return the conversation entries ITEMS as HTML." (let ((n 0)) (mapconcat (lambda (it) (pcase (plist-get it :kind) ('user (concat (if (plist-get it :pending) "
    " "
    ") (emacs-web--md (plist-get it :text)) "
    ")) ('assistant (concat "
    " (emacs-web--md (plist-get it :text)) "
    ")) ('question (emacs-web--chat-question it)) ('event (concat "

    " (emacs-web--escape (plist-get it :text)) "

    ")) ('tool (format "
    %s %s%s%s
    " (if (plist-get it :error) " err" "") (if (plist-get it :mcp) " mcp" "") (emacs-web--escape (or (plist-get it :id) (format "n%d" (cl-incf n)))) (emacs-web--escape (or (plist-get it :name) "tool")) (emacs-web--escape (or (plist-get it :input) "")) (let ((command (plist-get it :command))) (if (and (stringp command) (not (string-empty-p command))) (concat "
    " (emacs-web--escape command) "
    ") "")) (let ((out (plist-get it :output))) (if (and out (not (string-empty-p out))) (concat "
    " (emacs-web--escape out) "
    ") "")))))) items "\n"))) (defun emacs-web--sendable-p (buffer) "Non-nil if text can be sent to BUFFER as typed input." (and (fboundp 'ghostel-paste-string) (with-current-buffer buffer (derived-mode-p 'ghostel-mode)))) (defun emacs-web--render-window () "Return the window of the invisible frame, making the frame if need be." (unless (frame-live-p emacs-web--render-frame) (setq emacs-web--render-frame (make-frame '((visibility . nil) (name . "emacs-web") (minibuffer . nil) (no-other-frame . t))))) (frame-root-window emacs-web--render-frame)) (defun emacs-web--catch-up (buffer) "Bring the terminal in BUFFER up to date when no window shows it. Ghostel turns terminal output into buffer text only for a buffer on screen, so show BUFFER for the length of one redraw, out of sight. It leaves before redisplay, which is when the terminal would take on the window's size." (when (and (emacs-web--sendable-p buffer) (not (get-buffer-window buffer t)) (fboundp 'ghostel--redraw-now) (display-graphic-p)) (let ((window (emacs-web--render-window)) (shown (buffer-local-value 'buffer-display-time buffer))) (set-window-buffer window buffer) (unwind-protect (ghostel--redraw-now buffer t) (set-window-buffer window (get-buffer-create " *emacs-web*")) (with-current-buffer buffer (setq buffer-display-time shown)))))) (defconst emacs-web--keys '(("esc" "escape" "ESC") ("⇥" "tab" "TAB") ("↑" "up" "") ("↓" "down" "") ("←" "left" "") ("→" "right" "") ("⌫" "backspace" "DEL") ("↵" "return" "RET")) "Keys a buffer page offers: (LABEL KEY NOTATION). KEY is as Ghostel names it, NOTATION as Emacs does.") (defun emacs-web--straight-quotes (text) "Return TEXT with the curly quotes a phone types made straight. iOS turns ' and \" into ‘ ’ and “ ” as one types, which Lisp and key notation do not read." (replace-regexp-in-string (rx (any "‘’‛“”„")) (lambda (q) (if (member q '("‘" "’" "‛")) "'" "\"")) text t t)) (defun emacs-web--sexp (text) "Return the form TEXT is, if it is one for Emacs to evaluate, else nil. It is when all of TEXT is one list headed by a function, macro or special form Emacs knows; so a message to an agent in parentheses, such as (note: this), goes as text." (let ((text (emacs-web--straight-quotes (string-trim (or text ""))))) (when (string-prefix-p "(" text) (pcase (ignore-errors (read-from-string text)) (`(,form . ,end) (and (= end (length text)) (consp form) (symbolp (car form)) (fboundp (car form)) form)))))) (defun emacs-web--eval (buffer form &optional quiet) "Evaluate FORM with BUFFER current, as `M-:' there would. Return (TEXT . SHOW): TEXT what it gives, printed - a string as it stands - or the error it signals, logged in *Messages* too as `M-:' logs it unless QUIET; SHOW the buffer it gives or else the first one it makes, for the phone to open, or nil. A prompt it opens waits for the phone to answer; it finishes once answered." (let ((before (buffer-list)) (message-log-max (and (not quiet) message-log-max)) (inhibit-message (or quiet inhibit-message))) (condition-case err (let* ((value (save-window-excursion (with-current-buffer buffer (eval form t)))) (printed (if (stringp value) value (prin1-to-string value)))) (message "%s" printed) (cons (truncate-string-to-width printed 4000 nil nil "…") (if (and (bufferp value) (buffer-live-p value)) value (seq-find (lambda (b) (and (not (memq b before)) (emacs-web--visible-p b))) (buffer-list))))) (error (let ((why (concat "Error: " (error-message-string err)))) (message "%s" why) (list why))) (quit (message "Quit") (list "Quit"))))) (defconst emacs-web--keys-regexp (rx bos (* (any " \t\n")) "{" (group (+ nonl)) "}" (* (any " \t\n")) eos) "A message that is keys to type into Emacs: Emacs's notation in braces.") (defvar emacs-web--keys-frame nil "An invisible frame keys sent to a buffer run in, or nil.") (defun emacs-web--keys-window () "Return the window of the frame keys run in, making the frame if need be. It is not the one hidden terminals are drawn in, which swaps buffers under keys still waiting for an answer to a prompt." (unless (frame-live-p emacs-web--keys-frame) (setq emacs-web--keys-frame (make-frame '((visibility . nil) (name . "emacs-web-keys") (minibuffer . nil) (no-other-frame . t))))) (frame-root-window emacs-web--keys-frame)) (defvar emacs-web--keys-run nil "While keys sent to a buffer run: (WINDOW BUFFER SHOWN DEPTH TIMER). WINDOW was selected before, SHOWN is BUFFER's `buffer-display-time' then, DEPTH the recursion depth they started at, TIMER the one watching for them to be done.") (defun emacs-web--keys-done-p () "Give the selection back once the keys sent to a buffer are done. They are, when none is left to read and no prompt they opened waits." (pcase emacs-web--keys-run (`(,window ,buffer ,shown ,depth ,timer) (when (and (null unread-command-events) (<= (recursion-depth) depth)) (setq emacs-web--keys-run nil) (when (timerp timer) (cancel-timer timer)) (let ((keys (emacs-web--keys-window))) (when (window-live-p keys) (set-window-buffer keys (get-buffer-create " *emacs-web*")))) (when (window-live-p window) (select-window window)) (when (buffer-live-p buffer) (with-current-buffer buffer (setq buffer-display-time shown))))))) (defun emacs-web--run-keys (buffer notation) "Have the keys NOTATION names, in Emacs's notation, typed in BUFFER. They go to Emacs as if typed there, a moment later, once the phone has its answer: so a prompt they open waits for a real answer, which the phone can give. BUFFER is shown, and its window selected, in an invisible frame meanwhile, so no window here changes; the window selected before is selected again once they are done. Return nil, or a message when NOTATION names no keys or keys run already." (let* ((notation (emacs-web--straight-quotes notation)) (keys (condition-case nil (kbd notation) (error nil)))) (cond ((not keys) (format "Not a key sequence: %s" notation)) ((and emacs-web--keys-run (not (emacs-web--keys-more-p buffer))) "Keys sent before still wait for an answer") (t (run-at-time 0 nil (lambda () (cond ((emacs-web--keys-more-p buffer) (setq unread-command-events (append unread-command-events (listify-key-sequence keys)))) ((and (buffer-live-p buffer) (not emacs-web--keys-run)) (let ((window (emacs-web--keys-window))) (setq emacs-web--keys-run (list (selected-window) buffer (buffer-local-value 'buffer-display-time buffer) (recursion-depth) (run-with-timer 0.2 0.2 #'emacs-web--keys-done-p))) (set-window-buffer window buffer) (select-window window) (setq unread-command-events (append (listify-key-sequence keys) unread-command-events))))))) nil)))) (defun emacs-web--keys-more-p (buffer) "Non-nil if keys sent to BUFFER run and more may join them. They may while no prompt they opened waits." (pcase emacs-web--keys-run (`(,_ ,run ,_ ,depth ,_) (and (eq run buffer) (<= (recursion-depth) depth))))) (defun emacs-web--valid-key-p (key mods) "Non-nil if KEY with MODS may be sent to a buffer. Only the keys the page offers may, and none with modifiers." (and (stringp key) (assoc key (mapcar #'cdr emacs-web--keys)) (member mods '(nil "")))) (defconst emacs-web--term-script "\n" "Script driving a terminal page: its size, keys, taps and messages.") (defun emacs-web--dock (api) "Return the bar for typing and pressing keys in a buffer, its actions at API." (format "\n
    %s
    " (emacs-web--escape api) ;; a shared page's own server hears it (if emacs-web--shared "" (emacs-web--escape emacs-web-transcribe-at)) (concat "
    " (mapconcat (lambda (k) (format "" (nth 1 k) (car k))) emacs-web--keys "") "
    "))) (defun emacs-web--replace-all (replacements html) "Return HTML with each FROM of REPLACEMENTS, a list of (FROM . TO), made TO. FROM is found as it stands and as character references." (seq-reduce (lambda (html r) (string-replace (mapconcat (lambda (c) (format "&#%d;" c)) (car r) "") (cdr r) (string-replace (car r) (cdr r) html))) replacements html)) (defun emacs-web--buffer-page (buffer up) "Return the page showing BUFFER as Emacs colours it. UP is the relative link back to the index." (emacs-web--catch-up buffer) (let* ((info (emacs-web--describe buffer)) (span (emacs-web--span buffer)) (term (emacs-web--sendable-p buffer)) (rows (and (not term) (emacs-web--rows-p buffer))) (rendered (unless rows (let ((r (emacs-web--htmlize buffer))) (if (and term (cdr r)) (cons (car r) (emacs-web--replace-all emacs-web-term-replacements (cdr r))) r)))) ;; which part of the buffer shows, when not all of it (cut (and (not rows) (with-current-buffer buffer (save-restriction (widen) (pcase-let ((`(,beg . ,end) span)) (cond ((and (= beg (point-min)) (= end (point-max))) nil) ((= beg (point-min)) 'first) ((= end (point-max)) 'last) (t 'around))))))) (style (concat (emacs-web--scope-style (or (car rendered) "")) (format "\n.code .pt::after{background:%s}" (or (face-background 'cursor nil t) "#ff9f0a"))))) (emacs-web--page up (concat (plist-get info :name) " | " emacs-web-title " Emacs") (concat "\n" "
    " (emacs-web--back up) (or (emacs-web--buffer-menu buffer up) "") "
    \n" "

    " (emacs-web--escape (plist-get info :name)) "

    \n" (if cut (let ((size (emacs-web--size emacs-web-max-chars))) (format "

    Showing %s.

    \n" (if (eq cut 'around) (format "%s characters around point" size) (format "the %s %s characters" cut size)))) "") (cond (rows (concat "
    " (emacs-web--rows-html (emacs-web--rows buffer)) "
    ")) (term (concat (with-current-buffer buffer (format "
    " (bound-and-true-p ghostel--term-cols) (bound-and-true-p ghostel--term-rows) (or (bound-and-true-p ghostel--term-cols) 80))) (or (cdr rendered) "
    ") "
    ")) (t (concat ;; where the text starts, for a tap to say where it is (if-let* ((columns (emacs-web--display buffer 'min-columns))) (format "
    " (car span) columns) (format "
    " (car span))) (or (cdr rendered) "
    ") "
    "))) (emacs-web--dock (concat up "api/ACT/" (emacs-web--href info)))) (concat emacs-web--refresh-script emacs-web--term-script (if rows emacs-web--rows-script "") emacs-web--pull-script emacs-web--menu-script ;; open where point is "\n")))) (defconst emacs-web--chat-script "\n" "Script keeping a chat page up with the conversation.") (defun emacs-web--chat-page (buffer up) "Return the page showing the conversation in BUFFER's terminal as HTML. UP is the relative link back to the index." (let ((info (emacs-web--describe buffer))) (pcase-let ((`(,title ,status ,items) (or (emacs-web--chat buffer) (list (buffer-name buffer) "No agent" nil)))) (emacs-web--page up (concat title " | " emacs-web-title " Emacs") (concat "
    " (emacs-web--back up) (or (emacs-web--buffer-menu buffer up) "") "
    \n" "

    " (emacs-web--escape title) "

    \n" "
    " (emacs-web--escape status) "
    \n" "
    " (emacs-web--chat-log items) "
    " (emacs-web--dock (concat up "api/ACT/" (emacs-web--href info)))) (concat emacs-web--chat-script emacs-web--term-script emacs-web--pull-script emacs-web--menu-script))))) (defun emacs-web--missing-page (up) "Return the page saying the buffer asked for is not there. UP is the relative link back to the index." (emacs-web--page up "No such buffer" (concat "
    " (emacs-web--back up) "
    \n" "

    No such buffer

    \n" "

    The buffer is gone, or never was.

    "))) ;;;; Server (defun emacs-web--route (path) "Return what PATH, a request path still percent-encoded, asks for. The value is `index', `all', `transcribe', (asset FILE), (upload NAME), \(mode MAJOR), \(buffer NAME ID) with ID possibly nil, (chat NAME ID) for the same link under c/, its conversation, (act ACTION BUFFER-ROUTE), or nil." (let ((path (string-trim-left path "/"))) (cond ((string-empty-p path) 'index) ((equal path "all") 'all) ((equal path "api/transcribe") 'transcribe) ((equal path "api/run") 'run) ((equal path "api/prompt") 'prompt) ((string-match (rx bos "api/" (group (or "send" "key" "click" "size" "menu" "row" "point" "upload")) "/" (group "b/" (* nonl)) eos) path) (let ((action (intern (match-string 1 path))) (target (emacs-web--route (match-string 2 path)))) (when (eq (car-safe target) 'buffer) (list 'act action target)))) ((assoc path emacs-web--asset-types) (list 'asset path)) ((string-match (rx bos "u/" (group (any "a-z0-9") (* (any "a-z0-9.-"))) eos) path) (list 'upload (match-string 1 path))) ((string-match (rx bos "m/" (group (+ (not (any "/")))) eos) path) (list 'mode (decode-coding-string (url-unhex-string (match-string 1 path)) 'utf-8))) ((string-match (rx bos (group (any "bc")) "/" (group (+ (not (any "/")))) (? "/i/" (group (+ (any "0-9a-f")))) eos) path) (let ((chat (equal (match-string 1 path) "c")) (name (match-string 2 path)) (id (match-string 3 path))) (list (if chat 'chat 'buffer) (decode-coding-string (url-unhex-string name) 'utf-8) id)))))) (defun emacs-web--send (proc code html) "Send HTML with status CODE to PROC." (ws-response-header proc code '("Content-Type" . "text/html; charset=utf-8") '("Cache-Control" . "no-store")) (ws-send proc (encode-coding-string html 'utf-8))) (defun emacs-web--target (name id) "Return the live listed buffer a link with NAME and ID leads to, or nil. The id outranks the name, which may since belong to another buffer." (let ((buffer (if id (emacs-web--find-id id) (get-buffer name)))) (and buffer (emacs-web--visible-p buffer) buffer))) (defun emacs-web--screen-row (text from-end rows) "Return the terminal row of the line FROM-END lines before TEXT's last. TEXT ends with the terminal's screen of ROWS lines. Return nil when the line lies above the screen, in the scrollback." (let* ((lines (1+ (cl-count ?\n (string-remove-suffix "\n" text)))) (row (- rows 1 from-end))) (and (>= from-end 0) (< from-end (min rows lines)) row))) (defun emacs-web--give-back-sizes () "Size the terminals a phone took over back to their Emacs windows. Not while keys sent from the phone run: those are not Emacs being used." (when (and emacs-web--resized (not emacs-web--keys-run)) (let ((buffers emacs-web--resized)) (setq emacs-web--resized nil) (dolist (buffer buffers) (when-let* (((buffer-live-p buffer)) (window (get-buffer-window buffer t))) (ghostel--adjust-size window t)))))) (defun emacs-web--focus-changed () "Give terminal sizes back once an Emacs frame has focus." (when (seq-some (lambda (f) (eq (frame-focus-state f) t)) (frame-list)) (emacs-web--give-back-sizes))) (defun emacs-web--hold-size (adjust window &rest args) "Call ADJUST on WINDOW with ARGS unless a phone holds its terminal's size. Ghostel refits a terminal whenever its window changes, which on an unattended Emacs happens for reasons as small as a message." (unless (or (memq (window-buffer window) emacs-web--resized) ;; keys typed into it show it in a window of their own (and emacs-web--keys-run (eq (window-buffer window) (nth 1 emacs-web--keys-run)))) (apply adjust window args))) (defun emacs-web--run (index) "Run the action at INDEX in `emacs-web-list-actions'. Return (ok . LINK), LINK leading to the page of the buffer the action returns or nil, or (error . MESSAGE) when it did not run. An action quit, as by cancelling what it asked, did nothing, unless a command run on its question left a page to open." (if-let* (((natnump index)) (action (nth index emacs-web-list-actions))) (condition-case err (let* ((fn (cdr action)) ;; as if run by a key, so what it asks knows who asks (this-command fn) (real-this-command fn) (result (save-window-excursion (if (commandp fn) (call-interactively fn) (funcall fn))))) (cons 'ok (and (bufferp result) (buffer-live-p result) (emacs-web--href (emacs-web--describe result))))) (quit (cons 'ok (emacs-web--take-open-next))) (error (cons 'error (error-message-string err)))) (cons 'error "No such action"))) (defun emacs-web--run-buffer-action (buffer index &optional input) "Run the action at INDEX in `emacs-web-buffer-actions' on BUFFER. An action asking for text is given INPUT, the text. Return (ok . LINK), LINK leading to the page of another buffer the action returns or nil, (gone) when BUFFER is gone after it, or (error . MESSAGE)." (let ((action (and (natnump index) (nth index emacs-web-buffer-actions)))) (if (not (and action (or (emacs-web--resolve (nth 0 action) buffer) (emacs-web--action-prop action :anyway)))) (cons 'error "No such action") (condition-case err (let* ((link (emacs-web--link buffer)) (result (if (emacs-web--action-prop action :input) (funcall (nth 1 action) buffer (or input "")) (funcall (nth 1 action) buffer)))) (cond ((not (buffer-live-p buffer)) (list 'gone)) ((and (bufferp result) (buffer-live-p result) (not (eq result buffer))) (cons 'ok (emacs-web--href (emacs-web--describe result)))) ((not (equal link (emacs-web--link buffer))) (cons 'ok (emacs-web--link buffer))) (t (list 'ok)))) (error (cons 'error (error-message-string err))))))) (defun emacs-web--done (why) "Signal WHY, a message from `emacs-web--act', unless it is nil." (when why (user-error "%s" why))) (defun emacs-web-pin-label (buffer) "Return the label of the menu entry pinning BUFFER or unpinning it." (if (emacs-web--pinned-p buffer) "Unpin" "Pin")) (defun emacs-web-pin-icon (buffer) "Return the icon of the swipe action pinning BUFFER or unpinning it." (if (emacs-web--pinned-p buffer) "unpin" "pin")) (defun emacs-web-toggle-pin (buffer) "Pin BUFFER to the home page, or unpin it." (emacs-web--set-pinned buffer (not (emacs-web--pinned-p buffer)))) (defvar-local emacs-web-shared nil "Non-nil if the buffer is shared for someone else to edit.") (put 'emacs-web-shared 'permanent-local t) (defun emacs-web-share-label (buffer) "Return the label of the menu entry sharing BUFFER or unsharing it." (if (buffer-local-value 'emacs-web-shared buffer) "Unshare" "Share")) (defun emacs-web-toggle-share (buffer) "Share BUFFER, or stop sharing it." (with-current-buffer buffer (setq emacs-web-shared (not emacs-web-shared))) nil) (defun emacs-web-send-keys-label (buffer) "Return \"Send keys\" if BUFFER is a terminal." (and (with-current-buffer buffer (derived-mode-p 'ghostel-mode)) "Send keys")) (defun emacs-web-send-keys (buffer keys) "Type KEYS, keys as `kbd' reads them, into the terminal BUFFER. They go to the program running there, whatever Emacs binds them to." (let* ((events (append (condition-case nil (kbd keys) (error nil)) nil)) (specs (mapcar #'ghostel--event-key-spec events))) (cond ((or (null events) (memq nil specs)) (user-error "Not keys a terminal takes: %s" keys)) (t (with-current-buffer buffer (dolist (spec specs) (ghostel-send-key (car spec) (and (not (string-empty-p (cdr spec))) (cdr spec))))) nil)))) (defun emacs-web-save-label (buffer) "Return \"Save\" if BUFFER visits a file and has changes to save." (and (buffer-file-name buffer) (buffer-modified-p buffer) "Save")) (defun emacs-web-save (buffer) "Save BUFFER to its file." (emacs-web--done (emacs-web--act 'save buffer))) (defun emacs-web-revert-label (buffer) "Return \"Revert\" if BUFFER visits a file." (and (buffer-file-name buffer) "Revert")) (defun emacs-web-revert (buffer) "Read BUFFER again from its file, dropping changes not saved." (with-current-buffer buffer (revert-buffer :ignore-auto :noconfirm))) (defun emacs-web-rename-question (buffer) "Return what to ask for BUFFER's new name." (format "Rename %s to:" (buffer-name buffer))) (defun emacs-web-rename (buffer name) "Rename BUFFER to NAME, keeping it pinned." (let ((name (string-trim name)) (key (emacs-web--pin-key buffer)) (pinned (emacs-web--pinned-p buffer))) (when (string-empty-p name) (user-error "No name given")) (with-current-buffer buffer (rename-buffer name t)) (when (and pinned (not (equal key (emacs-web--pin-key buffer)))) (setq emacs-web--pins (delete key emacs-web--pins)) (emacs-web--set-pinned buffer t)) nil)) (defun emacs-web-kill-question (buffer) "Return what to ask before killing BUFFER, or nil if nothing is lost." (let ((info (emacs-web--describe buffer))) (cond ((plist-get info :modified) (format "%s has unsaved changes. Kill it?" (buffer-name buffer))) ((memq (plist-get info :process) '(busy idle done blocked error)) (format "%s has a running process. Kill it?" (buffer-name buffer)))))) (defun emacs-web-kill (buffer) "Kill BUFFER, without asking: the page has asked already." (emacs-web--done (emacs-web--act 'kill buffer))) (defconst emacs-web--completions-max 500 "How many completions a prompt offers the phone at most.") (defun emacs-web--completions (table pred) "Return the completions of TABLE that PRED lets through, as the phone shows them. The value is a vector of alists of label and note, the note being what the table's annotation function adds, in the table's own order if it has one, else alphabetical; at most `emacs-web--completions-max'." (let* ((md (completion-metadata "" table pred)) (sort (completion-metadata-get md 'display-sort-function)) (note (or (completion-metadata-get md 'annotation-function) (plist-get completion-extra-properties :annotation-function))) (all (all-completions "" table pred))) (vconcat (mapcar (lambda (c) `((label . ,(substring-no-properties c)) (note . ,(when-let* ((n (and note (ignore-errors (funcall note c)))) (n (string-trim (substring-no-properties n))) ((not (string-empty-p n)))) n)))) (seq-take (if sort (funcall sort all) (sort all #'string<)) emacs-web--completions-max))))) (defvar emacs-web--prompt-completions nil "The completions of the prompt last asked, as (TABLE PROMPT . COMPLETIONS).") (defun emacs-web--prompt () "Return what Emacs asks in its minibuffer, as an alist, or nil. It has the prompt, the kind of answer wanted, `yes-no', `y-n', `password' or `text', what is typed so far, except a password, and the choices of a prompt that completes, as `emacs-web--completions' gives them, with the `emacs-web--completion-actions' for them." (when-let* ((window (active-minibuffer-window))) (with-current-buffer (window-buffer window) (let* ((prompt (or (minibuffer-prompt) "")) (kind (cond ((or (bound-and-true-p read-passwd-mode) (and (boundp 'read-passwd-map) (eq (current-local-map) read-passwd-map))) 'password) ((or (and (boundp 'y-or-n-p-map) (memq y-or-n-p-map (list (current-local-map) (keymap-parent (current-local-map))))) (string-match-p (rx "(y or n)" (* " ") eos) prompt)) 'y-n) ((string-match-p (rx "(yes or no)" (* " ") eos) prompt) 'yes-no) (t 'text)))) `((active . t) (prompt . ,(string-trim prompt)) (kind . ,(symbol-name kind)) (input . ,(if (eq kind 'password) "" (minibuffer-contents-no-properties))) ,@(when (and (eq kind 'text) minibuffer-completion-table) (unless (and (eq (car emacs-web--prompt-completions) minibuffer-completion-table) (equal (cadr emacs-web--prompt-completions) prompt)) (setq emacs-web--prompt-completions (cons minibuffer-completion-table (cons prompt (emacs-web--completions minibuffer-completion-table minibuffer-completion-predicate))))) `((choices . ,(cddr emacs-web--prompt-completions)) (actions . ,(emacs-web--completion-actions))))))))) (defun emacs-web-embark-collect-label () "Return \"Embark Collect\" if Embark can collect candidates." (and (fboundp 'embark--collect) "Embark Collect")) (declare-function embark--collect "ext:embark") (declare-function embark--descriptive-buffer-name "ext:embark") (defun emacs-web-embark-collect () "Collect the minibuffer's candidates in an Embark Collect buffer; return it." (embark--collect (embark--descriptive-buffer-name 'collect))) (defun emacs-web--completion-actions () "Return the `emacs-web-completion-actions' offered now. The value is a vector of alists of label and index, the entry's place." (let ((n -1)) (vconcat (delq nil (mapcar (lambda (action) (cl-incf n) (when-let* ((label (if (functionp (car action)) (funcall (car action)) (car action)))) `((label . ,label) (index . ,n)))) emacs-web-completion-actions))))) (defvar emacs-web--open-next nil "A buffer whose page the phone is to open next, or nil.") (defun emacs-web--take-open-next () "Return the link to the page the phone is to open next, or nil, once." (prog1 (and (buffer-live-p emacs-web--open-next) (emacs-web--href (emacs-web--describe emacs-web--open-next))) (setq emacs-web--open-next nil))) (defun emacs-web--run-completion-action (index) "Run the entry at INDEX in `emacs-web-completion-actions' in the minibuffer. A buffer it returns is for the phone to open, and the question is left." (when-let* ((window (active-minibuffer-window)) (action (and (natnump index) (nth index emacs-web-completion-actions)))) (let ((result (with-selected-window window (condition-case err (funcall (cdr action)) (error (message "emacs-web: %s" (error-message-string err)) nil))))) (when (and (bufferp result) (buffer-live-p result)) (setq emacs-web--open-next result) (abort-recursive-edit))))) (defun emacs-web--answer-prompt (args) "Answer what Emacs asks in its minibuffer with ARGS' text, or cancel it. ARGS may name instead a command of `emacs-web-completion-actions' to run there. It happens a moment later: leaving the minibuffer leaves all that runs within it, the request asking for that too." (let ((text (alist-get 'text args)) (cancel (alist-get 'cancel args)) (action (alist-get 'action args))) (run-at-time 0 nil (lambda () (when-let* ((window (active-minibuffer-window))) (cond (action (emacs-web--run-completion-action action)) (cancel (abort-recursive-edit)) (t (with-selected-window window (delete-minibuffer-contents) (insert (or text "")) (exit-minibuffer))))))))) (defun emacs-web--act (action buffer &optional args) "Do ACTION to BUFFER, with ARGS an alist from the request. ACTION is `kill', `save' or `send', which puts ARGS' text at the end of the buffer; for a terminal it types the text and RET instead. A {key sequence} is typed into Emacs as it stands, in either, and a form `emacs-web--sexp' takes is evaluated there. There are also `key' (its key), `click' (on the cell its col and from_end name) and `size' (its cols and rows); and for any other buffer `point' (to its line and col, counted from beg). Return nil when done, or a message saying why not." (pcase action ((and 'key (guard (not (emacs-web--sendable-p buffer)))) (let ((key (alist-get 'key args))) (if (not (emacs-web--valid-key-p key (alist-get 'mods args))) "No such key" (emacs-web--run-keys buffer (cadr (assoc key (mapcar #'cdr emacs-web--keys))))))) ((and (or 'click 'size) (guard (not (emacs-web--sendable-p buffer)))) "The buffer takes no input") ((and 'point (guard (not (emacs-web--sendable-p buffer)))) (let ((beg (alist-get 'beg args)) (line (alist-get 'line args)) (col (alist-get 'col args))) (if (not (and (natnump beg) (natnump line) (natnump col))) "No place given" (with-current-buffer buffer (let ((pos (save-restriction (widen) (save-excursion (goto-char (min (max beg (point-min)) (point-max))) (forward-line line) (min (+ (point) col) (line-end-position)))))) (goto-char pos) (dolist (window (get-buffer-window-list buffer nil t)) (set-window-point window pos)))) nil))) ((and 'send (guard (not (emacs-web--sendable-p buffer)))) (with-current-buffer buffer (cond ((string-match emacs-web--keys-regexp (or (alist-get 'text args) "")) (emacs-web--run-keys buffer (match-string 1 (alist-get 'text args)))) (buffer-read-only "The buffer is read-only") (t ;; it goes in at point, the next message on the line after it (goto-char (emacs-web--point buffer)) (let ((start (point))) (insert (or (alist-get 'text args) "")) (unless (bolp) (insert "\n")) (let ((emacs-web-sent-columns (let ((cols (alist-get 'cols args))) (and (natnump cols) (<= 10 cols 500) cols)))) (run-hook-with-args 'emacs-web-after-send-functions start))) (dolist (window (get-buffer-window-list buffer nil t)) (set-window-point window (point))) nil)))) ('key (let ((key (alist-get 'key args)) (mods (alist-get 'mods args))) (if (not (emacs-web--valid-key-p key mods)) "No such key" (with-current-buffer buffer (ghostel-send-key key (and (not (equal mods "")) mods))) nil))) ('click (emacs-web--catch-up buffer) (with-current-buffer buffer (let ((row (emacs-web--screen-row (buffer-substring-no-properties (point-min) (point-max)) (alist-get 'from_end args) ghostel--term-rows)) (col (alist-get 'col args))) (cond ((not (and row (natnump col))) "That line is not on the screen") ;; a program that takes no taps leaves the tap to the page ((ghostel--mouse-event ghostel--term 0 1 row col 0) (ghostel--mouse-event ghostel--term 1 1 row col 0) nil))))) ('size (let ((cols (alist-get 'cols args)) (rows (alist-get 'rows args))) (cond ((not (and (natnump cols) (natnump rows))) "No size given") (t (when (get-buffer-window buffer t) (cl-pushnew buffer emacs-web--resized)) (with-current-buffer buffer (ghostel--set-size-with-cell-dims ghostel--term (min 200 (max 5 rows)) (min 300 (max 20 cols)))) (emacs-web--catch-up buffer) nil)))) ('send (let ((text (alist-get 'text args))) (cond ((string-match emacs-web--keys-regexp (or text "")) (emacs-web--run-keys buffer (match-string 1 text))) (t (with-current-buffer buffer (unless (string-empty-p (or text "")) (ghostel-paste-string text))) ;; a terminal app may take a paste and a key in one read as all paste (run-at-time 0.1 nil (lambda () (when (buffer-live-p buffer) (with-current-buffer buffer (ghostel-send-key "return"))))) nil)))) ('kill ;; asked already; nobody is at the Emacs end to answer (let ((kill-buffer-query-functions nil)) (with-current-buffer buffer (set-buffer-modified-p nil)) (kill-buffer buffer)) (and (buffer-live-p buffer) "Emacs refused to kill the buffer")) ('save (with-current-buffer buffer (cond ((not buffer-file-name) "The buffer visits no file") ((not (verify-visited-file-modtime buffer)) "The file changed on disk since it was visited") (t (let ((inhibit-message t)) (save-buffer)) nil)))))) (defun emacs-web--send-json (proc code payload) "Send PAYLOAD as JSON with status CODE to PROC." (ws-response-header proc code '("Content-Type" . "application/json; charset=utf-8") '("Cache-Control" . "no-store")) (ws-send proc (encode-coding-string (json-encode payload) 'utf-8))) (defun emacs-web--parse-json (body) "Return BODY, a request's JSON body, as an alist, or nil." (ignore-errors (and body (not (string-empty-p body)) (let ((json-object-type 'alist)) ;; the body arrives as the bytes sent, in UTF-8 (json-read-from-string (decode-coding-string body 'utf-8)))))) (defun emacs-web--close (proc) "Close PROC, a connection answered after its handler returned." (when-let* ((server (plist-get (process-plist proc) :server))) (with-slots (requests) server (setq requests (cl-remove-if (lambda (r) (eql proc (process r))) requests)))) (delete-process proc)) (defun emacs-web--with-body (request fn) "Call FN with REQUEST's whole body, once all of it has arrived. The server hands over only what came with the headers. FN returns `:keep-alive' when it answers later, and so does this when the body is still on its way; the connection is closed once FN has answered." (with-slots (process headers body) request (let ((need (string-to-number (or (cdr (assoc :CONTENT-LENGTH headers)) "0"))) (got (or body ""))) (if (>= (string-bytes got) need) (funcall fn got) (set-process-filter process (lambda (proc string) (setq got (concat got string)) (when (>= (string-bytes got) need) (set-process-filter proc #'ignore) (unless (eq (condition-case err (funcall fn got) (error (emacs-web--send-json proc 500 `((error . ,(error-message-string err)))))) :keep-alive) (emacs-web--close proc))))) :keep-alive)))) (defun emacs-web--audio-file (type dir) "Return a file in DIR to hold audio of content TYPE." (expand-file-name (concat "voice." (cond ((string-search "webm" type) "webm") ((string-search "ogg" type) "ogg") ((string-search "wav" type) "wav") (t "m4a"))) dir)) (defun emacs-web--collapse-repeats (text) "Return TEXT with a word or phrase said over and over said once. Whisper can fall into saying the same words again and again; a run of more than three of the same, up to eight words long, is one of them." (let* ((words (vconcat (split-string text " " t))) (key (lambda (w) (downcase (replace-regexp-in-string (rx punct) "" w)))) (out nil) (i 0)) (while (< i (length words)) (let ((skip nil)) (cl-loop for n from 1 to 8 while (not skip) do (let ((times 1)) (while (and (<= (+ i (* (1+ times) n)) (length words)) (cl-loop for k below n always (equal (funcall key (aref words (+ i k))) (funcall key (aref words (+ i (* times n) k)))))) (cl-incf times)) (when (> times 3) (dotimes (k n) (push (aref words (+ i k)) out)) (setq skip (* times n))))) (if skip (setq i (+ i skip)) (push (aref words i) out) (cl-incf i)))) (string-join (nreverse out) " "))) (defun emacs-web--transcribe-prompt (context) "Return the prompt for Whisper, naming CONTEXT, the buffer spoken to." (concat emacs-web-transcribe-prompt (if (and (stringp context) (not (string-empty-p context))) (format " Now in the buffer %s." context) ""))) (defconst emacs-web--transcribe-script "import json, sys import mlx.core as mx import mlx_whisper from mlx_whisper.audio import load_audio, log_mel_spectrogram, pad_or_trim, N_FRAMES, N_SAMPLES from mlx_whisper.transcribe import ModelHolder audio_file, model_path, languages, prompt = sys.argv[1:5] model = ModelHolder.get_model(model_path, mx.float16) audio = load_audio(audio_file) mel = log_mel_spectrogram(audio, n_mels=model.dims.n_mels, padding=N_SAMPLES) _, probs = model.detect_language(pad_or_trim(mel, N_FRAMES, axis=-2).astype(mx.float16)) language = max(languages.split(','), key=lambda l: probs.get(l, 0)) result = mlx_whisper.transcribe( audio, path_or_hf_repo=model_path, language=language, condition_on_previous_text=False, initial_prompt=prompt if language == 'en' and prompt else None) print(json.dumps({'language': language, 'text': result['text']}, ensure_ascii=False)) " "Python turning the audio file its first argument names into text. It writes JSON of the language it heard, of those its third argument lists, and the text; the second argument is the model, the fourth the prompt for English.") (defun emacs-web--transcribe (proc args) "Answer PROC with the text spoken in the audio ARGS carry, as JSON. ARGS holds the audio in base64, its content type and the context, the buffer it is meant for. Return `:keep-alive': the answer comes once the text is ready." (let ((audio (alist-get 'audio args))) (if (not (stringp audio)) (progn (emacs-web--send-json proc 400 '((error . "No audio"))) nil) (let* ((dir (make-temp-file "emacs-web-voice" t)) (file (emacs-web--audio-file (or (alist-get 'type args) "") dir)) (out (generate-new-buffer " *emacs-web-transcribe*")) (err (generate-new-buffer " *emacs-web-transcribe-err*"))) (let ((coding-system-for-write 'binary)) (with-temp-file file (set-buffer-multibyte nil) (insert (base64-decode-string audio)))) (make-process :name "emacs-web-transcribe" :buffer out :stderr err :connection-type 'pipe :coding 'utf-8 :command (append emacs-web-transcribe-command (list "-c" emacs-web--transcribe-script file emacs-web-transcribe-model (string-join emacs-web-transcribe-languages ",") (emacs-web--transcribe-prompt (alist-get 'context args)))) :sentinel (lambda (p _) (unless (process-live-p p) (let ((heard (and (eql (process-exit-status p) 0) (emacs-web--json (with-current-buffer out (car (last (split-string (buffer-string) "\n" t)))))))) (when (process-live-p proc) (if (stringp (alist-get 'text heard)) (emacs-web--send-json proc 200 ;; Whisper puts a line per stretch of speech `((text . ,(emacs-web--collapse-repeats (string-trim (replace-regexp-in-string (rx (* (any " \t")) "\n" (* (any " \t\n"))) " " (alist-get 'text heard))))) (language . ,(alist-get 'language heard)))) (emacs-web--send-json proc 500 `((error . ,(concat "Transcription failed: " (with-current-buffer err (string-trim (buffer-substring (max (point-min) (- (point-max) 300)) (point-max))))))))) (emacs-web--close proc)) (kill-buffer out) (when (buffer-live-p err) (kill-buffer err)) (delete-directory dir t))))) :keep-alive)))) (defun emacs-web--handle-post (proc headers body) "Answer the POST whose HEADERS came over PROC. It is an action on a buffer, or speech to turn into text. BODY is the request's JSON body as an alist." (pcase (funcall (if emacs-web--shared #'emacs-web--shared-route #'emacs-web--route) (or (cdr (assoc :POST headers)) "")) ;; a page of ours sets it; a form posted from elsewhere cannot ((guard (not (equal (cdr (assoc :X-EMACS-WEB headers)) "1"))) (emacs-web--send-json proc 403 '((error . "Not from this app")))) ;; text to a shared buffer goes in as text: no forms, no Emacs keys ((and (guard emacs-web--shared) (guard (let ((text (or (alist-get 'text body) ""))) (or (emacs-web--sexp text) (string-match-p emacs-web--keys-regexp text))))) (emacs-web--send-json proc 403 '((error . "A shared buffer takes text only")))) ('transcribe (emacs-web--transcribe proc body)) (`(act upload ,_) (condition-case err (emacs-web--send-json proc 200 `((ok . t) (name . ,(emacs-web--upload body)))) (error (emacs-web--send-json proc 400 `((error . ,(error-message-string err))))))) ('prompt (if (not (active-minibuffer-window)) (emacs-web--send-json proc 409 '((error . "Emacs asks nothing now"))) (emacs-web--answer-prompt body) (emacs-web--send-json proc 200 '((ok . t))))) (`(act menu (buffer ,name ,id)) (if-let* ((buffer (emacs-web--target name id))) (pcase (emacs-web--run-buffer-action buffer (alist-get 'action body) (alist-get 'input body)) (`(error . ,why) (emacs-web--send-json proc 409 `((error . ,why)))) ('(gone) (emacs-web--send-json proc 200 '((ok . t) (gone . t)))) (`(ok . ,href) (emacs-web--send-json proc 200 `((ok . t) (href . ,href))))) (emacs-web--send-json proc 404 '((error . "The buffer is gone"))))) ('run (pcase (emacs-web--run (alist-get 'action body)) (`(error . ,why) (emacs-web--send-json proc 409 `((error . ,why)))) (`(ok . ,href) (emacs-web--send-json proc 200 `((ok . t) (href . ,href)))))) ((and `(act send (buffer ,name ,id)) (guard (emacs-web--sexp (alist-get 'text body)))) (if-let* ((buffer (emacs-web--target name id))) (pcase-let ((`(,text . ,show) (emacs-web--eval buffer (emacs-web--sexp (alist-get 'text body)) (alist-get 'quiet body)))) (emacs-web--send-json proc 200 `((ok . t) (result . ,text) (href . ,(and show (emacs-web--href (emacs-web--describe show))))))) (emacs-web--send-json proc 404 '((error . "The buffer is gone"))))) (`(act row (buffer ,name ,id)) (let ((buffer (emacs-web--target name id)) (pos (alist-get 'pos body))) (cond ((not buffer) (emacs-web--send-json proc 404 '((error . "The buffer is gone")))) ((not (and (natnump pos) (emacs-web--rows-p buffer))) (emacs-web--send-json proc 409 '((error . "No row to press")))) (t (pcase-let ((`(,text . ,show) (emacs-web--eval buffer `(emacs-web--press-row ,pos) t))) (emacs-web--send-json proc 200 `((ok . t) (result . ,text) (href . ,(and show (not (eq show buffer)) (emacs-web--href (emacs-web--describe show))))))))))) (`(act ,action (buffer ,name ,id)) (if-let* ((buffer (emacs-web--target name id))) (if-let* ((why (emacs-web--act action buffer body))) (emacs-web--send-json proc 409 `((error . ,why))) (emacs-web--send-json proc 200 '((ok . t)))) (emacs-web--send-json proc 404 '((error . "The buffer is gone"))))) (_ (emacs-web--send-json proc 404 '((error . "No such action")))))) (defun emacs-web--shared-p (buffer) "Non-nil if BUFFER is shared." (buffer-local-value 'emacs-web-shared buffer)) (defun emacs-web--shared-route (path) "Return what PATH asks the server of the shared buffers for, or nil. That is the list of them, the app's assets, a shared buffer's pages, text and keys for it - keys and taps only for a terminal - and dictation turned into text." (let ((route (emacs-web--route path)) (shared (lambda (name id) (when-let* ((buffer (emacs-web--target name id))) (and (emacs-web--shared-p buffer) buffer))))) (pcase route ((or 'index 'transcribe `(asset ,_)) route) ((or `(buffer ,name ,id) `(chat ,name ,id)) (and (funcall shared name id) route)) (`(act send (buffer ,name ,id)) (and (funcall shared name id) route)) (`(act ,(or 'key 'click 'size) (buffer ,name ,id)) (when-let* ((buffer (funcall shared name id))) (and (emacs-web--sendable-p buffer) route)))))) (defun emacs-web--shared-index () "Return the page listing the shared buffers." (let ((infos (mapcar #'emacs-web--describe (seq-filter #'emacs-web--shared-p (emacs-web--buffers))))) (emacs-web--page "" "Shared" (concat "
    \n

    Shared

    \n" (if infos (concat "
    " (mapconcat (lambda (i) (emacs-web--buffer-row i "")) infos "\n") "
    \n") "

    Nothing is shared

    ")) (concat emacs-web--swipe-script emacs-web--pull-script)))) (defun emacs-web--handle-shared (request) "Answer REQUEST to the server of the shared buffers." (let ((emacs-web--shared t)) (emacs-web--handle request))) (defun emacs-web--handle (request) "Answer REQUEST." (with-slots (process headers) request (condition-case err (if (assoc :POST headers) (let ((shared emacs-web--shared)) (emacs-web--with-body request (lambda (body) ;; the body may come after this returns (let ((emacs-web--shared shared)) (emacs-web--handle-post process headers (emacs-web--parse-json body)))))) (pcase (funcall (if emacs-web--shared #'emacs-web--shared-route #'emacs-web--route) (or (cdr (assoc :GET headers)) "")) ('index (emacs-web--send process 200 (if emacs-web--shared (emacs-web--shared-index) (emacs-web--index-page (emacs-web--infos))))) (`(asset ,file) (pcase-let ((`(,_ ,type ,var ,encoding) (assoc file emacs-web--asset-types))) (ws-response-header process 200 (cons "Content-Type" type) '("Cache-Control" . "max-age=86400")) (ws-send process (if encoding (base64-decode-string (symbol-value var)) (encode-coding-string (symbol-value var) 'utf-8))))) (`(upload ,name) (let ((file (expand-file-name name emacs-web-upload-directory))) (if (not (file-regular-p file)) (ws-send-404 process) (ws-response-header process 200 (cons "Content-Type" (or (mailcap-file-name-to-mime-type file) "application/octet-stream")) '("Cache-Control" . "public, max-age=31536000, immutable")) (ws-send process (with-temp-buffer (set-buffer-multibyte nil) (insert-file-contents-literally file) (buffer-string)))))) ('prompt (emacs-web--send-json process 200 (append (or (emacs-web--prompt) '((active . :json-false))) (when-let* ((href (emacs-web--take-open-next))) `((open . ,href)))))) ('all (emacs-web--send process 200 (emacs-web--list-page "All Buffers" (emacs-web--infos) ""))) (`(mode ,major) (emacs-web--send process 200 (emacs-web--list-page major (seq-filter (lambda (i) (equal major (plist-get i :major))) (emacs-web--infos)) "../"))) (`(chat ,name ,id) (let ((buffer (emacs-web--target name id)) (up (emacs-web--up id))) (if buffer (emacs-web--send process 200 (emacs-web--chat-page buffer up)) (emacs-web--send process 404 (emacs-web--missing-page up))))) (`(buffer ,name ,id) (let ((buffer (emacs-web--target name id)) (up (emacs-web--up id))) (if buffer (emacs-web--send process 200 (emacs-web--buffer-page buffer up)) (emacs-web--send process 404 (emacs-web--missing-page up))))) (_ (ws-send-404 process)))) (error (emacs-web--send process 500 (emacs-web--page "" "Error" (format "
    \n

    Error

    \n

    %s

    " (emacs-web--escape (error-message-string err))))))))) (defun emacs-web-start () "Start the buffer browser, replacing one already running." (interactive) (emacs-web-stop) (setq emacs-web--process (ws-start #'emacs-web--handle emacs-web-port nil :host "127.0.0.1")) (when emacs-web-shared-port (setq emacs-web--shared-process (ws-start #'emacs-web--handle-shared emacs-web-shared-port nil :host "127.0.0.1"))) ;; errors go to the log, every connection accepted does not (dolist (server (delq nil (list emacs-web--process emacs-web--shared-process))) (process-put (process server) :log-buffer (get-buffer-create "*emacs-web*"))) (setq emacs-web--activity-timer (run-at-time 0 1 #'emacs-web--note-activity)) (add-function :after after-focus-change-function #'emacs-web--focus-changed) ;; using Emacs again means wanting its own sizes back (add-hook 'pre-command-hook #'emacs-web--give-back-sizes) (with-eval-after-load 'ghostel (advice-add 'ghostel--adjust-size :around #'emacs-web--hold-size)) (message "emacs-web listening on 127.0.0.1:%d" emacs-web-port) emacs-web--process) ;;;###autoload (defun emacs-web-stop () "Stop the buffer browser if it is running." (interactive) (when emacs-web--process (ignore-errors (ws-stop emacs-web--process)) (setq emacs-web--process nil)) (when emacs-web--shared-process (ignore-errors (ws-stop emacs-web--shared-process)) (setq emacs-web--shared-process nil)) (when emacs-web--activity-timer (cancel-timer emacs-web--activity-timer) (setq emacs-web--activity-timer nil)) (remove-function after-focus-change-function #'emacs-web--focus-changed) (remove-hook 'pre-command-hook #'emacs-web--give-back-sizes) (advice-remove 'ghostel--adjust-size #'emacs-web--hold-size) (emacs-web--give-back-sizes) (when (frame-live-p emacs-web--render-frame) (delete-frame emacs-web--render-frame)) (setq emacs-web--render-frame nil) (when (frame-live-p emacs-web--keys-frame) (delete-frame emacs-web--keys-frame)) (setq emacs-web--keys-frame nil)) (provide 'emacs-web) ;;; emacs-web.el ends here