35 Commits
Author SHA1 Message Date
HomerandClaude Opus 5.5 6bad293b29 Subagents by name; the activity panel as the reference: header button, compact, tasks
ci / check (push) Failing after 3s
ci / start (push) Failing after 4s
- "Subagents" everywhere a person reads it (chip, popover with Fewest/Most, scheduled task,
  panel); offered per the server's `subagents` on a model or chat where it says it, else as
  before; the fields' most is the server's cap, else 32. No subagent-model setting.
- Panel: always reachable from the header's button; the line above the composer only while
  something runs; Environment for an agent chat; Context with "N until auto-compact" and a
  Compact button (POST /chats/{chat}/compact; a server without it is said so); Background
  tasks under Running with elapsed time and a stop square each, unfolding to kind, step,
  live text and conversation, nested tasks folded under their parent, Finished N folded;
  Room and Plan as sections.
- i18n: 24 new sentences x 8 languages, 9 retired; CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:55:09 +00:00
HomerandClaude Opus 5.5 4058b4b018 LLeMbas Desktop 1.0.0-beta.4: the look of LLeMbas 2.0.0-beta.6
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
release / linux (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:11:51 +00:00
HomerandClaude Opus 5.5 8590792b50 Scheduled: the server's real /api/v2 routes and shapes, edit, settings, results, models
ci / check (push) Failing after 8s
ci / start (push) Failing after 5s
Re-wired to api/v2/schedules.py and its contract fixtures: description and
next_run(s), pause/resume verbs, runs paged with `before`, a run's result
(GET runs/{id}, Markdown) unfolded under its row, run.model with the switch from
a busy one, waiting and its retry, timing (late, caught up, by hand). The form
serves new and edit: words -> compile -> title, instruction and next runs; the
rule set by hand (days and a time, read back by preview) when the words give
none; notify policy, fresh or continue, model from the scheduling models,
effort and team size. Models on the list: the person's narrowing (PUT
/schedules/models), each free or busy. Live from the `schedules` event.
i18n: 39 new sentences x 8 languages (312 in all); CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:37:21 +00:00
HomerandClaude Opus 5.5 5ce5a62dff watch: scheduled results from the server's /events stream, told by the task's notify policy
The app follows GET /api/v2/events instead of asking /schedules every 15 s; each
`schedules` frame goes to the page (the Scheduled page redraws from it) and a
schedule's newly finished last run is told by the server's decision: `notified`
when it already told a device, else its `notify` policy (matters: a result, not
a silent one; always: silent too; never: nothing), and a failure the first time
in a row and on the third, which asks whether to pause. A run is told once, also
when it finished under the id it had while running. Signing out or another
server ends the stream; a server without it is asked again every 5 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:37:21 +00:00
HomerandClaude Opus 5.5 9ddde6c870 README for the new shape; the sidebar's button unseen before sign-in
ci / check (push) Failing after 2s
ci / start (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:04:34 +00:00
HomerandClaude Opus 5.5 a13e437823 Scheduled list: centred, the next run as a time from now (the date in its title)
ci / start (push) Failing after 3s
ci / check (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:04:09 +00:00
HomerandClaude Opus 5.5 89806982a9 watch: a type for a test's last run (clippy type_complexity)
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:02:06 +00:00
HomerandClaude Opus 5.5 6a095cb102 Translations and CHANGELOG for the redesign, the agent's link and Scheduled
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
51 sentences of the app's own in all eight languages (gen-i18n.py OWN), the rest from the
server's catalogues; 11 sentences no page uses any more are gone. The title bar's pills stay
inside its height at touch sizes; a failed scheduled run has its own notification words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:01:52 +00:00
HomerandClaude Opus 5.5 ad36932c9f Scheduled: the list, a task's page with its history and Run now, a new one said in words
ci / check (push) Failing after 2s
ci / start (push) Failing after 3s
Built against the scheduling plan's /api/v2 routes (GET/POST /schedules, /schedules/compile,
PATCH/DELETE /schedules/{id}, POST /schedules/{id}/run); a server without them answers 404 and
the page says so. A new task is words, the server's reading of them (the rule in words, the
instruction, the next five runs), then Save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:58:52 +00:00
HomerandClaude Opus 5.5 af14f4a229 A chat app's shape: sidebar, a new chat as a chat page, the chat's settings as composer chips
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s
- Sidebar: New chat, search, Scheduled, Files, the chats of both kinds by day (Today, Earlier),
  Settings with the account at the foot; folds away (Ctrl+B), a drawer on a narrow window.
- A new chat is the chat page empty: a greeting, Agent or Chat, and the composer.
- The composer is a card: the text, then attach, the chips (folder and mode for an agent chat,
  model with its picture, effort as the model offers it, the team's size), and a round Send that
  Stop replaces while a reply is written. An open chat's chips change the chat.
- Messages: the person's in a bubble on the right with attached pictures as thumbnails, a reply
  full width; thinking and tool calls as compact rows that unfold.
- Activity: a chip above the composer for every open chat, the panel on the right as before.
- The agent: a chip in the title bar opening its state and the one thing to do about it; "This
  computer is not linked yet" with Link this computer right above the composer of an agent chat,
  and "refused" only with the server's reason.
- Files: a page with the two trees as tabs. Settings: grouped, the groups listed beside them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:58:46 +00:00
HomerandClaude Opus 5.5 5b00822551 Attached pictures through the bridge, and a notification for a scheduled task's result
ci / check (push) Failing after 4s
ci / start (push) Failing after 2s
The picture command also serves a message's attached file (/attachments/<id>, one plain id,
8 MB at most) as a data: URL, for thumbnails; the page still reaches the server only through
the bridge.

The watch asks /schedules beside the chats and tells, by the schedule's title, of a new run the
server says is worth telling (`notified`); a silent run never, a server without the routes
never.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:50:46 +00:00
HomerandClaude Opus 5.5 1749d2369e The agent's link: asked for whenever it does not work, and "refused" only for the server's word
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s
The sign-in asked for the agent's token only when the CLI had no login for the server on file,
so a computer with an old, refused login got the app's token alone, and the agent stayed
refused. It now asks unless the CLI says the link works, and hands the token over (the CLI's
--token-stdin login replaces the one it had) or gives it up when the link came up meanwhile.

Link this computer is a device login for the agent's scopes alone, handed to the CLI the same
way, with a cancel: `lembas login <server>` only refreshed a login the CLI already had.

A service that ends for want of a login is "not linked"; another reason is "stopped"; only the
server's refusal, with its reason, is "refused".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 12:38:21 +00:00
HomerandClaude Opus 5.5 a12ed7ac36 LLeMbas Desktop 1.0.0-beta.3: CLI 2.0.0-beta.3, the look of LLeMbas 2.0.0-beta.5
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s
release / linux (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:01:14 +00:00
HomerandClaude Opus 5.5 b8a4f1bc57 Activity strip: only while an agent is at work, as the server's
ci / check (push) Failing after 2s
ci / start (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:54:38 +00:00
HomerandClaude Opus 5.5 7c0c86ada1 ci-start: a cleanup that cannot fail the check
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s
The start job on d04bc4b saw the app running after 25 s on X11 and Wayland,
then failed in its trap: the document portal had mounted itself at
$XDG_RUNTIME_DIR/doc, and rm cannot remove a mount point. Unmount it first,
and let the cleanup's leftovers be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:54:16 +00:00
HomerandClaude Opus 5.5 de914f4b16 Activity: one stream at a time, none while hidden; the header by the chat's width
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s
The events stream is held while it opens, so a reconnect and the window
showing again cannot open two; while the window is hidden in the tray it is
not reopened (the server polls for each), and showing the window opens it
again. The Thinking switch says 'Think before answering' in the header too:
'Thinking' is the catalogues' word for a reply in progress. The chat's header
restacks by the chat column's own width (a container query at --bp-narrow),
so a chat beside the activity panel keeps its title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:53:40 +00:00
HomerandClaude Opus 5.5 b6d69575f7 Translations and CHANGELOG for the second pass
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s
Every new sentence in the nine languages: 54 more, of which all but four come
from the server's own catalogues (its activity panel, team setting and
Thinking switch use the same words); the four about continuing in a terminal
are the app's own, in all eight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:52:03 +00:00
HomerandClaude Opus 5.5 d04bc4b6bd The activity panel, and continuing an agent chat in a terminal
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
The activity panel beside a chat, from /api/v2's activity routes: Context
(used share, tokens against the window, a segmented bar and a row per
category), Outputs (the files written, lines added and removed, each opening
in the viewer), Background tasks (a team's members as a tree, helpers and
commands: state, elapsed time counting on between reads, model, latest step,
what it is writing, its whole conversation read-only, Stop -- a member after
a confirmation, as it takes those under it), the team's room (who to whom,
reports marked, the words through the page's own markdown) and the plan.

The chat follows GET /chats/{chat}/activity/events while it is open: the strip
above the message box sums the agents up and opens the panel; a new version
reads the panel again while it is open, the costly context part on opening,
after a reply and at most every 15 s. The stream is reopened when the server
closes it, and more slowly while it fails. Beside the chat on a wide window
(the viewer then goes over the chat), over it below --bp-wide, a full-height
sheet at --bp-narrow. The header's Activity button, the palette and Escape
open and close it.

An agent chat with a device session (device_session_id) can be continued in a
terminal: the chat's menu and a header button show 'cd <folder> && lembas -s
<id>' to copy, with lembas the CLI as it starts here (on PATH, else the app's
own copy by its path). Shown rather than run: no terminal opens the same way
on every desktop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:50:46 +00:00
HomerandClaude Opus 5.5 844684a152 Effort as the model offers it, and the team's size beside it
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
/api/v2/models now says each model's levels, start level and control. A model
with one level shows no effort control; off and one effort is a Thinking
switch (a checkbox with the switch role); more keep the selector. In the
chat's header the switch and selector save at once; in the new-chat form they
start at the model's start level and redraw when the model changes.

Where a team is offered -- an open chat asks GET /chats/{chat}/team; a new
chat on the server needs a model that calls tools and serves two requests at
once -- the Agents fewest/most fields sit beside it. The header saves the pair
on change (the server refuses a pair outside its bounds and says them; the
fields go back); a new chat sends them only when set, else the person's
default stands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:43:55 +00:00
HomerandClaude Opus 5.5 917c1e2286 Model pictures: a model's own image, read through the bridge as a data: URL
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s
/api/v2/models names each model's picture (image, readable with the app's
token at /api/v2/models/images/<file>). A command reads it in Rust -- one
plain file name on the app's door, one of the four types the server accepts,
at most its 2 MB -- and hands the page a data: URL, which its policy already
allows. The avatar shows it once it arrives; the coloured initial stays for a
model without one and until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:41:19 +00:00
HomerandClaude Opus 5.5 46b83c3809 agent: a method that takes self is not named from_*
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
clippy's wrong_self_convention, under -D warnings in CI (run 42584657011).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:17:53 +00:00
HomerandClaude Opus 5.5 2a51b4f6d0 Daily use: attachments, edit and regenerate, the chat's menu, model avatars, a palette
ci / check (push) Failing after 4s
ci / start (push) Failing after 3s
- Attachments from the file dialog, a drop on the window (Rust holds the dropped paths; the page
  never names one) or a paste (the bytes as the raw body of the call), sent at once to
  /api/v2/attachments as multipart and shown above the composer.
- Edit message (rewind), Regenerate reply on the last reply, Copy on both.
- The chat's menu (header and a right click in the sidebar): rename, open in the web interface,
  archive, delete.
- The chat's model and effort in its header; effort for a new chat. Model avatars coloured as
  the server's stable_hue colours them (MD5 of the id, checked against Python), mixed into the
  theme's surface so no colour is hard-coded.
- Ctrl+K palette over commands and every chat's title; Ctrl+N, Ctrl+,.
- The agent's new states in the page; 34 new sentences in all eight languages, the server's own
  words where it has them; a Czech row that was Slovak; two untranslated strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:15:31 +00:00
HomerandClaude Opus 5.5 7ee394b549 The agent's status is the CLI's word, not whether a process runs
Desktop said "running" as soon as `lembas service run` was started, while the CLI was still
dialling, waiting for a terminal that holds the link, or refused by the server -- the reason went
only to agent.log. Now the supervisor reads what the CLI itself writes: `service.json` in its
state directory (connecting, linked, waiting + why, stopped + why) for the pid it started, the
line it printed before that, and its last line when it ends. A service the app did not start is
described the same way.

A run the CLI ends with 0 because trying again will not help (a refused token, no login) is no
longer restarted every second: it waits five minutes, or until linking again starts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:15:31 +00:00
HomerandClaude Opus 5.5 2958be0a23 LLeMbas Desktop 1.0.0-beta.2: CLI 2.0.0-beta.2, the look of LLeMbas 2.0.0-beta.4
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
release / linux (push) Failing after 3s
The editor's vendored modules come from server v2.0.0-beta.4 with the
process stub @lezer/lr needs, so languages load and code is highlighted
again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:43:28 +00:00
HomerandClaude Opus 5.5 3b0def78ec fetch-vendor: carry the server's process stub for @lezer/lr
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
lezer-lr.mjs imports esm.sh's /node/process.mjs, which the rewrite left
as an absolute path: a 404 in the app, so every parsing language failed.
It becomes ./node-process.mjs, copied from the server at the same ref,
and an absolute import surviving the rewrite now fails the script.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 10:19:13 +00:00
HomerandClaude Opus 5.5 4c2b0d045b LLeMbas Desktop 1.0.0-beta.1: CLI 2.0.0-beta.1, the look of LLeMbas 2.0.0-beta.1
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
release / linux (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:12:36 +00:00
HomerandClaude Opus 5.5 5c41da8b4d CHANGELOG and README: one approval, the files and show routes, the gate
ci / check (push) Failing after 6s
ci / start (push) Failing after 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:48:00 +00:00
HomerandClaude Opus 5.5 ab0c1434c4 One approval for app and agent; /api/v2 files and show; app_api 2
- The app's device login asks for `app models` and, where discovery says
  login.device.agent_scope, `agent_scope=models memory link` in the same
  approval. The agent's token goes to the bundled CLI on stdin
  (`lembas login --token-stdin --server <url> --json`) and is never kept
  here; if the CLI does not take it, it is given up on the server. A
  computer linked already keeps its link; a server without agent_scope
  gets the old two steps.
- The server pane reads GET /api/v2/files and /files/content (mounts open
  like folders; `withheld` shown in the server's words). The "not offered
  yet" state is gone.
- A show card's file comes from GET /api/v2/chats/{chat}/show?node=&version=
  or ?shown= as plain text (new `api_text` command, 1 MB cap); args.path is
  read only for agent chats on this computer, locally.
- The gate is link protocol 3 plus app_api 2 (APP_API = 2), not the
  server's version number, which says 1.7.2 on dev until its first beta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:48:00 +00:00
HomerandClaude Opus 5.5 b82d4c688a README and CHANGELOG for Desktop 1.0
ci / check (push) Failing after 4s
ci / start (push) Failing after 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 04:55:03 +00:00
HomerandClaude Opus 5.5 7f54d18375 CI on dev, the app started on X11 and Wayland; no silent re-release
ci.yml runs on dev too, adds the hygiene check, check-ui.sh (the modules parsed
as modules; nothing sets HTML) and the vendor pin, and a start job: a debug
build with the bundled page, started under Xvfb and under a headless Weston with
GTK's Wayland backend, which must still be running after 25 seconds.
release.yml refuses a tag that already has a release unless run by hand with
replace, marks betas as pre-releases, and gives a beta's .deb a ~beta version
so it sorts below its release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 04:55:03 +00:00
HomerandClaude Opus 5.5 4b4e63f622 The app's own interface: agent chats first, two explorers, a viewer
Plain HTML, CSS and ES modules in ui/, no build step. The sidebar opens on agent
chats (this computer first), with chats on the server as the second tab; the
transcript draws the /api/v2 steps, follows a reply as it is written, answers
cards, and starts agent chats in one of the agent's folders. Two explorers kept
apart (this computer's, the server's) and a viewer: Markdown drawn by a safe
renderer that never parses HTML, code in read-only CodeMirror with the server's
token colours, HTML in the sealed frame, show calls opened in it. Settings for
the sign-in, the agent and its folders, notifications and updates; no admin
pages. Layout on fixed and shared tracks; the sidebar is a drawer below 48rem,
the viewer goes over the chat below 64rem. All 124 sentences in the server's
nine languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 04:55:03 +00:00
HomerandClaude Opus 5.5 96a2abe7e0 One window, the app's own page; the server through /api/v2
The two webviews (local title bar, remote web UI) become one WebviewWindow on
ui/index.html. The window refuses every navigation but the app's page and its
sealed frames; the capability still names only main and no remote URL.

- server.rs: discovery refuses a server older than 2.0 (protocol 3 and major
  version 2) and an app API newer than this app knows; the app's own device
  login for the app scope, the token kept in account.json (0600) and never
  given to the page; a proxy limited to /api/v2 paths; the SSE stream.
- files.rs: list and read the agent's working directory, resolved (links and
  .. included) and refused outside the agent's folders.
- sealed.rs: HTML for the viewer on its own scheme under the server's sealed
  policy: no bridge, no network, no navigation.
- watch.rs + tauri-plugin-notification: a notification when a reply or an
  agent chat finishes out of sight.
- A failed start and panics go to desktop.log; a missing tray no longer stops
  the app; the DMA-BUF renderer is off under NVIDIA's driver.
- The unstable multiwebview feature, the gtk dependency and the theme report
  are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 04:55:03 +00:00
HomerandClaude Opus 5.5 af48856fcd The server's 2.0 tokens and its CodeMirror build, pinned
ui/tokens.css from the server's dev (tokens.lock.json; repin to the beta tag
at release). scripts/fetch-vendor.sh copies the server's vendored CodeMirror and
Lezer modules into ui/vendor/, each checked against the server's own pin first,
with the bare module names rewritten to sibling files (the page has no import
map); vendor.lock.json records both hashes, and the script with no argument
verifies the copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 04:55:03 +00:00
HomerandClaude Opus 5.5 b849e1a4b8 CHANGELOG 0.7.2: the reason is in the agent's log, not the status
ci / check (push) Failing after 3s
release / linux (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:52:57 +00:00
HomerandClaude Opus 5.5 f58f28a2a8 LLeMbas Desktop 0.7.2, carrying LLeMbas CLI 1.5.2
ci / check (push) Failing after 4s
Linking this computer again leaves one device, and the agent's status says why
it cannot connect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 19:52:30 +00:00
72 changed files with 15601 additions and 1445 deletions

No files matched your search

+40 -7
View File
@@ -1,9 +1,11 @@
# Checks on every push to main and every pull request: formatting, lints, tests, and the page's # Checks on every push to main and dev and every pull request: formatting, lints, tests, the
# JavaScript. The app is built for real only by release.yml, from a tag. # page's scripts, the pins (tokens, CodeMirror), the public-repository hygiene -- and the app
# started for real, on X11 and on Wayland, to prove it gets as far as its window. The packages
# are built only by release.yml, from a tag.
name: ci name: ci
on: on:
push: push:
branches: [main] branches: [main, dev]
pull_request: pull_request:
permissions: permissions:
@@ -23,6 +25,8 @@ jobs:
with: with:
components: rustfmt, clippy components: rustfmt, clippy
cache-workspaces: src-tauri cache-workspaces: src-tauri
- name: Nothing private in a public repository
run: scripts/hygiene.sh
- name: The bundled LLeMbas CLI, verified - name: The bundled LLeMbas CLI, verified
run: scripts/fetch-cli.sh run: scripts/fetch-cli.sh
- name: Formatting - name: Formatting
@@ -34,8 +38,37 @@ jobs:
- name: Tests - name: Tests
working-directory: src-tauri working-directory: src-tauri
run: cargo test --locked run: cargo test --locked
- name: The pages' JavaScript, and the script the content view runs - name: The page's scripts parse, and none sets HTML
run: scripts/check-ui.sh
- name: The design tokens and CodeMirror are the server's, as pinned
run: | run: |
for f in ui/*.js src-tauri/src/*.js; do node --check "$f"; done scripts/fetch-tokens.sh
- name: The design tokens are the server's, as pinned scripts/fetch-vendor.sh
run: scripts/fetch-tokens.sh
# The app started for real: it must still be running after 25 seconds (timeout's 124), and its
# log must not say it stopped. Once under Xvfb (X11) and once under a headless Weston (Wayland,
# GTK's Wayland backend forced), each with a session bus (the tray and the notifications want
# one) and an empty home, so it starts at its first-run page. Built with the custom protocol on,
# as a release is, so the page is the bundled ui/ and not a development server.
start:
runs-on: ubuntu-22.04
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: System libraries, and an X server and a Wayland compositor without a screen
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev \
xvfb weston dbus libgl1-mesa-dri
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: src-tauri
- name: The bundled LLeMbas CLI, verified
run: scripts/fetch-cli.sh
- name: Build (debug, with the bundled page)
working-directory: src-tauri
run: cargo build --locked --features tauri/custom-protocol
- name: Starts on X11
run: scripts/ci-start.sh x11
- name: Starts on Wayland
run: scripts/ci-start.sh wayland
+41 -7
View File
@@ -1,6 +1,6 @@
# A release, from a signed tag vX.Y.Z on main: the Linux packages (.deb and AppImage) built with # A release, from a signed tag vX.Y.Z (stable, main) or vX.Y.Z-beta.N (a beta, marked as a
# the LLeMbas CLI inside, SHA256SUMS over them, and the GitHub Release with the tag message as its # pre-release): the Linux packages (.deb and AppImage) built with the LLeMbas CLI inside,
# notes. LLeMbas Desktop is developed on Gitea, which mirrors its commits and tags here; the builds # SHA256SUMS over them, and the GitHub Release with the tag message as its notes. LLeMbas Desktop is developed on Gitea, which mirrors its commits and tags here; the builds
# happen here because they need a full Rust and WebKitGTK toolchain. # happen here because they need a full Rust and WebKitGTK toolchain.
name: release name: release
on: on:
@@ -11,6 +11,10 @@ on:
tag: tag:
description: "The tag, e.g. v0.1.0" description: "The tag, e.g. v0.1.0"
required: true required: true
replace:
description: "Replace a release that already exists for this tag (people who installed it get no update)"
type: boolean
default: false
permissions: permissions:
contents: write contents: write
@@ -48,6 +52,26 @@ jobs:
run: cargo install tauri-cli --version "^2" --locked run: cargo install tauri-cli --version "^2" --locked
- name: Build - name: Build
run: cargo tauri build --bundles deb,appimage run: cargo tauri build --bundles deb,appimage
# Debian sorts 1.0.0-beta.1 *above* 1.0.0 (what follows the dash is a package revision), so a
# beta's package would make the release after it look like a downgrade, and apt would keep the
# beta. A beta's package version says ~beta, which sorts below.
- name: A beta's .deb sorts below its release
run: |
deb="$(ls src-tauri/target/release/bundle/deb/*.deb)"
have="$(dpkg-deb -f "$deb" Version)"
want="${VERSION/-/\~}"
if [ "$have" != "$want" ]; then
echo "repacking $deb: Version $have -> $want"
work="$(mktemp -d)"
dpkg-deb -R "$deb" "$work/pkg"
sed -i "s/^Version: .*/Version: $want/" "$work/pkg/DEBIAN/control"
dpkg-deb --root-owner-group -b "$work/pkg" "$deb"
fi
base="${VERSION%%-*}"
if [ "$VERSION" != "$base" ]; then
dpkg --compare-versions "$(dpkg-deb -f "$deb" Version)" lt "$base" \
|| { echo "the beta's package does not sort below $base" >&2; exit 1; }
fi
- name: Collect the files and SHA256SUMS - name: Collect the files and SHA256SUMS
run: | run: |
mkdir dist mkdir dist
@@ -62,8 +86,18 @@ jobs:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
git tag -l --format='%(contents)' "$TAG" | sed '/-----BEGIN SSH SIGNATURE-----/,$d' > notes.md git tag -l --format='%(contents)' "$TAG" | sed '/-----BEGIN SSH SIGNATURE-----/,$d' > notes.md
# A tag moved to a fixed commit and pushed again replaces its release: # A version is released once. Installing the same version again over a broken one is a
# the old one, and the files built from the old commit, go first. # no-op for apt ("already the newest version") and the updater offers nothing, so a fix
gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --yes 2>/dev/null || true # is a new version -- never the old one re-released. Replacing is a deliberate act: a
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \ # run by hand with `replace`.
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
if [ "${{ inputs.replace || false }}" != "true" ]; then
echo "A release for $TAG exists already. Release the fix as a new version." >&2
exit 1
fi
gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --yes
fi
pre=()
case "$VERSION" in *-*) pre=(--prerelease) ;; esac
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag "${pre[@]}" \
--title "LLeMbas Desktop $VERSION" --notes-file notes.md dist/* --title "LLeMbas Desktop $VERSION" --notes-file notes.md dist/*
+247
View File
@@ -6,6 +6,253 @@ All notable changes to LLeMbas Desktop. The format follows
## [Unreleased] ## [Unreleased]
### Changed
- **Subagents**, by that name everywhere. The chip under the message box says **Subagents** with the
fewest and the most a chat runs, and opens the two fields under those words; it shows where the
server offers subagents for the chat (for a new chat, for its model), and the most is the
server's cap. Subagents work on the chat's own model unless you name another one in the chat --
there is no setting for a subagent's model. A scheduled task's field is **Subagents** too.
- **The activity panel, reshaped.** A button in the chat's header opens it at any time, and the
line above the message box shows only while something runs ("3 running · 1 queued · 2
finished"). An agent chat's **Environment** (its folder, and where it is) comes first. **Context**
says how far it is to the automatic compaction ("80.6k until auto-compact") beside a **Compact**
button that summarises the earlier turns now. **Background tasks** lists subagents, a team's
members and background commands under **Running**, each with how long it has run and a stop
square, unfolding to what it is, its latest step, what it is writing and its conversation; the
tasks under one fold beneath it ("2 tasks"), and the finished ones fold away under **Finished**.
The team's **Room** and the **Plan** have sections of their own.
## [1.0.0-beta.4] — 2026-11-09
Carries LLeMbas CLI 2.0.0-beta.3 and the look of LLeMbas 2.0.0-beta.6. Scheduled tasks need LLeMbas
2.0.0-beta.6 or later.
### Fixed
- **An agent that could not link now gets its link.** Signing in asked the server for the agent's
token only when the built-in LLeMbas CLI had no login for that server on file -- so a computer
with an old login the server no longer takes got the app's own token alone, and the agent stayed
refused. The sign-in now asks for the agent's token unless the agent's link works right now, and
the CLI's new login replaces the old one. **Link this computer** (and **Link again**) is now its
own approval for the agent's token, handed to the CLI the same way; before, it ran the CLI's own
login, which for a server it was logged in to only refreshed the login it had.
- **"Refused" means the server refused**, and says the server's reason. A service that stopped for
want of a login says *not linked*; any other reason, *stopped* with the reason.
### Changed
- **A new look, the shape of an AI desktop app.** A slim sidebar: **New chat**, a search line,
**Scheduled**, **Files**, then your chats of both kinds -- agent chats and chats on the server --
under *Today* and *Earlier*, an agent chat with its folder's name; **Settings** with your account
at the foot. The sidebar folds away (its button in the title bar, or Ctrl+B) and is a drawer on a
narrow window.
- **A new chat is a chat page.** A greeting, the choice of **Agent** (on this computer) or **Chat**
(on the server), and the message box ready -- no form. What the chat starts with sits under the
text as small chips, each opening a menu: the folder and the mode for an agent chat, the model
with its picture, how hard it thinks (as the model offers it), and the team's size where a team
is offered. An open chat has the same chips, and changing one changes the chat.
- **The conversation** in a centred column: your messages in bubbles on the right, with attached
pictures as thumbnails and other files as tiles; replies full width; thinking and each tool call
a compact row that unfolds. **Send** is a round button that **Stop** replaces while a reply is
written.
- **The activity panel** opens from a chip above the message box, on every open chat; while
agents work, the chip says what they are doing.
- **The agent's status** is a chip in the title bar that opens what the agent says and the one
thing to do about it. When this computer is not linked -- or the server refused its link -- a
new agent chat says so right above the message box, with **Link this computer** (or **Link
again**) there, not only in Settings; while the approval waits, the code to compare is shown
with Cancel.
- **Files** is a page of its own, with this computer's folders and your files on the server as
two tabs. **Settings** is grouped -- Account, Agent, Project folders, Notifications, About --
with the groups listed beside them.
### Added
- **Scheduled tasks** (with a server that has them, LLeMbas 2.0.0-beta.6 or later): the list
with each task's next run, how its last run went and a switch to pause it, kept up to date as
the server runs them; a task's page with what it does, when (and in which time zone), its
model, effort, team and how it tells you, the next runs, and its history -- each run's status,
time, whether it was late, caught up or run by hand, the model it ran on (and that it switched
from a busy one), and its result to unfold in place, with its chat to open; earlier runs on
request. **Run now**, **Edit**, pause and resume, and delete. A new task is said in your own
words: the server reads them into a rule, a title and an instruction and shows the next five
runs before you save; the title and instruction can be changed, and where the words give no
time the days and the time can be set by hand. Each task's settings: when to tell you
(*when it matters*, *always*, *never*), a new chat for each run or one that grows, its model
(or the first free one of the scheduling models), its effort and its team. **Models** on the
list chooses which models scheduled tasks may run on, each shown free or busy.
- **A scheduled task's result comes as a system notification** while the app runs, by the
task's own setting: a result, never a run with nothing to report unless the task says
*always*, nothing for *never*; a failure the first time, and on the third in a row the
question whether to pause it. The app follows the server's event stream for this rather than
asking every few seconds.
## [1.0.0-beta.3] — 2026-11-09
Carries LLeMbas CLI 2.0.0-beta.3 and the look of LLeMbas 2.0.0-beta.5. Needs a LLeMbas 2.0 server
(2.0.0-beta.5 or later for the activity panel, the Thinking switch and team sizes).
### Added
- **Attachments.** A message can carry files: the attach button beside the composer (the
system's file dialog), files dropped anywhere on the window, or an image pasted into the
composer. Each is sent to the server at once and shown above the composer with its size, or
with the server's reason when it refused it; one taken off before sending is deleted on the
server. The app sends only files you chose, dropped or pasted -- the page never names a path.
Agent chats get them too.
- **Edit a message, regenerate a reply, copy either.** Under your messages, *Edit message*
rewrites one in place and starts the reply again from there (everything after it is dropped,
as the app says before you save; on the agent's device too); under the last reply,
*Regenerate reply*. *Copy* puts a message, or a reply's text as Markdown, on the clipboard.
- **The chat's own menu** (⋯ in its header, or a right click on it in the sidebar): rename it,
open it in the web interface in your browser, archive it, delete it (asked first).
- **Change a chat's model** from its header, and how hard it thinks as the model offers it: a
model with one level shows nothing to choose; one that can switch thinking off and has one
effort shows a **Think before answering** switch; more levels keep the selector. A new chat
starts at the model's own start level, and the control follows the model you pick.
- **Agents, fewest and most**, beside it where the chat can have a team (a chat on the server,
on a model that serves at least two requests at once, with subagents on): how many agents the
chat's team may have, its own agent included. Saved at once in the header; on a new chat,
left empty, your default stands. A pair the server will not take is said in its words, and the
fields go back.
- **Model pictures**: a model the server has a picture for shows it in the pickers and under
each reply; the others show their initial on a colour of their own -- the same colour the web
interface gives them.
- **The activity panel** beside a chat (the header's bolt button, the strip above the message
box, or the palette): what is in the chat's context, by category, against the model's window;
the files the chat and its agents wrote, with lines added and removed, each opening in the
viewer; every background agent -- a team's members as a tree, helpers and commands -- with what
it was asked, its state, how long it has run, its model, its latest step and what it is
writing, its whole conversation to read and a button to stop it (a member after you confirm,
for those under it stop with it); the team's room, who wrote to whom and which messages are
reports; and the plan. It keeps up by itself while the chat is open. The strip above the
message box says what the agents are doing ("2 agents working · 1 queued") and opens it. On a
narrow window the panel covers the chat; at phone width it fills the window.
- **Continue an agent chat in a terminal**: its menu and its header show the command that opens
the agent's session in a terminal (`lembas -s …`, in the chat's folder), to copy. On this
computer it names the LLeMbas CLI as it starts here -- the one on your PATH, else the app's own.
- **A command palette** (Ctrl+K): new chats, settings, the agent and updates by name, and every
chat of both kinds by its title (accents optional). Ctrl+N starts a new chat of the tab you are
on; Ctrl+, opens the settings.
### Changed
- **The agent's status is the CLI's own word.** The title bar and Settings no longer say
*running* because a process runs: they say what the built-in LLeMbas CLI says of its link to
the server -- connecting, running (linked), waiting and why (the server cannot be reached, the
link dropped, a terminal on this computer holds the link), or refused and why. The same for a
`lembas service` you run yourself. When the CLI says trying again will not help (the server
refused this computer's token), the app no longer restarts it every second: it waits until you
link the computer again. When the agent stops with an error, its last words are the reason
shown, not just an exit status.
### Fixed
- A Czech sentence in the viewer was in Slovak; "Server files" and the composer's placeholder
were not translated.
## [1.0.0-beta.2] — 2026-11-07
Carries LLeMbas CLI 2.0.0-beta.2 and the look of LLeMbas 2.0.0-beta.4.
### Changed
- **The agent keeps what its model has read** (LLeMbas CLI 2.0.0-beta.2, harness spec 3.1.0): the
system prompt is made once per session and sent unchanged, so a local model's server reuses the
whole conversation instead of reading it again; the date and anything that changes during a
session come as a short note on your next message; the tools stay the same for the session;
compaction is the next turn of the same conversation.
### Fixed
- **The editor highlights code again.** One of the editor's modules imported a file the app does
not have, so every language that parses failed to load and files opened as plain text.
## [1.0.0-beta.1] — 2026-11-03
LLeMbas Desktop 1.0 becomes an app of its own: the agent on this computer by default, your
server's chats, two explorers and a viewer -- no longer a window onto the web interface. It needs a
LLeMbas 2.0 server.
Carries LLeMbas CLI 2.0.0-beta.1. Needs a LLeMbas 2.0 server (2.0.0-beta.1 or later).
### Changed
- **The app's own interface replaces the server's web pages.** The window shows the app's page
(`ui/`, plain HTML, CSS and JavaScript modules, no build step): the chats, the transcript with
its steps and tool calls, the reply as it is written, the questions and approvals that wait for
an answer, and a composer. It talks to the server's `/api/v2`.
- **Agent chats first.** The sidebar opens on agent chats, those on this computer above those on
your other devices; a new agent chat is started on this computer, in one of the agent's
folders, with its mode and model. Chats on the server are the second tab.
- **One approval signs the app in and links this computer.** The app asks the server's device
login for its own token (your chats, as the web interface reads and writes them, and dictation)
and, in the same approval, for the agent's; the approval opens in your browser. The agent's
token goes straight to the built-in LLeMbas CLI on its standard input (`lembas login
--token-stdin`), which keeps it in its own configuration as `lembas login` does; the app never
stores it. A computer linked already keeps its link, and **Link again** under Settings is still
there. A server that cannot give both tokens from one approval gets the old two steps.
- The app's own token is not a device: the server lists it under *Apps*, and only the agent's
link is among the devices an agent chat can start on.
- The look is still the server's: its design tokens, now copied from the 2.0 server, and its
CodeMirror build for code (`scripts/fetch-vendor.sh`, pinned in `vendor.lock.json`). The
window takes the account's theme.
- One window with one page: the second webview, and the Linux workaround it needed, are gone. The
window still has the app's own title bar, and its edges resize it.
- The page's words, old and new, are in all nine of the server's languages.
### Added
- **Two explorers, kept apart**: this computer's -- the open agent chat's folder, or one of the
agent's folders -- read only inside the agent's folders, and the server's files, through
`/api/v2/files` with your own access. A server file that is not text, or too large, says so in
your language instead of failing.
- **A viewer** beside the chat: Markdown drawn, code highlighted (read-only CodeMirror), text,
and HTML in a sealed frame on its own address, with no access to the app, no network and no way
to navigate. A file the agent shows (`show`) opens in it: from this computer for an agent chat
running here, otherwise as the server keeps it (`/api/v2/chats/…/show`), checked against the
chat's access when it is opened. A file that is gone, too large or not text says so.
- **Native notifications** when a reply or an agent chat finishes while you are elsewhere (on by
default; Settings, Notifications).
- **A server older than LLeMbas 2.0 is refused** with a message saying which side to update, and so
is a server whose app interface is newer than this app knows. What counts is the link protocol
(3) and the app interface (`app_api` 2), not the server's version number.
- **Settings in the app**: the server and the sign-in, the agent and its folders, notifications
and updates. There are no admin pages.
- When the app cannot start, it says why in `desktop.log` in its log directory, not only on a
terminal nobody sees. A missing tray no longer stops it from starting.
- With NVIDIA's own driver, WebKitGTK's DMA-BUF renderer is turned off before the window opens
(unless you have set `WEBKIT_DISABLE_DMABUF_RENDERER`): that renderer leaves the window blank or
ends the app there, on Wayland most of all.
### Removed
- The server's web interface in the window, the microphone permission it needed, and the theme
report the window read from it. The web interface stays in the browser, and as an installable
web app.
### Fixed
- A release can no longer be quietly replaced under the same version: installing the same version
again does nothing, so people with the broken copy never got the fix. The release workflow now
refuses a tag that has a release already, unless it is run by hand to replace it.
- A beta's `.deb` sorts below the release that follows it (`1.0.0~beta.1` before `1.0.0`), so apt
does not keep a beta over its release.
## [0.7.2] — 2026-10-25
Linking this computer again leaves one device, and LLeMbas CLI 1.5.2 inside.
### Changed
- Carries LLeMbas CLI 1.5.2. Linking this computer again no longer leaves an offline copy of it on
the server holding its agent chats: the old link is signed out, and a LLeMbas server 1.7.2 or
later moves its chats to the new one. When the agent cannot connect, its log (`agent.log` in the
app's log directory) now says why -- a certificate that is not trusted, for instance -- instead
of only "could not reach the instance".
## [0.7.1] — 2026-10-24 ## [0.7.1] — 2026-10-24
The microphone works, and LLeMbas CLI 1.5.1 inside. The microphone works, and LLeMbas CLI 1.5.1 inside.
+81 -28
View File
@@ -1,17 +1,35 @@
# LLeMbas Desktop # LLeMbas Desktop
Your [LLeMbas](https://llembas.eu) server in a window of its own, with the agent on this computer. The [LLeMbas](https://llembas.eu) agent on this computer, and the chats on your LLeMbas server, in
an app of its own.
- **The whole web UI**: chats, agent chats, the library, reports, settings. Same account, models, - **The agent, built in, by default.** LLeMbas Desktop carries the
permissions and credits as in the browser; the app is a front end to the server you already run. [LLeMbas CLI](https://github.com/LLeMbas/LLeMbas-CLI) and runs it in the background as this
- **The agent, built in.** LLeMbas Desktop carries the [LLeMbas CLI](https://github.com/LLeMbas/LLeMbas-CLI) computer's device. Agent chats work in your project folders here, without a terminal -- start
and runs it in the background as this computer's device, so agent chats started on the server them, follow them and answer them in the app.
can work in your projects here, without a terminal. - **Your server's chats** beside them: the same account, models and permissions as in the browser.
- **Its own window**: a LLeMbas title bar with the agent's state and a menu (start and stop the - **A chat app's shape**: a slim sidebar with your chats of both kinds by day, a new chat that is
agent, link the computer, reload, change server, quit). Close the window and the agent keeps a chat page, and the chat's folder, mode, model, effort and team size as chips in the message
running in the tray, which has the same menu. box.
- **Scheduled tasks** (with a server that has them): the list, each task's history and *Run now*,
and a new one said in your own words, with its next runs shown before you save.
- **Files**, two trees kept apart: the agent's folders on this computer, and your files on the
server.
- **A viewer** for what you open and for what the agent shows you: Markdown drawn, code
highlighted, text, and HTML in a sealed frame that can reach neither the app nor the network.
- **Attachments** from the file dialog, a drop on the window or a paste; **edit** a message,
**regenerate** a reply, rename, archive or delete a chat, and open it in the web interface.
- **A command palette** (Ctrl+K) for commands and every chat by its title.
- **The agent's real status**: what the built-in CLI says of its link -- connecting, running,
waiting and why, refused with the server's reason -- not whether a process runs; and, when this
computer is not linked, **Link this computer** right where you start an agent chat.
- **Notifications** when a reply or an agent's work finishes while you are elsewhere.
- **Its own window and tray**: close the window and the agent keeps running in the tray.
> **Alpha.** Linux first (`.deb` and AppImage, x86-64). Windows and macOS later. There are no admin pages: an administrator uses the server's web interface for that.
> **Beta (1.0).** Needs a **LLeMbas 2.0 server** or later; an older one is refused with a message
> saying so. Linux first (`.deb` and AppImage, x86-64). Windows and macOS later.
## Install ## Install
@@ -20,10 +38,14 @@ Download the `.deb` or the AppImage from the
`SHA256SUMS` in the same release. `SHA256SUMS` in the same release.
```sh ```sh
sudo apt install ./llembas-desktop_*_amd64.deb # Debian, Ubuntu (replaces 0.1.0's l-le-mbas) sudo apt install ./llembas-desktop_*_amd64.deb # Debian, Ubuntu
chmod +x llembas-desktop_*.AppImage && ./llembas-desktop_*.AppImage chmod +x llembas-desktop_*.AppImage && ./llembas-desktop_*.AppImage
``` ```
Installing a `.deb` of the version you already have changes nothing (apt calls it "already the
newest version"); `sudo apt reinstall ./llembas-desktop_*_amd64.deb` installs it again. If the app
does not start, the reason is in `~/.local/share/eu.llembas.desktop/logs/desktop.log`.
On GNOME the tray needs the *AppIndicator* extension; without it, the title bar's menu has On GNOME the tray needs the *AppIndicator* extension; without it, the title bar's menu has
everything, and starting the app again brings a hidden window back. everything, and starting the app again brings a hidden window back.
@@ -33,20 +55,41 @@ LLeMbas Desktop looks for a new release when it starts and every six hours. When
**update** button appears in the title bar (and *Install LLeMbas x.y.z…* in its menu; *Check for **update** button appears in the title bar (and *Install LLeMbas x.y.z…* in its menu; *Check for
updates…* otherwise). Installing asks for your password to update the `.deb` (an AppImage updates updates…* otherwise). Installing asks for your password to update the `.deb` (an AppImage updates
itself), then the app starts again. Every package is checked against the release signing key itself), then the app starts again. Every package is checked against the release signing key
built into the app before anything is installed. built into the app before anything is installed. Betas are not offered as updates.
## Use ## Use
1. Start LLeMbas and enter your server's address. Sign in there as you would in a browser. 1. Start LLeMbas and enter your server's address. **Sign in**: the approval page opens in your
2. From the tray, **Link this computer**. The approval page opens in the window; approve it. browser, where you are signed in already; approve the code shown in the app. This one approval
3. The agent starts. Choose the folders it may work in (Settings…, *Add folder…*); a new lets the app read and write your chats, as the web interface does, and links this computer as
computer allows none until you do. the device the agent runs on: the app hands the agent's token to its LLeMbas CLI, which keeps it
4. In the server's Agents tab, start an agent chat on **Local** and pick one of those folders. in its own configuration. The agent starts. (A computer whose link works keeps it; on a server
that cannot give both from one approval, **Link this computer** -- above the message box of a
new agent chat, in the agent's panel in the title bar, or in **Settings** -- is a second one.)
2. Choose the folders the agent may work in (Settings, *Add folder…*); a new computer allows none
until you do.
3. **New chat**, **Agent**: pick one of those folders, a mode and a model in the chips under the
message box, and write.
The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration
(`~/.config/lembas`). The app's copy of the CLI lives in its own directory (`~/.config/lembas`). The app's copy of the CLI lives in its own directory
(`/usr/lib/llembas-desktop/`), not on your `PATH`. If you also installed the CLI and run `lembas service` yourself, the app sees (`/usr/lib/llembas-desktop/`), not on your `PATH`. If you also installed the CLI and run
it and does not start a second one: they are the same device. `lembas service` yourself, the app sees it and does not start a second one: they are the same
device.
### What can reach what
- The app's page is its own (`ui/`), and it is the only content with access to the app. No page
of the server is ever loaded into the window, and the window goes nowhere else.
- The app's sign-in token stays in the app's Rust half (`account.json` in the app's config
directory, readable by you only); the page asks for the server's `/api/v2` through it and never
holds the token.
- The explorer reads only inside the agent's folders.
- A file goes to the server as an attachment only when you choose it in the file dialog, drop it
on the window or paste it: the page cannot name a path for the app to send.
- HTML is shown in a sealed frame on an address of its own: scripts in it run, but it has no
access to the app, no network and no way to navigate anywhere.
- Links in a reply open in your browser, and only web and mail links.
## Build ## Build
@@ -56,21 +99,31 @@ Needs Rust (stable) and, on Linux, WebKitGTK 4.1 and an AppIndicator library:
sudo apt install libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev sudo apt install libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev
scripts/fetch-cli.sh # the bundled CLI, checked against its release signature scripts/fetch-cli.sh # the bundled CLI, checked against its release signature
scripts/fetch-tokens.sh # verifies ui/tokens.css against tokens.lock.json scripts/fetch-tokens.sh # verifies ui/tokens.css against tokens.lock.json
scripts/fetch-vendor.sh # verifies ui/vendor/ against vendor.lock.json
cargo install tauri-cli --version "^2" --locked cargo install tauri-cli --version "^2" --locked
cargo tauri dev # or: cargo tauri build cargo tauri dev # or: cargo tauri build
``` ```
There is no Node and no bundler: the app's one local page is plain HTML, CSS and JavaScript in There is no Node and no bundler: the app's page is plain HTML, CSS and JavaScript modules in
`ui/`. Everything else on screen is the server's own web UI. The version of the CLI it carries is `ui/`. The version of the CLI it carries is in `lembas-cli.version`.
in `lembas-cli.version`.
Two files in `ui/` are generated and committed. `ui/tokens.css` is the server's design tokens, Three things in `ui/` are copied from the LLeMbas server and committed, each pinned:
copied by `scripts/fetch-tokens.sh <server tag>` from a checkout of the server beside this
repository (`../LLeMbas`, or `LEMBAS_SERVER`) and pinned in `tokens.lock.json`; run it with a - `ui/tokens.css`, the server's design tokens: `scripts/fetch-tokens.sh <server ref>` from a
new tag when the server's look changes. `ui/i18n.js` is the pages' words in the server's checkout of the server beside this repository (`../LLeMbas`, or `LEMBAS_SERVER`), pinned in
languages, written by `scripts/gen-i18n.py` from the server's catalogues and the script's own `tokens.lock.json`.
table; run it when a string on the page changes. - `ui/vendor/`, the server's vendored CodeMirror build: `scripts/fetch-vendor.sh <server ref>`
checks every module against the server's own pin before copying it, and records both hashes in
`vendor.lock.json`. The one change: the modules import each other by file name, since the page
has no import map.
- `ui/i18n.js`, the page's words in the server's languages: `scripts/gen-i18n.py`, from the
server's catalogues and the script's own table. Run it when a sentence on the page changes.
`scripts/check-ui.sh` parses the page's scripts and refuses any that sets HTML from a string;
`scripts/hygiene.sh` keeps private names out of this public repository. CI runs both, and starts
the built app under X11 and under Wayland (`scripts/ci-start.sh`).
## Licence ## Licence
MIT; see [LICENSE](LICENSE). The bundled LLeMbas CLI is MIT too, with its own third-party notices. MIT; see [LICENSE](LICENSE). The bundled LLeMbas CLI is MIT too, with its own third-party notices.
CodeMirror and Lezer (in `ui/vendor/`) are MIT.
+1 -1
View File
@@ -1 +1 @@
1.5.1 2.0.0-beta.3
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# The page's JavaScript parses: the app's modules and the vendored ones as ES modules (a plain
# `node --check file.js` treats a .js file as CommonJS and passes a module with a syntax error in
# it), the generated i18n.js as the classic script it is.
set -euo pipefail
cd "$(dirname "$0")/.."
bad=0
for f in ui/js/*.js ui/vendor/*.mjs; do
node --input-type=module --check < "$f" || { echo "check-ui: $f does not parse" >&2; bad=1; }
done
node --check ui/i18n.js || { echo "check-ui: ui/i18n.js does not parse" >&2; bad=1; }
# Nothing the page draws is set as HTML: text goes in as text (ui/js/dom.js).
if grep -nE '\.(innerHTML|outerHTML)\s*=|insertAdjacentHTML|document\.write' ui/js/*.js; then
echo "check-ui: the lines above set HTML from a string" >&2
bad=1
fi
[ "$bad" -eq 0 ] && echo "check-ui: the page's scripts parse, and none sets HTML"
exit "$bad"
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Start the built app on a display without a screen and check it is still running 25 seconds
# later, with nothing in its log saying it stopped. For CI (ci.yml, job `start`):
#
# scripts/ci-start.sh x11 under Xvfb
# scripts/ci-start.sh wayland under a headless Weston, GTK's Wayland backend forced
#
# The app starts with an empty home, so it shows its first-run page and talks to no server.
set -euo pipefail
cd "$(dirname "$0")/.."
APP="${LLEMBAS_DESKTOP_BIN:-src-tauri/target/debug/llembas-desktop}"
[ -x "$APP" ] || { echo "ci-start: no app at $APP; build it first" >&2; exit 1; }
how="${1:?usage: ci-start.sh x11|wayland}"
home="$(mktemp -d)"
runtime="$(mktemp -d)"
chmod 700 "$runtime"
pids=()
cleanup() {
for p in "${pids[@]}"; do kill "$p" 2>/dev/null || true; done
# The document portal, started on the session bus, may have mounted itself (FUSE) at
# $XDG_RUNTIME_DIR/doc; rm cannot remove a mount point, and a cleanup must not fail the check.
fusermount3 -u "$runtime/doc" 2>/dev/null || fusermount -u "$runtime/doc" 2>/dev/null || true
rm -rf "$home" "$runtime" 2>/dev/null || true
}
trap cleanup EXIT
export HOME="$home" XDG_CONFIG_HOME="$home/.config" XDG_DATA_HOME="$home/.local/share" \
XDG_STATE_HOME="$home/.local/state" XDG_CACHE_HOME="$home/.cache" XDG_RUNTIME_DIR="$runtime"
# No GPU on a runner: Mesa's software renderer.
export LIBGL_ALWAYS_SOFTWARE=1
log="$XDG_DATA_HOME/eu.llembas.desktop/logs/desktop.log"
case "$how" in
x11)
run=(xvfb-run -a -s "-screen 0 1280x840x24")
;;
wayland)
weston --backend=headless-backend.so --socket=wayland-ci --idle-time=0 --use-pixman \
> "$runtime/weston.log" 2>&1 &
pids+=("$!")
for _ in $(seq 1 50); do
[ -S "$runtime/wayland-ci" ] && break
sleep 0.2
done
[ -S "$runtime/wayland-ci" ] || { echo "ci-start: weston did not start" >&2; cat "$runtime/weston.log" >&2; exit 1; }
export WAYLAND_DISPLAY=wayland-ci GDK_BACKEND=wayland
unset DISPLAY
run=()
;;
*)
echo "ci-start: x11 or wayland, not $how" >&2
exit 2
;;
esac
code=0
dbus-run-session -- "${run[@]}" timeout 25 "$APP" > "$runtime/app.log" 2>&1 || code=$?
echo "--- the app's output ($how)"
cat "$runtime/app.log"
if [ -f "$log" ]; then
echo "--- desktop.log"
cat "$log"
fi
if [ "$code" -ne 124 ]; then
echo "ci-start: on $how the app ended by itself within 25 seconds (exit $code)" >&2
exit 1
fi
if [ -f "$log" ] && grep -q 'stopped:\|could not start\|could not open the window' "$log"; then
echo "ci-start: on $how the app logged a failure" >&2
exit 1
fi
echo "ci-start: on $how the app was still running after 25 seconds"
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Put the server's vendored CodeMirror build where the app's own pages read it from:
# ui/vendor/*.mjs (copies of the server's static/vendor/cm-*.mjs and lezer-*.mjs)
#
# scripts/fetch-vendor.sh <server-ref> copy the modules at that ref (a tag such as
# v2.0.0-beta.1, or a branch) and write vendor.lock.json
# scripts/fetch-vendor.sh verify: ui/vendor matches vendor.lock.json
#
# The server is a git checkout, LLeMbas beside this repository by default (LLEMBAS_SERVER names
# another). Each module is checked against the server's own pin (its scripts/vendor.lock.json, at
# the same ref) before it is copied, so what the app carries is exactly what the server serves.
#
# The one change made to a copy: the server loads these modules through an import map
# ("@codemirror/view" -> its file), which the app's pages do not have -- an inline import map is a
# script the content security policy would have to allow. So the bare names in `import`/`from`
# are rewritten to the sibling files ("./cm-view.mjs"), and nothing else is touched. esm.sh's
# Node polyfill ("/node/process.mjs", which @lezer/lr imports to read process.env.LOG) becomes
# "./node-process.mjs", the server's own one-line stub (static/js/node-process.mjs), copied too:
# an absolute path is a 404 in the app, and a failed @lezer/lr takes every editor language with it.
#
# vendor.lock.json records the ref, the commit it resolved to, and for every file the server's
# SHA-256 and the copy's, which is what verify checks. Like fetch-tokens.sh: the file in the tree
# says which server version the editor comes from, and CI refuses a copy that drifted.
set -euo pipefail
cd "$(dirname "$0")/.."
SERVER="${LEMBAS_SERVER:-../LLeMbas}"
SOURCE="src/lembas/web/static/vendor"
SERVER_LOCK="scripts/vendor.lock.json"
OUT="ui/vendor"
LOCK="vendor.lock.json"
sha() { sha256sum "$1" | cut -d' ' -f1; }
if [ $# -eq 0 ]; then
[ -f "$LOCK" ] || { echo "fetch-vendor: no $LOCK; run with a server ref first" >&2; exit 1; }
ref="$(sed -n -E 's/^ *"tag": *"([^"]+)".*/\1/p' "$LOCK" | head -n1)"
bad=0
count=0
# One file a line: "name": {"server": "…", "copy": "…"}
while IFS=$'\t' read -r name want; do
count=$((count + 1))
if [ ! -f "$OUT/$name" ]; then
echo "fetch-vendor: $OUT/$name is missing" >&2
bad=1
elif [ "$(sha "$OUT/$name")" != "$want" ]; then
echo "fetch-vendor: $OUT/$name does not match $LOCK (server $ref)" >&2
bad=1
fi
done < <(sed -n -E 's/^ *"([a-z0-9-]+\.mjs)": *\{"server": *"[0-9a-f]{64}", *"copy": *"([0-9a-f]{64})"\}.*/\1\t\2/p' "$LOCK")
for f in "$OUT"/*.mjs; do
grep -q "\"$(basename "$f")\"" "$LOCK" || { echo "fetch-vendor: $f is not in $LOCK" >&2; bad=1; }
done
[ "$count" -gt 0 ] || { echo "fetch-vendor: $LOCK lists no file" >&2; exit 1; }
[ "$bad" -eq 0 ] || exit 1
echo "fetch-vendor: $count files in $OUT match $LOCK (server $ref)"
exit 0
fi
REF="$1"
[ -d "$SERVER/.git" ] || [ -f "$SERVER/.git" ] \
|| { echo "fetch-vendor: $SERVER is not a git checkout of the LLeMbas server (set LEMBAS_SERVER)" >&2; exit 1; }
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$REF^{commit}")" \
|| { echo "fetch-vendor: $REF is not a commit in $SERVER" >&2; exit 1; }
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
git -C "$SERVER" show "$commit:$SERVER_LOCK" > "$work/server-lock.json" \
|| { echo "fetch-vendor: $SERVER_LOCK is not in $SERVER at $REF" >&2; exit 1; }
names="$(git -C "$SERVER" ls-tree --name-only "$commit" "$SOURCE/" | sed -n -E 's#^.*/((cm|lezer)-[a-z0-9-]+\.mjs)$#\1#p' | sort)"
[ -n "$names" ] || { echo "fetch-vendor: no CodeMirror modules in $SOURCE at $REF" >&2; exit 1; }
mkdir -p "$work/out"
entries=()
for name in $names; do
git -C "$SERVER" show "$commit:$SOURCE/$name" > "$work/$name"
pinned="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], {}).get("sha256", ""))' "$work/server-lock.json" "$name")"
have="$(sha "$work/$name")"
[ -n "$pinned" ] || { echo "fetch-vendor: $name is not pinned in the server's $SERVER_LOCK" >&2; exit 1; }
[ "$pinned" = "$have" ] || { echo "fetch-vendor: $name does not match the server's pin ($have, pinned $pinned)" >&2; exit 1; }
# "@codemirror/view" -> "./cm-view.mjs", "@lezer/lr" -> "./lezer-lr.mjs", "codemirror" -> "./cm-codemirror.mjs".
perl -pe 's#\b(from|import)(\s*)"\@codemirror/([a-z-]+)"#$1$2"./cm-$3.mjs"#g; s#\b(from|import)(\s*)"\@lezer/([a-z-]+)"#$1$2"./lezer-$3.mjs"#g; s#\b(from|import)(\s*)"codemirror"#$1$2"./cm-codemirror.mjs"#g; s#\b(from|import)(\s*)"/node/process\.mjs"#$1$2"./node-process.mjs"#g' \
"$work/$name" > "$work/out/$name"
if grep -E -q '(from|import)\s*"((@codemirror|@lezer)/|/)' "$work/out/$name"; then
echo "fetch-vendor: a bare module name or an absolute path survived in $name; fix the script" >&2
exit 1
fi
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$(sha "$work/out/$name")")")
done
# The polyfill stub, from the server's own scripts (it is not a download, so it has no pin there).
stub="src/lembas/web/static/js/node-process.mjs"
git -C "$SERVER" show "$commit:$stub" > "$work/out/node-process.mjs" \
|| { echo "fetch-vendor: $stub is not in $SERVER at $REF" >&2; exit 1; }
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' node-process.mjs "$(sha "$work/out/node-process.mjs")" "$(sha "$work/out/node-process.mjs")")")
# Every module a copy imports must be one of the copies.
for f in "$work"/out/*.mjs; do
while read -r dep; do
[ -f "$work/out/$dep" ] || { echo "fetch-vendor: $(basename "$f") imports $dep, which is not copied" >&2; exit 1; }
done < <(grep -o -E '(from|import)\s*"\./[a-z0-9-]+\.mjs"' "$f" | sed -E 's/.*"\.\/([^"]+)"/\1/' | sort -u)
done
rm -rf "$OUT"
mkdir -p "$OUT"
cp "$work"/out/*.mjs "$OUT/"
{
printf '{\n "tag": "%s",\n "commit": "%s",\n "files": {\n' "$REF" "$commit"
last=$((${#entries[@]} - 1))
for i in "${!entries[@]}"; do
if [ "$i" -lt "$last" ]; then printf '%s,\n' "${entries[$i]}"; else printf '%s\n' "${entries[$i]}"; fi
done
printf ' }\n}\n'
} > "$LOCK"
echo "fetch-vendor: LLeMbas $REF (${commit:0:12}) $SOURCE -> $OUT (${#entries[@]} files); $LOCK written"
+1902 -289
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# The repository is public: nothing in it may name the private estate it was written on.
# The LLeMbas section rule ("everything public is current-state and estate-free"), as a check
# that runs in CI on every push rather than a sentence somebody has to remember.
#
# Patterns are the kinds of thing that leak: a private domain, an internal `.lan` name, a
# container number, a private address range, a machine's name. This file is the one place that
# may spell them, and is skipped.
set -euo pipefail
cd "$(dirname "$0")/.."
patterns=(
'houmeres'
'\b[a-z0-9-]+\.lan\b'
'\bCT ?2[0-9]{2}\b'
'\b10\.0\.0\.[0-9]+'
'\b192\.168\.[0-9]+\.[0-9]+'
'\bgamebox\b'
'\bthor\b'
)
found=0
for pattern in "${patterns[@]}"; do
if git ls-files -z | grep -zv '^scripts/hygiene.sh$' | xargs -0 grep -nIEi -- "$pattern" ; then
echo "hygiene: the lines above match /$pattern/" >&2
found=1
fi
done
[ "$found" -eq 0 ] && echo "hygiene: nothing private in the tracked files"
exit "$found"
+100 -3
View File
@@ -469,6 +469,17 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"rand_core",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -569,6 +580,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.2" version = "1.5.2"
@@ -1296,6 +1316,7 @@ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core",
] ]
[[package]] [[package]]
@@ -2064,15 +2085,15 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]] [[package]]
name = "llembas-desktop" name = "llembas-desktop"
version = "0.7.1" version = "1.0.0-beta.4"
dependencies = [ dependencies = [
"gtk",
"libc", "libc",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater", "tauri-plugin-updater",
@@ -2094,6 +2115,20 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [
"cc",
"log",
"objc2",
"objc2-foundation",
"time",
"uuid",
]
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.39.0" version = "0.39.0"
@@ -2220,6 +2255,20 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "notify-rust"
version = "4.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4587364a9a0074333429b3df75a30a205340c56a536ca3eb6ca0e59b87bbf8af"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -2842,6 +2891,23 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom 0.4.3",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@@ -3429,7 +3495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest",
] ]
@@ -3925,6 +3991,26 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-notification"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bc6c194a95a535abdfbe3bc1af9a43935b8b9b29c216b8e71af8b59132703cf"
dependencies = [
"log",
"notify-rust",
"rand",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
"time",
"url",
"zbus",
]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.7.1" version = "2.7.1"
@@ -4097,6 +4183,17 @@ dependencies = [
"toml 1.1.8+spec-1.1.0", "toml 1.1.8+spec-1.1.0",
] ]
[[package]]
name = "tauri-winrt-notification"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36"
dependencies = [
"thiserror 2.0.21",
"windows",
"windows-version",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
+4 -7
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "llembas-desktop" name = "llembas-desktop"
version = "0.7.1" version = "1.0.0-beta.4"
description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer" description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
authors = ["Jaroslav Beneš"] authors = ["Jaroslav Beneš"]
license = "MIT" license = "MIT"
repository = "https://github.com/LLeMbas/LLeMbas-Desktop" repository = "https://github.com/LLeMbas/LLeMbas-Desktop"
@@ -17,8 +17,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[dependencies] [dependencies]
tauri = { version = "2.12", features = ["tray-icon", "unstable"] } tauri = { version = "2.12", features = ["tray-icon"] }
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
@@ -29,10 +30,6 @@ ureq = { version = "2", default-features = false, features = ["tls", "native-cer
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
# The window's GTK box: Tauri packs every webview into it with an equal share on Linux.
gtk = "0.18"
[profile.release] [profile.release]
codegen-units = 1 codegen-units = 1
lto = true lto = true
+3 -3
View File
@@ -1,5 +1,5 @@
//! Besides Tauri's own build step: the two themes' colours the window itself paints, read out //! Besides Tauri's own build step: the two themes' page colour, which the window itself paints
//! of `ui/tokens.css` (the server's tokens, fetched by `scripts/fetch-tokens.sh`) into //! before the page has (so a dark theme does not start white), read out of `ui/tokens.css` (the server's tokens, fetched by `scripts/fetch-tokens.sh`) into
//! `$OUT_DIR/tokens.rs`, so no colour is written in Rust. `lib.rs` includes that file. //! `$OUT_DIR/tokens.rs`, so no colour is written in Rust. `lib.rs` includes that file.
use std::env; use std::env;
@@ -10,7 +10,7 @@ use std::path::Path;
/// Rust names the constants get. /// Rust names the constants get.
const THEMES: [(&str, &str); 2] = [("moria", "MORIA"), ("shire", "SHIRE")]; const THEMES: [(&str, &str); 2] = [("moria", "MORIA"), ("shire", "SHIRE")];
/// The tokens taken from each theme, by the custom property and the constant's suffix. /// The tokens taken from each theme, by the custom property and the constant's suffix.
const TOKENS: [(&str, &str); 2] = [("--bg", "BG"), ("--border", "BORDER")]; const TOKENS: [(&str, &str); 1] = [("--bg", "BG")];
fn main() { fn main() {
let tokens = Path::new("../ui/tokens.css"); let tokens = Path::new("../ui/tokens.css");
+4 -2
View File
@@ -1,8 +1,10 @@
{ {
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "The app's own pages: the title bar and the first-run page. The server's web UI is a remote page in the same window and gets no IPC at all: this capability lists no remote URL. The window permissions are the title bar's: moving, resizing, minimising, maximising and closing (which hides).", "description": "The app's own page (ui/), the only content with the bridge. It names the main window and no remote URL: no server page is ever loaded, and HTML the viewer shows is a sealed frame on its own scheme, which no capability names. The window permissions are the title bar's: moving, resizing, minimising, maximising and closing (which hides).",
"windows": ["main"], "windows": [
"main"
],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
+278 -13
View File
@@ -4,6 +4,12 @@
//! the web UI run here, in this computer's projects. It is the CLI's own service loop, unchanged; //! the web UI run here, in this computer's projects. It is the CLI's own service loop, unchanged;
//! the app only starts it, restarts it when it fails, and stops it (SIGTERM first, so a turn in //! the app only starts it, restarts it when it fails, and stops it (SIGTERM first, so a turn in
//! progress ends cleanly) when the agent is switched off or the app quits. //! progress ends cleanly) when the agent is switched off or the app quits.
//!
//! What it shows is the CLI's own word: a process that runs is not an agent that is linked. While
//! it runs, the state is what the service writes to its `service.json` (connecting, linked,
//! waiting and why, stopped and why); before that, the line it printed; when it ends, its last
//! line. A service the CLI says cannot go on (a refused token, no login) is not restarted every
//! second: it waits until linking again starts it.
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::path::PathBuf; use std::path::PathBuf;
@@ -20,6 +26,8 @@ use crate::cli;
const RESTART_FOR_UPDATE: i32 = 75; const RESTART_FOR_UPDATE: i32 = 75;
const BACKOFF: Duration = Duration::from_secs(10); const BACKOFF: Duration = Duration::from_secs(10);
const STOP_GRACE: Duration = Duration::from_secs(15); const STOP_GRACE: Duration = Duration::from_secs(15);
/// How long to wait before trying again after the CLI said trying again will not help.
const REFUSED_WAIT: Duration = Duration::from_secs(300);
#[derive(Debug, Clone, PartialEq, Serialize)] #[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "state", content = "detail", rename_all = "snake_case")] #[serde(tag = "state", content = "detail", rename_all = "snake_case")]
@@ -28,25 +36,110 @@ pub enum State {
Off, Off,
/// This computer is not signed in to the server yet. /// This computer is not signed in to the server yet.
NotLinked, NotLinked,
/// The process is started; the CLI has not said anything of its link yet.
Starting, Starting,
/// The CLI is dialling the server.
Connecting,
/// The CLI says it is linked: agent chats can use this computer.
Running, Running,
/// Another `lembas service` on this computer holds the link (its pid). /// Not linked right now, and trying again: the CLI's own reason (a terminal holds the link,
Elsewhere(u32), /// the server cannot be reached, the link dropped).
/// It ended with an error; trying again shortly. Waiting(String),
/// The server refused this computer's link, in its words (a token it no longer takes, or a
/// protocol it does not speak). The CLI will not try again by itself; linking again is the
/// way out. Only the server's refusal: a missing login is `NotLinked`.
Refused(String),
/// Another `lembas service` on this computer holds the link.
Elsewhere(Elsewhere),
/// It ended with an error; trying again shortly. The CLI's last words, where it said any.
Failed(String), Failed(String),
} }
/// A `lembas service` this app did not start, and what it says of its link.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Elsewhere {
pub pid: u32,
/// It says it is linked.
pub linked: bool,
/// Why not, when it is not (its own words; empty while it has said nothing).
pub why: String,
}
impl State { impl State {
pub fn label(&self) -> String { pub fn label(&self) -> String {
match self { match self {
State::Off => "Agent: off".into(), State::Off => "Agent: off".into(),
State::NotLinked => "Agent: this computer is not linked".into(), State::NotLinked => "Agent: this computer is not linked".into(),
State::Starting => "Agent: starting…".into(), State::Starting => "Agent: starting…".into(),
State::Connecting => "Agent: connecting…".into(),
State::Running => "Agent: running".into(), State::Running => "Agent: running".into(),
State::Elsewhere(pid) => format!("Agent: running as a service (pid {pid})"), State::Waiting(why) => format!("Agent: waiting — {why}"),
State::Refused(why) => format!("Agent: refused — {why}"),
State::Elsewhere(e) if e.linked => {
format!("Agent: running as a service (pid {})", e.pid)
}
State::Elsewhere(e) => format!("Agent: a service (pid {}) is not linked", e.pid),
State::Failed(why) => format!("Agent: stopped — {why}"), State::Failed(why) => format!("Agent: stopped — {why}"),
} }
} }
/// Whether this is a state the CLI's own status file decides, while the process runs.
fn told_by_cli(&self) -> bool {
matches!(
self,
State::Starting | State::Connecting | State::Running | State::Waiting(_)
)
}
}
/// What the CLI's status says, as a state of the app's. `before`: the state shown now, so that a
/// new dial after a failed one keeps the reason it is waiting (the CLI writes `connecting` at
/// every try, `waiting` between them) instead of flickering between the two.
pub fn from_link(link: &cli::Link, before: &State) -> State {
let why = link.detail.clone().unwrap_or_default();
match link.state.as_str() {
"linked" => State::Running,
"connecting" => match before {
State::Waiting(w) if link.attempts > 1 => State::Waiting(w.clone()),
_ => State::Connecting,
},
"waiting" => State::Waiting(why),
"stopped" => State::Refused(why),
_ => State::Starting,
}
}
/// What a service that ended cleanly (exit 0) said: the server's refusal (`stopped` in its status
/// file, with the server's reason) is `Refused`; no login for the server is `NotLinked`; any
/// other reason it gave (a configuration that cannot work) is `Failed` -- never "refused", which
/// is the server's word only. None when it said nothing.
fn ended_cleanly(stopped: Option<&cli::Link>, said: Option<&str>) -> Option<State> {
if let Some(link) = stopped {
let why = link.detail.clone().unwrap_or_default();
// The CLI's advice to its terminal user ("sign in again: lembas login") is the app's
// Link button here.
let why = why
.split(" — sign in again")
.next()
.unwrap_or("")
.trim()
.to_string();
if !why.is_empty() && why != "stopped" {
return Some(State::Refused(why));
}
}
let said = strip_prefix(said?);
if said.starts_with("not logged in") {
return Some(State::NotLinked);
}
Some(State::Failed(said))
}
/// What a running process has said before its status file says anything: the one line the
/// CLI's service writes while a terminal on this computer holds the link for `/remote`.
fn from_output(line: &str) -> Option<State> {
line.contains("holds the link")
.then(|| State::Waiting(line.trim().to_string()))
} }
type Notify = Arc<dyn Fn(&State) + Send + Sync>; type Notify = Arc<dyn Fn(&State) + Send + Sync>;
@@ -124,15 +217,33 @@ impl Agent {
continue; continue;
} }
if let Some(pid) = cli::running_service().filter(|p| Some(*p) != self.child_pid()) { if let Some(pid) = cli::running_service().filter(|p| Some(*p) != self.child_pid()) {
if !self.set(generation, State::Elsewhere(pid)) { // Not ours, but this computer's agent all the same: say what it says.
let link = cli::link_of(pid);
let linked = link.as_ref().is_some_and(|l| l.state == "linked");
let why = link
.map(|l| match l.detail {
Some(d) if !d.is_empty() => d,
_ => l.state,
})
.unwrap_or_default();
if !self.set(
generation,
State::Elsewhere(Elsewhere {
pid,
linked,
why: if linked { String::new() } else { why },
}),
) {
return; return;
} }
self.pause(generation, BACKOFF); self.pause(generation, Duration::from_secs(3));
continue; continue;
} }
if !self.set(generation, State::Starting) { if !self.set(generation, State::Starting) {
return; return;
} }
// Where this run's output starts in the log: its last line is its reason.
let from = std::fs::metadata(&self.log).map(|m| m.len()).unwrap_or(0);
let child = match self.spawn() { let child = match self.spawn() {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
@@ -146,6 +257,7 @@ impl Agent {
continue; continue;
} }
}; };
let pid = child.id();
{ {
let mut g = self.inner.lock().unwrap(); let mut g = self.inner.lock().unwrap();
if g.generation != generation { if g.generation != generation {
@@ -155,23 +267,36 @@ impl Agent {
} }
g.child = Some(child); g.child = Some(child);
} }
self.set(generation, State::Running); let code = self.wait_child(generation, pid, from);
let code = self.wait_child(generation);
let mut g = self.inner.lock().unwrap(); let mut g = self.inner.lock().unwrap();
if g.generation != generation { if g.generation != generation {
return; return;
} }
g.child = None; g.child = None;
drop(g); drop(g);
let said = last_line(&self.log, from);
match code { match code {
Some(RESTART_FOR_UPDATE) => continue, Some(RESTART_FOR_UPDATE) => continue,
Some(0) => { Some(0) => {
// It ended by itself without an error: nothing to do but start it again. // It ended by itself without an error: the CLI's word for "starting again
self.pause(generation, Duration::from_secs(1)); // will not help". Waiting a long while (linking again starts it at once).
let stopped = cli::link_of(pid).filter(|l| l.state == "stopped");
let next = ended_cleanly(stopped.as_ref(), said.as_deref());
match next {
Some(state) => {
if !self.set(generation, state) {
return;
}
self.pause(generation, REFUSED_WAIT);
}
None => self.pause(generation, Duration::from_secs(1)),
}
} }
other => { other => {
let why = other.map_or("ended by a signal".to_string(), |c| { let why = said.map(|s| strip_prefix(&s)).unwrap_or_else(|| {
format!("exit status {c}") other.map_or("ended by a signal".to_string(), |c| {
format!("exit status {c}")
})
}); });
if !self.set(generation, State::Failed(why)) { if !self.set(generation, State::Failed(why)) {
return; return;
@@ -217,7 +342,11 @@ impl Agent {
} }
/// Poll the child until it exits (its exit code), or until this supervisor is replaced. /// Poll the child until it exits (its exit code), or until this supervisor is replaced.
fn wait_child(&self, generation: u64) -> Option<i32> { /// Meanwhile the state shown is what the CLI says of its link: its status file once it has
/// written one, before that the line it printed (a terminal holding the link).
fn wait_child(&self, generation: u64, pid: u32, from: u64) -> Option<i32> {
// Every other poll (once a second) the status is read again.
let mut read = true;
loop { loop {
{ {
let mut g = self.inner.lock().unwrap(); let mut g = self.inner.lock().unwrap();
@@ -230,6 +359,21 @@ impl Agent {
Some(Err(_)) | None => return None, Some(Err(_)) | None => return None,
} }
} }
if read {
let now = self.state();
if now.told_by_cli() {
let next = match cli::link_of(pid) {
Some(link) => from_link(&link, &now),
None => last_line(&self.log, from)
.and_then(|l| from_output(&l))
.unwrap_or(State::Starting),
};
if !self.set(generation, next) {
return None;
}
}
}
read = !read;
thread::sleep(Duration::from_millis(500)); thread::sleep(Duration::from_millis(500));
} }
} }
@@ -283,3 +427,124 @@ fn terminate(mut child: Child) {
let _ = child.kill(); let _ = child.kill();
let _ = child.wait(); let _ = child.wait();
} }
/// The last line the log holds after `from` (this run's output), if it wrote any. Only the end of
/// the file is read.
fn last_line(log: &std::path::Path, from: u64) -> Option<String> {
use std::io::{Read, Seek, SeekFrom};
let mut f = File::open(log).ok()?;
let len = f.metadata().ok()?.len();
let start = from.max(len.saturating_sub(4096));
if start >= len {
return None;
}
f.seek(SeekFrom::Start(start)).ok()?;
let mut buf = Vec::new();
f.take(len - start).read_to_end(&mut buf).ok()?;
String::from_utf8_lossy(&buf)
.lines()
.rev()
.map(str::trim)
.find(|l| !l.is_empty())
.map(String::from)
}
/// `lembas service: not logged in` → `not logged in`: the CLI's error line without its name.
fn strip_prefix(line: &str) -> String {
line.strip_prefix("lembas service: ")
.unwrap_or(line)
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refused_is_only_the_servers_word() {
let refused = link(
"stopped",
Some("not allowed to link — sign in again: lembas login"),
1,
);
assert_eq!(
ended_cleanly(Some(&refused), None),
Some(State::Refused("not allowed to link".into()))
);
assert_eq!(
ended_cleanly(
None,
Some("lembas service: not logged in to any LLeMbas instance — lembas login <address>")
),
Some(State::NotLinked)
);
assert_eq!(
ended_cleanly(None, Some("lembas service: logged in to several instances")),
Some(State::Failed("logged in to several instances".into()))
);
assert_eq!(ended_cleanly(None, None), None);
}
fn link(state: &str, detail: Option<&str>, attempts: u32) -> cli::Link {
cli::Link {
state: state.into(),
detail: detail.map(String::from),
pid: 1,
attempts,
}
}
#[test]
fn the_state_shown_is_the_clis_word_for_its_link() {
let s = State::Starting;
assert_eq!(from_link(&link("linked", None, 1), &s), State::Running);
assert_eq!(
from_link(&link("connecting", None, 1), &s),
State::Connecting
);
let waiting = from_link(
&link("waiting", Some("could not reach the instance"), 1),
&s,
);
assert_eq!(
waiting,
State::Waiting("could not reach the instance".into())
);
// The next dial keeps the reason it is waiting, rather than flicker.
assert_eq!(from_link(&link("connecting", None, 2), &waiting), waiting);
assert_eq!(
from_link(&link("stopped", Some("refused"), 2), &waiting),
State::Refused("refused".into())
);
// Not running is never shown as running.
for state in ["connecting", "waiting", "stopped", "?"] {
assert_ne!(from_link(&link(state, None, 1), &s), State::Running);
}
}
#[test]
fn a_terminal_holding_the_link_is_said() {
assert_eq!(
from_output("a terminal here holds the link (/remote); waiting for it to close"),
Some(State::Waiting(
"a terminal here holds the link (/remote); waiting for it to close".into()
))
);
assert_eq!(from_output("linked"), None);
}
#[test]
fn the_last_line_is_this_runs_own() {
let p = std::env::temp_dir().join(format!("llembas-agent-log-{}", std::process::id()));
std::fs::write(&p, "old run\nlinked\n").unwrap();
let from = std::fs::metadata(&p).unwrap().len();
assert_eq!(last_line(&p, from), None);
let mut f = OpenOptions::new().append(true).open(&p).unwrap();
std::io::Write::write_all(&mut f, b"connecting\nlembas service: not logged in\n\n")
.unwrap();
let said = last_line(&p, from).unwrap();
assert_eq!(said, "lembas service: not logged in");
assert_eq!(strip_prefix(&said), "not logged in");
std::fs::remove_file(&p).unwrap();
}
}
+106
View File
@@ -8,6 +8,8 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::OnceLock; use std::sync::OnceLock;
use serde::Deserialize;
static BUNDLED: OnceLock<PathBuf> = OnceLock::new(); static BUNDLED: OnceLock<PathBuf> = OnceLock::new();
/// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from /// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from
@@ -40,6 +42,37 @@ pub fn binary() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(exe_name())) .unwrap_or_else(|| PathBuf::from(exe_name()))
} }
/// How a person starts the CLI in a terminal on this computer: `lembas` where one is on PATH
/// (installed by hand: it shares the app's sessions, the directories are the same), else the
/// bundled one by its full path, quoted for a POSIX shell. The bundled copy is never on PATH.
pub fn terminal_word() -> String {
let path = std::env::var_os("PATH").unwrap_or_default();
on_path(&path, exe_name()).map_or_else(
|| shell_quote(&binary().to_string_lossy()),
|_| exe_name().to_string(),
)
}
fn on_path(path: &std::ffi::OsStr, name: &str) -> Option<PathBuf> {
std::env::split_paths(path)
.map(|dir| dir.join(name))
.find(|p| p.is_file())
}
/// A word for a POSIX shell: as it is when it is plainly safe, else in single quotes.
pub fn shell_quote(word: &str) -> String {
let plain = !word.is_empty()
&& word.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '/' | '.' | '-' | '_' | '+' | ':' | '@' | '%' | '=')
});
if plain {
word.to_string()
} else {
format!("'{}'", word.replace('\'', "'\\''"))
}
}
fn home() -> PathBuf { fn home() -> PathBuf {
std::env::var_os("HOME") std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE")) .or_else(|| std::env::var_os("USERPROFILE"))
@@ -108,6 +141,32 @@ fn running_in(state: &Path) -> Option<u32> {
(pid != 0 && alive(pid)).then_some(pid) (pid != 0 && alive(pid)).then_some(pid)
} }
/// What a running `lembas service run` says of its link to the server, as it writes it to
/// `service.json` in its state directory at every change: `connecting`, `linked`, `waiting`
/// (not linked now, trying again; `detail` says why) or `stopped` (it will not try again;
/// `detail` says why -- the server refused the token, or speaks another protocol). `pid` is the
/// process that wrote it: a file left by an earlier run is not this one's word.
#[derive(Clone, Debug, PartialEq, Deserialize)]
pub struct Link {
pub state: String,
#[serde(default)]
pub detail: Option<String>,
#[serde(default)]
pub pid: u32,
#[serde(default)]
pub attempts: u32,
}
/// The link status the CLI's service wrote last, if it was `pid` that wrote it.
pub fn link_of(pid: u32) -> Option<Link> {
link_in(&state_dir()).filter(|l| l.pid == pid)
}
fn link_in(state: &Path) -> Option<Link> {
let text = std::fs::read_to_string(state.join("service.json")).ok()?;
serde_json::from_str(&text).ok()
}
/// This computer's host name: what the CLI sent the server as `device_host` at login /// This computer's host name: what the CLI sent the server as `device_host` at login
/// (`os.hostname()`, the kernel's node name), so the server can tell which device is this one. /// (`os.hostname()`, the kernel's node name), so the server can tell which device is this one.
#[cfg(unix)] #[cfg(unix)]
@@ -151,6 +210,26 @@ mod tests {
d d
} }
#[test]
fn a_word_for_the_shell_is_quoted_only_when_it_must_be() {
assert_eq!(
shell_quote("/usr/lib/llembas-desktop/lembas"),
"/usr/lib/llembas-desktop/lembas"
);
assert_eq!(shell_quote("/home/a b/lembas"), "'/home/a b/lembas'");
assert_eq!(shell_quote("it's"), "'it'\\''s'");
assert_eq!(shell_quote(""), "''");
}
#[test]
fn the_cli_on_path_is_found_by_name() {
let d = scratch("path");
assert_eq!(on_path(d.as_os_str(), "lembas"), None);
fs::write(d.join("lembas"), b"").unwrap();
assert_eq!(on_path(d.as_os_str(), "lembas"), Some(d.join("lembas")));
fs::remove_dir_all(&d).unwrap();
}
#[test] #[test]
fn linked_reads_the_logins_the_cli_wrote() { fn linked_reads_the_logins_the_cli_wrote() {
let d = scratch("linked"); let d = scratch("linked");
@@ -166,6 +245,33 @@ mod tests {
fs::remove_dir_all(&d).unwrap(); fs::remove_dir_all(&d).unwrap();
} }
#[test]
fn the_link_status_is_read_as_the_service_writes_it() {
let d = scratch("link");
assert_eq!(link_in(&d), None);
// As `lembas service run` writes it (src/service.ts): LinkStatus and its pid.
fs::write(
d.join("service.json"),
r#"{"state":"waiting","instance":"https://ai.example","since":"2026-01-01T00:00:00.000Z","detail":"could not reach the instance; trying again in 4s","attempts":3,"pid":4242}"#,
)
.unwrap();
let l = link_in(&d).unwrap();
assert_eq!(l.state, "waiting");
assert_eq!(l.pid, 4242);
assert_eq!(l.attempts, 3);
assert!(l.detail.unwrap().starts_with("could not reach"));
// `linked` carries no detail.
fs::write(
d.join("service.json"),
r#"{"state":"linked","instance":"x","since":"y","attempts":1,"pid":7}"#,
)
.unwrap();
assert_eq!(link_in(&d).unwrap().detail, None);
fs::write(d.join("service.json"), "not json").unwrap();
assert_eq!(link_in(&d), None);
fs::remove_dir_all(&d).unwrap();
}
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn a_lock_counts_only_while_its_process_lives() { fn a_lock_counts_only_while_its_process_lives() {
+246
View File
@@ -0,0 +1,246 @@
//! The agent's working directory, for the explorer and the viewer: list a folder, read a file.
//!
//! Only inside the folders the agent may work in (`lembas remote` roots, as the CLI last said):
//! the page names a folder and a path inside it, and both are resolved here -- symbolic links and
//! `..` included -- before anything is opened. A path that ends up outside every root is refused,
//! whatever the page asked. Reading only: the app never writes in a project, the agent does.
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::time::UNIX_EPOCH;
use serde::Serialize;
/// The most of a folder listed at once.
pub const MAX_ENTRIES: usize = 5000;
/// The most of a file read for the viewer; a bigger one is offered by name only.
pub const MAX_READ: u64 = 2 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Entry {
pub name: String,
/// From the folder the page named, with `/` between parts.
pub path: String,
/// `dir` or `file` (a link counts as what it points to; a broken one as a file).
pub kind: &'static str,
pub size: u64,
/// Seconds since 1970, when the system says.
pub modified: Option<u64>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Listing {
pub root: String,
pub path: String,
pub entries: Vec<Entry>,
/// More entries than `MAX_ENTRIES`: the rest are not listed.
pub more: bool,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Opened {
pub root: String,
pub path: String,
pub name: String,
pub size: u64,
/// The text, when the file is text and small enough.
pub text: Option<String>,
/// Why there is no text: `binary` or `too_big`.
pub withheld: Option<&'static str>,
}
/// `root` (a folder the agent may use, or one inside it) and `path` inside it, resolved, and
/// refused unless the result is inside one of `roots`. The canonical root and target.
pub fn resolve(roots: &[String], root: &str, path: &str) -> Result<(PathBuf, PathBuf), String> {
let base = fs::canonicalize(root).map_err(|e| format!("{root}: {e}"))?;
let allowed = roots
.iter()
.filter_map(|r| fs::canonicalize(r).ok())
.any(|r| base.starts_with(&r));
if !allowed {
return Err(format!(
"{root} is not a folder the agent may work in. Add it under Settings, Project folders."
));
}
let rel = Path::new(path.trim_start_matches('/'));
if rel.components().any(|c| matches!(c, Component::Prefix(_))) {
return Err(format!("Not a path inside {root}: {path}"));
}
let target = fs::canonicalize(base.join(rel)).map_err(|e| format!("{path}: {e}"))?;
if !target.starts_with(&base) {
return Err(format!("{path} is outside {root}."));
}
Ok((base, target))
}
/// `target` from `base`, with `/` between parts ("" for the base itself).
fn relative(base: &Path, target: &Path) -> String {
target
.strip_prefix(base)
.map(|p| {
p.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
})
.unwrap_or_default()
}
pub fn list(roots: &[String], root: &str, path: &str) -> Result<Listing, String> {
let (base, dir) = resolve(roots, root, path)?;
if !dir.is_dir() {
return Err(format!("{path} is not a folder."));
}
let mut entries = Vec::new();
let mut more = false;
for item in fs::read_dir(&dir).map_err(|e| format!("{path}: {e}"))? {
let Ok(item) = item else { continue };
if entries.len() >= MAX_ENTRIES {
more = true;
break;
}
let name = item.file_name().to_string_lossy().into_owned();
// A link is shown as what it points to; following it is `resolve`'s to allow or not.
let meta = fs::metadata(item.path()).or_else(|_| item.metadata());
let (kind, size, modified) = match meta {
Ok(m) => (
if m.is_dir() { "dir" } else { "file" },
if m.is_dir() { 0 } else { m.len() },
m.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs()),
),
Err(_) => ("file", 0, None),
};
let at = relative(&base, &dir);
entries.push(Entry {
path: if at.is_empty() {
name.clone()
} else {
format!("{at}/{name}")
},
name,
kind,
size,
modified,
});
}
entries.sort_by(|a, b| {
(a.kind != "dir")
.cmp(&(b.kind != "dir"))
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a.name.cmp(&b.name))
});
Ok(Listing {
root: base.to_string_lossy().into_owned(),
path: relative(&base, &dir),
entries,
more,
})
}
/// Text, as the CLI's `show` decides it: no NUL byte, and valid UTF-8.
fn as_text(data: Vec<u8>) -> Option<String> {
if data.contains(&0) {
return None;
}
String::from_utf8(data).ok()
}
pub fn read(roots: &[String], root: &str, path: &str) -> Result<Opened, String> {
let (base, file) = resolve(roots, root, path)?;
let meta = fs::metadata(&file).map_err(|e| format!("{path}: {e}"))?;
if !meta.is_file() {
return Err(format!("{path} is not a file."));
}
let name = file
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let mut opened = Opened {
root: base.to_string_lossy().into_owned(),
path: relative(&base, &file),
name,
size: meta.len(),
text: None,
withheld: None,
};
if meta.len() > MAX_READ {
opened.withheld = Some("too_big");
return Ok(opened);
}
let data = fs::read(&file).map_err(|e| format!("{path}: {e}"))?;
match as_text(data) {
Some(t) => opened.text = Some(t),
None => opened.withheld = Some("binary"),
}
Ok(opened)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("llembas-files-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&d);
fs::create_dir_all(d.join("project/src")).unwrap();
fs::create_dir_all(d.join("outside")).unwrap();
fs::write(d.join("project/README.md"), "# Waybread\n").unwrap();
fs::write(d.join("project/src/main.rs"), "fn main() {}\n").unwrap();
fs::write(d.join("project/blob.bin"), [0u8, 1, 2]).unwrap();
fs::write(d.join("outside/secret.txt"), "no").unwrap();
d
}
fn roots(d: &Path) -> Vec<String> {
vec![d.join("project").to_string_lossy().into_owned()]
}
#[test]
fn a_root_lists_folders_first() {
let d = scratch("list");
let root = d.join("project").to_string_lossy().into_owned();
let l = list(&roots(&d), &root, "").unwrap();
let names: Vec<_> = l.entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["src", "blob.bin", "README.md"]);
assert_eq!(l.entries[0].kind, "dir");
let inner = list(&roots(&d), &root, "src").unwrap();
assert_eq!(inner.entries[0].path, "src/main.rs");
fs::remove_dir_all(&d).unwrap();
}
#[test]
fn nothing_outside_the_roots_is_reached() {
let d = scratch("outside");
let root = d.join("project").to_string_lossy().into_owned();
let other = d.join("outside").to_string_lossy().into_owned();
assert!(list(&roots(&d), &other, "").is_err());
assert!(read(&roots(&d), &root, "../outside/secret.txt").is_err());
assert!(read(&roots(&d), &root, "/../outside/secret.txt").is_err());
#[cfg(unix)]
{
std::os::unix::fs::symlink(d.join("outside"), d.join("project/escape")).unwrap();
assert!(read(&roots(&d), &root, "escape/secret.txt").is_err());
// A folder inside a root is a root of its own for the pane (an agent chat's folder).
let sub = d.join("project/src").to_string_lossy().into_owned();
assert!(list(&roots(&d), &sub, "").is_ok());
}
fs::remove_dir_all(&d).unwrap();
}
#[test]
fn text_is_read_and_the_rest_is_withheld() {
let d = scratch("read");
let root = d.join("project").to_string_lossy().into_owned();
let md = read(&roots(&d), &root, "README.md").unwrap();
assert_eq!(md.text.as_deref(), Some("# Waybread\n"));
assert_eq!(md.path, "README.md");
let bin = read(&roots(&d), &root, "blob.bin").unwrap();
assert_eq!(bin.text, None);
assert_eq!(bin.withheld, Some("binary"));
assert!(read(&roots(&d), &root, "src").is_err());
fs::remove_dir_all(&d).unwrap();
}
}
+1126 -423
View File
File diff suppressed because it is too large. Load diff
+131
View File
@@ -0,0 +1,131 @@
//! HTML to look at, sealed: an HTML file in the viewer, whether it is the agent's (`show`), the
//! server's tree's or this computer's, is a page a model may have written. It is drawn in a frame
//! from an address of its own (`llembas-sealed://`), never as part of the app's page:
//!
//! - **No bridge.** The capability names only the app's own page; Tauri gives the IPC script to
//! the main frame only, and this scheme is not the app's origin. Nothing in the frame can call
//! the app.
//! - **No network, no app.** It is served under the server's sealed policy (its
//! `security/sealed.py`): `sandbox allow-scripts` without `allow-same-origin` makes it an opaque
//! origin, nothing loads from anywhere (inline and `data:`/`blob:` only), no form is sent.
//! - **No way out.** The app refuses every navigation it did not make (see `lib.rs`), so a link or
//! a script in the frame goes nowhere.
//!
//! The page holds the HTML until it is closed (`forget`); at most `KEEP` are kept.
use std::collections::VecDeque;
use std::sync::Mutex;
pub const SCHEME: &str = "llembas-sealed";
/// The most sealed pages held at once; the oldest goes first.
const KEEP: usize = 16;
/// The policy a sealed page is served under -- the server's `sealed.policy()`, minus
/// `frame-ancestors`, which would have to name the app's own scheme.
pub const POLICY: &str = "sandbox allow-scripts allow-forms; default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' data: blob:; style-src 'unsafe-inline' data: blob:; img-src data: blob:; font-src data: blob:; media-src data: blob:; connect-src 'none'; worker-src blob:; frame-src 'none'; object-src 'none'; form-action 'none'; base-uri 'none'";
#[derive(Default)]
pub struct Pages {
next: Mutex<u64>,
held: Mutex<VecDeque<(String, String)>>,
}
impl Pages {
/// Hold `html`; the id it is served under.
pub fn hold(&self, html: String) -> String {
let id = {
let mut n = self.next.lock().unwrap();
*n += 1;
format!("p{}", *n)
};
let mut held = self.held.lock().unwrap();
while held.len() >= KEEP {
held.pop_front();
}
held.push_back((id.clone(), html));
id
}
pub fn forget(&self, id: &str) {
self.held.lock().unwrap().retain(|(i, _)| i != id);
}
pub fn get(&self, id: &str) -> Option<String> {
self.held
.lock()
.unwrap()
.iter()
.find(|(i, _)| i == id)
.map(|(_, h)| h.clone())
}
}
/// Where a held page is served: the address form differs by platform, as Tauri's own does.
pub fn url(id: &str) -> String {
if cfg!(windows) {
format!("http://{SCHEME}.localhost/{id}")
} else {
format!("{SCHEME}://localhost/{id}")
}
}
/// The id in a request's path: `/p3` -> `p3`.
pub fn id_of(path: &str) -> &str {
path.trim_start_matches('/')
.split(['?', '/'])
.next()
.unwrap_or("")
}
/// The response for a request to the scheme.
pub fn respond(pages: &Pages, path: &str) -> tauri::http::Response<Vec<u8>> {
let builder = tauri::http::Response::builder()
.header("Content-Security-Policy", POLICY)
.header("X-Content-Type-Options", "nosniff")
.header("Referrer-Policy", "no-referrer")
.header("Cache-Control", "no-store");
let built = match pages.get(id_of(path)) {
Some(html) => builder
.status(200)
.header("Content-Type", "text/html; charset=utf-8")
.body(html.into_bytes()),
None => builder
.status(404)
.header("Content-Type", "text/plain; charset=utf-8")
.body(Vec::new()),
};
built.unwrap_or_else(|_| tauri::http::Response::new(Vec::new()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pages_are_held_by_id_and_the_oldest_goes() {
let p = Pages::default();
let first = p.hold("<p>one</p>".into());
assert_eq!(p.get(&first).as_deref(), Some("<p>one</p>"));
for i in 0..KEEP {
p.hold(format!("<p>{i}</p>"));
}
assert_eq!(p.get(&first), None);
let last = p.hold("<p>last</p>".into());
p.forget(&last);
assert_eq!(p.get(&last), None);
}
#[test]
fn a_sealed_page_is_served_sealed() {
let p = Pages::default();
let id = p.hold("<script>alert(1)</script>".into());
let res = respond(&p, &format!("/{id}"));
assert_eq!(res.status(), 200);
let csp = res.headers()["Content-Security-Policy"].to_str().unwrap();
assert!(csp.starts_with("sandbox allow-scripts"));
assert!(!csp.contains("allow-same-origin"));
assert!(csp.contains("connect-src 'none'"));
assert_eq!(respond(&p, "/nope").status(), 404);
assert_eq!(id_of("/p7?x=1"), "p7");
}
}
File diff suppressed because it is too large. Load diff
+29 -6
View File
@@ -1,22 +1,35 @@
//! What the app remembers: which server it opens, and whether the agent should run. One small //! What the app remembers: which server it talks to, whether the agent should run, the theme and
//! JSON file in the app's config directory; nothing secret is in it (the device's key is the //! whether to notify. One small JSON file in the app's config directory; nothing secret is in it
//! (the app's sign-in is `account.json`, readable by this user only; the device's key is the
//! LLeMbas CLI's, in its own config). //! LLeMbas CLI's, in its own config).
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)] #[serde(default)]
pub struct Settings { pub struct Settings {
/// The server's base address, `https://host[:port]`, with no trailing slash. /// The server's base address, `https://host[:port]`, with no trailing slash.
pub server: Option<String>, pub server: Option<String>,
/// Run the agent while the app runs (once this computer is linked to the server). /// Run the agent while the app runs (once this computer is linked to the server).
pub agent: bool, pub agent: bool,
/// The server page's theme the last time it said (`moria` or `shire`): what the window's /// The account's theme the last time the server said (`moria` or `shire`): what the window
/// frame, the title bar and the first-run page are painted in until the server says again. /// is painted in until it says again. None until it has.
/// None until a server page has been shown.
pub theme: Option<String>, pub theme: Option<String>,
/// Say with a notification when a reply or an agent's work finishes out of sight.
pub notify: bool,
}
impl Default for Settings {
fn default() -> Self {
Settings {
server: None,
agent: false,
theme: None,
notify: true,
}
}
} }
pub fn file(config_dir: &Path) -> PathBuf { pub fn file(config_dir: &Path) -> PathBuf {
@@ -85,10 +98,20 @@ mod tests {
let dir = std::env::temp_dir().join(format!("llembas-desktop-test-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("llembas-desktop-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir); let _ = fs::remove_dir_all(&dir);
assert_eq!(load(&dir), Settings::default()); assert_eq!(load(&dir), Settings::default());
assert!(Settings::default().notify);
// A file from before 1.0 has no `notify`: it is on.
fs::create_dir_all(&dir).unwrap();
fs::write(
file(&dir),
r#"{"server":"https://ai.example","agent":true}"#,
)
.unwrap();
assert!(load(&dir).notify);
let s = Settings { let s = Settings {
server: Some("https://ai.example".into()), server: Some("https://ai.example".into()),
agent: true, agent: true,
theme: Some("shire".into()), theme: Some("shire".into()),
notify: false,
}; };
save(&dir, &s).unwrap(); save(&dir, &s).unwrap();
assert_eq!(load(&dir), s); assert_eq!(load(&dir), s);
-44
View File
@@ -1,44 +0,0 @@
// Run in the content view on every page it shows (an initialization script, before the page's
// own scripts). It reports the theme the page is in -- the server sets `data-theme` and
// `data-base` on <html> (base.html, then app.js:applyTheme when the reader switches) -- so the
// app can paint the frame and the title bar in the same look.
//
// The report is a navigation to `llembas-theme:<base>`, which the app's navigation handler
// (lib.rs:allowed) reads and refuses, so nothing loads and the page is not left. That is the
// whole channel: the server's pages get no IPC, and this gives them none -- a page can say one
// of two theme names, and nothing else. The base, not the theme's id: a custom theme on the
// server is a handful of colours over `moria` or `shire`, and the base is the look the app has.
(function () {
"use strict";
// Only the server's pages: the app's own first-run page is painted from the app's side.
if (window.top !== window) return;
if (location.protocol !== "http:" && location.protocol !== "https:") return;
if (location.hostname === "tauri.localhost") return;
var last = "";
function report() {
var root = document.documentElement;
if (!root) return;
var base = root.getAttribute("data-base") || root.getAttribute("data-theme") || "";
if (base !== "moria" && base !== "shire") return;
if (base === last) return;
last = base;
try {
location.assign("llembas-theme:" + base);
} catch (e) { /* refused, as intended */ }
}
function watch() {
var root = document.documentElement;
if (!root) return;
new MutationObserver(report).observe(root, {
attributes: true,
attributeFilter: ["data-theme", "data-base"]
});
report();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", watch);
} else {
watch();
}
})();
+371
View File
@@ -0,0 +1,371 @@
//! Native notifications: a reply finished, or an agent chat's work did, while the person was not
//! looking at it; and a scheduled task has a result the server decided to tell of. The app's own,
//! so the server's Web Push can stay off.
//!
//! The app asks the server's chat list (`/api/v2/chats`) every `EVERY` and notes which chats are
//! being answered; a chat that was and no longer is has finished. The notification is shown
//! unless that chat is open in a window in front. Its words come from the page (`set_words`), in
//! the page's language; the chat's title is the person's own. Nothing from the reply itself is
//! put on screen outside the app.
//!
//! Scheduled tasks: the app follows the server's own event stream (`GET /api/v2/events`), whose
//! `schedules` frame carries the whole list (`{"version", "schedules": [schedule]}`) whenever it
//! changes -- a run starting, waiting, finishing. A schedule's last run that has newly finished is
//! told by the server's notify decision for it: `notified` when the server already told a device,
//! else the schedule's `notify` policy as the server applies it (`services/schedule/notify.py`:
//! `always` -- a result, even "nothing to report"; `matters` -- a result, never a `[SILENT]` one;
//! `never` -- nothing), and a failure the first time in a row and again on the third, which asks
//! whether to pause it. Said by the schedule's title, never the result's words. A server without
//! the stream answers 404, and nothing is said.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde::Deserialize;
use serde_json::Value;
pub const EVERY: Duration = Duration::from_secs(15);
/// What a notification says, in the page's language.
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct Words {
/// A chat's reply is ready.
pub reply: String,
/// An agent chat has finished its work.
pub agent: String,
/// A scheduled task has a result.
#[serde(default = "scheduled_word")]
pub scheduled: String,
/// A scheduled task's run failed.
#[serde(default = "scheduled_failed_word")]
pub scheduled_failed: String,
/// A scheduled task failed three times in a row: pause it?
#[serde(default = "scheduled_pause_word")]
pub scheduled_pause: String,
}
fn scheduled_word() -> String {
"A scheduled task has a result.".into()
}
fn scheduled_failed_word() -> String {
"A scheduled task failed.".into()
}
fn scheduled_pause_word() -> String {
"A scheduled task failed 3 times in a row. Pause it?".into()
}
impl Default for Words {
fn default() -> Self {
Words {
reply: "The reply is ready.".into(),
agent: "The agent has finished.".into(),
scheduled: scheduled_word(),
scheduled_failed: scheduled_failed_word(),
scheduled_pause: scheduled_pause_word(),
}
}
}
/// A chat that has just finished.
#[derive(Clone, Debug, PartialEq)]
pub struct Done {
pub id: String,
pub title: String,
pub agent: bool,
}
#[derive(Default)]
pub struct Watch {
/// The chat the page shows, if any.
pub open: Mutex<Option<String>>,
pub words: Mutex<Words>,
/// Which chats were being answered at the last look (None before the first look).
running: Mutex<Option<HashMap<String, bool>>>,
/// Each schedule's last run at the last look, and whether it had finished (None before the
/// first look).
runs: Mutex<Option<HashMap<String, (String, bool)>>>,
/// Set to end the event stream that is followed now; `reset` sets it and puts a new one here.
stop: Mutex<Arc<AtomicBool>>,
}
/// How a scheduled task's run is told.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Told {
/// It has a result.
Result,
/// It failed.
Failed,
/// It failed for the third time in a row: pause it?
Pause,
}
/// A scheduled task's newly finished run that is worth telling of.
#[derive(Clone, Debug, PartialEq)]
pub struct Ran {
pub title: String,
pub told: Told,
}
/// The run statuses that are an end (`db/models/schedule.py`): `running` and `waiting` are not.
const FINISHED: [&str; 5] = ["ok", "silent", "failed", "skipped", "missed"];
/// The failure in a row that asks whether to pause (`notify.PAUSE_AFTER`).
const PAUSE_AFTER: i64 = 3;
/// Whether a finished run is told, and how: the server's word when it already told (`notified`),
/// else its notify policy for the schedule as the server applies it.
pub fn decide(policy: &str, status: &str, notified: bool, streak: i64) -> Option<Told> {
let failed = matches!(status, "failed" | "skipped");
let kind = if failed && streak == PAUSE_AFTER {
Told::Pause
} else if failed {
Told::Failed
} else {
Told::Result
};
if notified {
return Some(kind);
}
match (policy, status) {
("never", _) => None,
(_, "ok") | ("always", "silent") => Some(Told::Result),
_ if failed && (streak <= 1 || streak == PAUSE_AFTER) => Some(kind),
_ => None,
}
}
impl Watch {
/// Note the chat list as the server gave it; the chats that finished since the last look.
/// The first look only notes: what finished before the app looked is not news.
pub fn look(&self, list: &Value) -> Vec<Done> {
let rows = list
.get("chats")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut now = HashMap::new();
let mut done = Vec::new();
let mut running = self.running.lock().unwrap();
for row in &rows {
let Some(id) = row.get("id").and_then(Value::as_str) else {
continue;
};
let busy = row.get("running").and_then(Value::as_bool).unwrap_or(false);
let was = running
.as_ref()
.and_then(|r| r.get(id).copied())
.unwrap_or(false);
if was && !busy {
done.push(Done {
id: id.to_string(),
title: row
.get("title")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
agent: row.get("kind").and_then(Value::as_str) == Some("agent"),
});
}
now.insert(id.to_string(), busy);
}
*running = Some(now);
done
}
/// Note the schedules as the event stream gave them (`{"schedules": [...]}`); the runs that
/// finished since the last look and are told. The first look only notes, as with the chats; a
/// run is told once, however often the list comes again.
pub fn look_schedules(&self, list: &Value) -> Vec<Ran> {
let rows = list
.get("schedules")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut seen = self.runs.lock().unwrap();
let first = seen.is_none();
let mut now = HashMap::new();
let mut out = Vec::new();
for row in &rows {
let Some(id) = row.get("id").and_then(Value::as_str) else {
continue;
};
let Some(run) = row.get("last_run").filter(|r| r.is_object()) else {
continue;
};
let Some(run_id) = run.get("id").and_then(Value::as_str) else {
continue;
};
let status = run.get("status").and_then(Value::as_str).unwrap_or("");
let finished = FINISHED.contains(&status);
let before = seen.as_ref().and_then(|s| s.get(id));
let known = before.is_some_and(|(r, done)| r == run_id && *done);
if !first && finished && !known {
let told = decide(
row.get("notify").and_then(Value::as_str).unwrap_or(""),
status,
run.get("notified").and_then(Value::as_bool) == Some(true),
row.get("failure_streak")
.and_then(Value::as_i64)
.unwrap_or(0),
);
if let Some(told) = told {
out.push(Ran {
title: row
.get("title")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
told,
});
}
}
now.insert(id.to_string(), (run_id.to_string(), finished));
}
*seen = Some(now);
out
}
/// The flag that ends the event stream followed now.
pub fn stop_flag(&self) -> Arc<AtomicBool> {
self.stop.lock().unwrap().clone()
}
/// Start again from nothing: another server, or signed out.
pub fn reset(&self) {
*self.running.lock().unwrap() = None;
*self.runs.lock().unwrap() = None;
let mut stop = self.stop.lock().unwrap();
stop.store(true, Ordering::SeqCst);
*stop = Arc::new(AtomicBool::new(false));
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// A schedule in a test: its id, notify policy, failure streak, and last run (id, status,
/// whether the server told of it).
type Row<'a> = (&'a str, &'a str, i64, Option<(&'a str, &'a str, bool)>);
fn schedules(rows: &[Row]) -> Value {
json!({"version": "v", "schedules": rows.iter().map(|(id, notify, streak, run)| json!({
"id": id, "title": format!("Task {id}"), "notify": notify, "failure_streak": streak,
"last_run": run.map(|(r, status, told)| json!({"id": r, "status": status, "notified": told})),
})).collect::<Vec<_>>()})
}
fn ran(id: &str, told: Told) -> Ran {
Ran {
title: format!("Task {id}"),
told,
}
}
#[test]
fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() {
let w = Watch::default();
assert!(w
.look_schedules(&schedules(&[
("a", "matters", 0, Some(("r1", "ok", false))),
("b", "always", 0, None)
]))
.is_empty());
// Running is not news; finishing is, even under the same run id.
assert!(w
.look_schedules(&schedules(&[(
"a",
"matters",
0,
Some(("r2", "running", false))
)]))
.is_empty());
let out = w.look_schedules(&schedules(&[
("a", "matters", 0, Some(("r2", "ok", false))),
("b", "always", 0, Some(("r3", "silent", false))),
("c", "matters", 0, Some(("r4", "silent", false))),
("d", "never", 0, Some(("r5", "ok", false))),
]));
assert_eq!(out, vec![ran("a", Told::Result), ran("b", Told::Result)]);
// The same list again (the server told a device meanwhile): not said twice.
assert!(w
.look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))]))
.is_empty());
}
#[test]
fn failures_are_said_the_first_time_and_the_third_asks_to_pause() {
assert_eq!(decide("matters", "failed", false, 1), Some(Told::Failed));
assert_eq!(decide("matters", "failed", false, 2), None);
assert_eq!(decide("always", "skipped", false, 3), Some(Told::Pause));
assert_eq!(decide("never", "failed", false, 1), None);
assert_eq!(decide("matters", "missed", false, 0), None);
// The server's own word wins: it told a device.
assert_eq!(decide("matters", "failed", true, 2), Some(Told::Failed));
assert_eq!(decide("matters", "silent", true, 0), Some(Told::Result));
}
#[test]
fn a_reset_ends_the_stream_and_starts_quiet() {
let w = Watch::default();
let old = w.stop_flag();
w.look_schedules(&schedules(&[(
"a",
"matters",
0,
Some(("r1", "running", false)),
)]));
w.reset();
assert!(old.load(Ordering::SeqCst));
assert!(!w.stop_flag().load(Ordering::SeqCst));
assert!(w
.look_schedules(&schedules(&[(
"a",
"matters",
0,
Some(("r1", "ok", false))
)]))
.is_empty());
}
fn list(rows: &[(&str, bool, &str)]) -> Value {
json!({"chats": rows.iter().map(|(id, running, kind)| json!({
"id": id, "title": format!("Chat {id}"), "running": running, "kind": kind
})).collect::<Vec<_>>()})
}
#[test]
fn a_chat_that_stops_running_has_finished_and_the_first_look_is_quiet() {
let w = Watch::default();
assert!(w
.look(&list(&[("a", true, "chat"), ("b", false, "agent")]))
.is_empty());
assert!(w
.look(&list(&[("a", true, "chat"), ("b", true, "agent")]))
.is_empty());
let done = w.look(&list(&[("a", false, "chat"), ("b", false, "agent")]));
assert_eq!(
done,
vec![
Done {
id: "a".into(),
title: "Chat a".into(),
agent: false
},
Done {
id: "b".into(),
title: "Chat b".into(),
agent: true
},
]
);
// Said once.
assert!(w.look(&list(&[("a", false, "chat")])).is_empty());
w.reset();
assert!(w.look(&list(&[("a", false, "chat")])).is_empty());
}
}
+6 -3
View File
@@ -10,7 +10,10 @@
"withGlobalTauri": true, "withGlobalTauri": true,
"windows": [], "windows": [],
"security": { "security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src ipc: http://ipc.localhost" "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost; frame-src llembas-sealed: http://llembas-sealed.localhost; object-src 'none'; base-uri 'none'; form-action 'none'",
"dangerousDisableAssetCspModification": [
"style-src"
]
} }
}, },
"bundle": { "bundle": {
@@ -20,8 +23,8 @@
"appimage" "appimage"
], ],
"category": "Productivity", "category": "Productivity",
"shortDescription": "Your LLeMbas server in a window, with the agent on this computer", "shortDescription": "The LLeMbas agent on this computer, and your server's chats",
"longDescription": "LLeMbas Desktop opens your LLeMbas server's web UI in a window of its own and runs the LLeMbas CLI agent on this computer, so agent chats can work in your projects here without a terminal.", "longDescription": "LLeMbas Desktop runs the LLeMbas CLI agent on this computer, so agent chats can work in your projects here without a terminal, and shows them, and the chats on your LLeMbas 2.0 server, in an app of its own: the agent's working directory and the server's files side by side, and a viewer for what the agent shows you.",
"license": "MIT", "license": "MIT",
"homepage": "https://llembas.eu", "homepage": "https://llembas.eu",
"publisher": "LLeMbas", "publisher": "LLeMbas",
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"tag": "v1.3.0", "tag": "v2.0.0-beta.6",
"commit": "61cf4c267c983d5895dba3a609f90ae1efd891a0", "commit": "a71e9b4bdb8acb6e9949e978253a72c347cc67f3",
"sha256": "aff95291186b57693469cf1f80452fd9a9e460aa360d45ed94c2f4f979e10c6c" "sha256": "e5f76f542ed5512702e06d69fa1dfa098c511f67dacbd7cc93f4a0618c6ae966"
} }
+1530 -106
View File
File diff suppressed because it is too large. Load diff
-125
View File
@@ -1,125 +0,0 @@
// The first-run page. It talks to the app over Tauri's IPC — the commands in src-tauri/src/lib.rs.
// The server's own pages never get this: they are remote content with no capability.
// Every word shown goes through `t` (i18n.js, loaded before this), in the system's language.
"use strict"
const { invoke } = window.__TAURI__.core
const { listen } = window.__TAURI__.event
const $ = (id) => document.getElementById(id)
const AGENT = {
off: () => [t("The agent is off."), ""],
not_linked: () => [t("This computer is not linked to the server yet."), ""],
starting: () => [t("The agent is starting…"), ""],
running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"],
elsewhere: (pid) => [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"],
failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"],
}
function render(s) {
window.lembasTheme.apply(s.theme)
$("version").textContent = s.version
if (s.server && !$("address").value) $("address").value = s.server
$("agent-section").hidden = !s.server
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
const [text, cls] =
s.agent.state === "running" && noFolders
? [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
: (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail)
const status = $("agent-status")
status.textContent = text
status.className = `status ${cls}`
$("link").textContent = s.linked ? t("Link again") : t("Link this computer")
$("toggle").textContent = s.agent_wanted ? t("Stop the agent") : t("Start the agent")
$("toggle").disabled = !s.linked && !s.agent_wanted
renderFolders(s)
}
function renderFolders(s) {
const r = s.remote
$("folders-section").hidden = !s.server || !s.linked || !r
if (!r) return
const list = $("folders")
list.replaceChildren()
if (!r.enabled || !r.roots.length) {
const li = document.createElement("li")
li.className = "empty"
li.textContent = t("No folder yet: the agent can open nothing. Add the folder of a project.")
list.append(li)
}
for (const dir of r.enabled ? r.roots : []) {
const li = document.createElement("li")
const path = document.createElement("span")
path.className = "path"
path.title = dir
const inner = document.createElement("bdi")
inner.textContent = dir
path.append(inner)
const remove = document.createElement("button")
remove.type = "button"
remove.className = "btn"
remove.textContent = t("Remove")
remove.setAttribute("aria-label", t("Remove %(dir)s", { dir }))
remove.addEventListener("click", () => invoke("remote_remove", { dir }))
li.append(path, remove)
list.append(li)
}
if (r.max_mode) $("max-mode").value = r.max_mode
$("terminal").checked = !!r.terminal
$("folders-status").className = "status"
$("folders-status").textContent = ""
}
async function refresh() {
render(await invoke("get_status"))
}
$("server-form").addEventListener("submit", async (e) => {
e.preventDefault()
const hint = $("server-hint")
const button = e.target.querySelector("button")
button.disabled = true
hint.className = "hint"
hint.textContent = t("Looking for a LLeMbas server there…")
try {
await invoke("set_server", { address: $("address").value })
// The window now shows the server.
} catch (err) {
hint.className = "hint is-error"
hint.textContent = String(err)
} finally {
button.disabled = false
}
})
$("link").addEventListener("click", async () => {
try {
await invoke("link_computer")
} catch (err) {
$("agent-status").className = "status is-error"
$("agent-status").textContent = String(err)
}
})
$("toggle").addEventListener("click", async () => {
const s = await invoke("get_status")
await invoke("set_agent", { on: !s.agent_wanted })
})
$("open").addEventListener("click", async () => {
const s = await invoke("get_status")
if (s.server) await invoke("set_server", { address: s.server })
})
$("add-folder").addEventListener("click", () => invoke("remote_add"))
$("max-mode").addEventListener("change", (e) => invoke("remote_mode", { mode: e.target.value }))
$("terminal").addEventListener("change", (e) => invoke("remote_terminal", { on: e.target.checked }))
listen("remote-error", (e) => {
$("folders-status").className = "status is-error"
$("folders-status").textContent = String(e.payload)
})
listen("status", (e) => render(e.payload))
refresh()
invoke("remote_refresh")
+2272 -64
View File
File diff suppressed because it is too large. Load diff
+143 -60
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" data-theme="moria" data-base="moria">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7,72 +7,155 @@
<title>LLeMbas</title> <title>LLeMbas</title>
<link rel="stylesheet" href="tokens.css"> <link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="app.css"> <link rel="stylesheet" href="app.css">
<script src="theme.js"></script> <script src="i18n.js"></script>
<script src="i18n.js" defer></script> <script type="module" src="js/main.js"></script>
<script src="app.js" defer></script>
</head> </head>
<body> <body>
<main class="card" aria-labelledby="title"> <div class="app">
<header class="brand"> <header class="bar" data-tauri-drag-region>
<img src="logo-mark.svg" alt="" width="40" height="40"> <button class="bar__btn" id="side-toggle" type="button" aria-label="Sidebar" title="Sidebar" aria-expanded="true" aria-controls="side" data-i18n-aria-label data-i18n-title></button>
<div> <img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
<h1 id="title"><b>LL</b>e<b>M</b>bas</h1> <span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
<p class="muted">Desktop <span id="version"></span></p> <span class="bar__host" id="host" data-tauri-drag-region></span>
</div> <button class="bar__update" id="update" type="button" hidden>update</button>
<button class="bar__agent" id="agent" type="button" aria-haspopup="dialog" aria-expanded="false">
<span class="dot" aria-hidden="true"></span><span class="bar__agent-text" id="agent-text">agent off</span>
</button>
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu" data-i18n-aria-label data-i18n-title></button>
<span class="bar__sep" aria-hidden="true"></span>
<button class="bar__btn" id="minimize" type="button" aria-label="Minimise" title="Minimise" data-i18n-aria-label data-i18n-title></button>
<button class="bar__btn" id="maximize" type="button" aria-label="Maximise" title="Maximise" data-i18n-aria-label data-i18n-title></button>
<button class="bar__btn bar__btn--close" id="close" type="button" aria-label="Close" title="Close (LLeMbas stays in the tray)" data-i18n-aria-label data-i18n-title></button>
</header> </header>
<section aria-labelledby="server-h"> <div class="setup" id="setup" hidden>
<h2 id="server-h" data-i18n>Server</h2> <main class="card" aria-labelledby="setup-title">
<form id="server-form" class="field" novalidate> <header class="brand">
<label for="address" data-i18n>Address of your LLeMbas server</label> <img src="logo-mark.svg" alt="" width="40" height="40">
<div class="row"> <div>
<input id="address" name="address" type="text" inputmode="url" autocomplete="url" <h1 id="setup-title"><b>LL</b>e<b>M</b>bas</h1>
placeholder="https://ai.example" spellcheck="false" required> <p class="muted">Desktop <span id="setup-version"></span></p>
<button type="submit" class="btn btn--primary" data-i18n>Connect</button> </div>
</div> </header>
<p class="hint" id="server-hint" aria-live="polite" data-i18n>Sign in there as you would in a browser.</p> <div class="setup__body" id="setup-body"></div>
</form> </main>
</section> </div>
<section aria-labelledby="agent-h" id="agent-section" hidden> <div class="shell" id="shell" hidden data-side="true" data-viewer="false" data-view="chat">
<h2 id="agent-h" data-i18n>Agent</h2> <nav class="side" id="side" aria-label="Chats" data-i18n-aria-label>
<p class="muted" data-i18n>The agent lets agent chats on your server work in this computer's projects. <div class="side__top">
It is the LLeMbas CLI, built in, running in the background while the app runs.</p> <button type="button" class="side__new" id="new-chat-btn"><span class="side__new-label" data-i18n>New chat</span><kbd class="kbd">Ctrl N</kbd></button>
<p class="status" id="agent-status" aria-live="polite"></p> <label class="search">
<div class="row row--buttons"> <span class="search__icon" id="side-search-icon"></span>
<button type="button" class="btn" id="link" data-i18n>Link this computer</button> <input type="search" id="side-search" placeholder="Search chats" aria-label="Search chats" autocomplete="off" spellcheck="false" data-i18n-placeholder data-i18n-aria-label>
<button type="button" class="btn" id="toggle" data-i18n>Start the agent</button> </label>
<button type="button" class="btn btn--primary" id="open" data-i18n>Open the server</button> </div>
</div> <div class="side__nav">
</section> <button type="button" class="navrow" id="nav-scheduled" data-view="scheduled"><span class="navrow__icon"></span><span data-i18n>Scheduled</span></button>
<button type="button" class="navrow" id="nav-files" data-view="files"><span class="navrow__icon"></span><span data-i18n>Files</span></button>
</div>
<ul class="list" id="chat-list" aria-label="Chats" data-i18n-aria-label></ul>
<div class="side__foot">
<button type="button" class="navrow navrow--account" id="settings-btn" data-view="settings"></button>
</div>
</nav>
<div class="scrim" id="scrim" aria-hidden="true"></div>
<section aria-labelledby="folders-h" id="folders-section" hidden> <main class="main" id="main" aria-labelledby="chat-title" data-new="false">
<h2 id="folders-h" data-i18n>Project folders</h2> <header class="head">
<p class="muted" data-i18n>Agent chats from your server can work in these folders and anything inside <h1 class="head__title" id="chat-title"></h1>
them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.</p> <div class="head__place" id="chat-place"></div>
<ul class="folders" id="folders"></ul> <div class="head__tools" id="chat-tools"></div>
<p class="status" id="folders-status" aria-live="polite"></p> </header>
<div class="row row--buttons"> <div class="thread" id="thread">
<button type="button" class="btn btn--primary" id="add-folder" data-i18n>Add folder…</button> <div class="hello" id="new-chat" hidden></div>
</div> <div class="messages" id="messages"></div>
<div class="settings-grid">
<div class="field">
<label for="max-mode" data-i18n>The most an agent chat may do here</label>
<select id="max-mode">
<option value="manual" data-i18n>Manual — asks first</option>
<option value="edit" data-i18n>Edit — asks before commands</option>
<option value="auto" data-i18n>Auto — asks nothing</option>
<option value="plan" data-i18n>Plan — changes nothing</option>
</select>
<p class="hint" data-i18n>A chat asking for more is lowered to this.</p>
</div> </div>
<div class="field"> <div class="dock" id="dock">
<span class="label" data-i18n>Terminal</span> <p class="thread__note is-error" id="thread-note" hidden></p>
<label class="check"><input type="checkbox" id="terminal"> <span data-i18n>Let the server open a terminal here</span></label> <div class="cards" id="cards" hidden></div>
<p class="hint" data-i18n>A shell as you, in one of these folders.</p> <div class="notice" id="link-notice" hidden></div>
<div class="strip" id="activity-strip" hidden aria-live="polite"></div>
<form class="composer" id="composer" hidden data-running="false" data-drag="false">
<ul class="composer__files" id="composer-files" hidden></ul>
<textarea id="composer-text" rows="1" placeholder="Message" aria-label="Message" data-i18n-placeholder data-i18n-aria-label></textarea>
<div class="composer__bar">
<button type="button" class="tool composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
<div class="composer__chips" id="composer-chips"></div>
<button type="button" class="round round--stop" id="stop" hidden aria-label="Stop" title="Stop" data-i18n-aria-label data-i18n-title></button>
<button type="submit" class="round" id="send" aria-label="Send" title="Send" data-i18n-aria-label data-i18n-title></button>
</div>
<p class="composer__drop" aria-hidden="true" data-i18n>Drop the files to attach them</p>
</form>
</div> </div>
</div> </main>
</section>
</main> <section class="page" id="page-files" hidden aria-labelledby="files-title">
<header class="head">
<h1 class="head__title" id="files-title" data-i18n>Files</h1>
<div class="head__place"></div>
<div class="head__tools"><div class="tabs" role="tablist" id="files-tabs"></div></div>
</header>
<div class="page__body files">
<section class="files__pane" id="pane-local" aria-label="This computer" data-i18n-aria-label>
<div class="files__head">
<div class="files__where" id="local-where"></div>
<button type="button" class="tool" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
</div>
<div class="files__tree" id="local-tree"></div>
</section>
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
<div class="files__head">
<div class="files__where muted" id="server-where"></div>
<button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
</div>
<div class="files__tree" id="server-tree"></div>
</section>
</div>
</section>
<section class="page" id="page-scheduled" hidden aria-labelledby="scheduled-title">
<header class="head">
<h1 class="head__title" id="scheduled-title" data-i18n>Scheduled</h1>
<div class="head__place" id="scheduled-place"></div>
<div class="head__tools" id="scheduled-tools"></div>
</header>
<div class="page__body" id="scheduled-body"></div>
</section>
<section class="page settings" id="settings" hidden aria-labelledby="settings-title">
<header class="head">
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
<div class="head__place"></div>
<div class="head__tools"><button type="button" class="tool" id="settings-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button></div>
</header>
<div class="settings__layout">
<nav class="settings__nav" id="settings-nav" aria-label="Settings" data-i18n-aria-label></nav>
<div class="settings__body" id="settings-body"></div>
</div>
</section>
<aside class="activity" id="activity" hidden aria-labelledby="activity-title">
<header class="viewer__head activity__head">
<button type="button" class="tool" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
<h2 class="viewer__name" id="activity-title"></h2>
<button type="button" class="tool" id="activity-close" aria-label="Close the activity panel" title="Close the activity panel" data-i18n-aria-label data-i18n-title></button>
</header>
<div class="activity__body" id="activity-body"></div>
</aside>
<aside class="viewer" id="viewer" hidden aria-labelledby="viewer-name">
<header class="viewer__head">
<div class="viewer__id">
<h2 class="viewer__name" id="viewer-name"></h2>
<p class="viewer__where" id="viewer-where"></p>
</div>
<div class="viewer__modes" id="viewer-modes"></div>
<button type="button" class="tool" id="viewer-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button>
</header>
<div class="viewer__body" id="viewer-body"></div>
</aside>
</div>
</div>
</body> </body>
</html> </html>
+836
View File
@@ -0,0 +1,836 @@
// The chat's activity panel, beside it, opened from the header's button at any time: where an
// agent chat works; what is in its context, by category, against the model's window, how far it
// is to the automatic compaction and a button to compact now; the files the chat and its
// subagents wrote; every background task -- subagents, a team's members as a tree under the one
// that leads them, background commands -- with how long it has run and a stop square, unfolding
// to its latest step, what it is writing and its whole conversation, read-only; the finished ones
// folded; the team's room; and the plan. The line above the message box sums up what runs, while
// anything does, and opens it. The same facts as the server's own panel (`services/activity.py`),
// from /api/v2:
//
// GET /chats/{chat}/activity?since=&context= everything, with a `version`
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
// GET /chats/{chat}/agents/{kind}/{id} one task and its conversation
// POST /chats/{chat}/agents/{kind}/{id}/stop
// POST /chats/{chat}/compact summarise the earlier turns now
//
// The events stream is followed for as long as a chat is open: it keeps the strip current, and
// while the panel is open a new `version` reads the panel again. The context is the costly part
// of a read; it is asked for when the panel opens, after a reply and at most every so often.
//
// Every name, role, brief, step, room message and conversation here is a model's words: set as
// text, and a room message or a reply through the page's own markdown (markdown.js), which
// builds elements and never parses HTML.
import { $, h, clear, icon, ICONS } from "./dom.js"
import { get, post, follow, why } from "./bridge.js"
import { markdown } from "./markdown.js"
import { ask } from "./popup.js"
import { openServerFile } from "./explorer.js"
const ACTIVE = ["queued", "working", "waiting"]
// How many live agents show before "Show N more", as on the server's panel.
const SHOWN = 6
// The context is read again on a change at most this often (ms); always on opening and after a reply.
const CONTEXT_EVERY = 15000
let layout = () => {} // (on) -> the shell makes room for the panel
let onThisComputer = () => false // (chat) -> whether an agent chat works on this computer
let onCompacted = () => {} // (chat id) -> the conversation is read again
let chat = null // the open chat: { id, kind, … }
let stream = null // the events stream's handle
let retry = 0 // the reconnect timer
let failures = 0
let summary = null
let version = ""
let data = null // the last `activity`
let room = [] // the room so far: each read asks only for what came after `cursor`
let cursor = 0
let readAt = 0 // when `data` was read: the elapsed times count on from there
let contextAt = 0
let reading = null // the read in flight
let readAgain = "" // "", "plain" or "context": what to read again once the read in flight is done
let view = null // null: the panel; { kind, id, name }: one agent's conversation
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
let ticker = 0
// --- Words ----------------------------------------------------------------------------------------
/** A duration as both harnesses write one: 5s, 1m 05s, 2h 03m. */
export function duration(seconds) {
const total = Math.max(0, Math.round(Number(seconds) || 0))
if (total < 60) return `${total}s`
const minutes = Math.floor(total / 60)
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, "0")}s`
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`
}
/** A token count at a glance: 950, 14.2k, 180k, 1.54M (the server's `compact_tokens`). */
export function compact(value) {
const n = Math.trunc(Number(value) || 0)
const trim = (x, digits) => x.toFixed(digits).replace(/\.?0+$/, "")
if (n < 1000) return String(n)
if (n < 100000) return `${trim(n / 1000, 1)}k`
if (n < 1000000) return `${Math.round(n / 1000)}k`
return `${trim(n / 1000000, 2)}M`
}
/**
* "3 running · 1 queued · 2 finished", or "" for none: every background task -- a subagent, a
* team's member, a command -- counts the same, as the panel lists them together.
*/
export function summaryText(counts) {
if (!counts) return ""
const parts = []
if (counts.working) parts.push(t("%(n)s running", { n: counts.working }))
if (counts.queued) parts.push(t("%(n)s queued", { n: counts.queued }))
if (counts.waiting) parts.push(t("%(n)s waiting", { n: counts.waiting }))
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
return parts.join(" · ")
}
function stateWord(state) {
return { queued: t("queued"), working: t("working"), waiting: t("waiting"), done: t("done"), stopped: t("stopped"), error: t("failed") }[state] || String(state || "")
}
function contextWord(key) {
return (
{
messages: t("Messages"),
system: t("System prompt"),
memory: t("Memory and personal"),
instructions: t("Instruction files"),
tools: t("Tools"),
mcp_tools: t("MCP tools"),
since: t("Since the last reply"),
buffer: t("Compaction buffer"),
free: t("Free space"),
}[key] || String(key)
)
}
/** A percentage through a sentence written the server's way (`%%` is a percent sign). */
function percentSentence(text, fields) {
return t(text, fields).replace(/%%/g, "%")
}
/** A plan's phases and their tasks, done ones struck through: the panel's and the cards'. */
export function planList(plan) {
return h(
"ol",
{ class: "plan" },
(plan.phases || []).map((p) =>
h("li", null, h("span", { class: "plan__phase", text: p.title }), h("ul", null, (p.tasks || []).map((task) => h("li", { class: `plan__task is-${task.status}`, text: task.text })))),
),
)
}
// --- Following a chat -----------------------------------------------------------------------------
const enc = encodeURIComponent
export function panelOpen() {
return !$("activity").hidden
}
/** The chat whose activity is followed: the one open, or null for none (a new chat, the settings). */
export function watchActivity(next) {
const id = next?.id || null
if (id && chat?.id === id) {
chat = next
return
}
stop()
chat = id ? next : null
summary = null
version = ""
data = null
room = []
cursor = 0
readAt = contextAt = 0
view = null
compactError = ""
openRows.clear()
renderStrip()
markOpen()
if (!chat) {
if (panelOpen()) showPanel(false)
return
}
listenTo(chat.id)
if (panelOpen()) {
render()
read(true)
}
}
/** A reply has ended, or the chat's settings changed: the context and the outputs move with it. */
export function activityChanged() {
if (chat && panelOpen()) read(true)
}
function stop() {
clearTimeout(retry)
retry = 0
failures = 0
stream?.close()
stream = null
}
async function listenTo(id) {
// Held while the stream opens, so nothing opens a second one meanwhile.
const pending = { close() {} }
stream = pending
let ended = false
let handleId = null
try {
const handle = await follow(`/chats/${enc(id)}/activity/events`, {
onFrame: (event, frame) => {
if (chat?.id !== id || event !== "activity" || !frame || typeof frame !== "object") return
failures = 0
heard(frame)
},
// The server closes the stream after a while (`close`): it is opened again at once. A broken
// one is opened again later, more slowly each time -- polling, in effect, while it fails.
onEnd: (err) => {
ended = true
if (chat?.id !== id) return
if (stream === pending || stream?.id === handleId) stream = null
// Not while the window is hidden (in the tray): it is opened again when it shows.
if (document.visibilityState === "hidden") return
failures = err ? failures + 1 : 0
retry = setTimeout(() => {
retry = 0
if (chat?.id === id && !stream) {
if (err && panelOpen()) read(false)
listenTo(id)
}
}, err ? Math.min(60000, 5000 * failures) : 500)
},
})
handleId = handle.id
if (chat?.id !== id || stream !== pending) handle.close()
else if (!ended) stream = handle
} catch {
if (stream === pending) stream = null
if (chat?.id !== id) return
failures += 1
retry = setTimeout(() => {
retry = 0
if (chat?.id === id && !stream) listenTo(id)
}, Math.min(60000, 5000 * failures))
}
}
function heard(frame) {
summary = frame.summary || summary
renderStrip()
if (frame.version && frame.version !== version) {
version = frame.version
if (panelOpen()) read(Date.now() - contextAt > CONTEXT_EVERY)
}
}
/** Read the panel's facts; one read at a time, and one more after it if asked for meanwhile. */
async function read(withContext) {
if (!chat) return
if (reading) {
readAgain = withContext ? "context" : readAgain || "plain"
return reading
}
const id = chat.id
reading = (async () => {
try {
const a = (await get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`)).activity
if (chat?.id !== id || !a) return
if (!withContext) a.context = data?.context ?? null
else contextAt = Date.now()
if (a.team) {
// A cursor lower than ours is a room begun again (a new team): read from the start.
if (a.team.cursor < cursor) {
room = []
cursor = 0
readAgain = readAgain || "plain"
}
for (const m of a.team.room || []) if (m.n > cursor) room.push(m)
cursor = Math.max(cursor, a.team.cursor || 0)
} else {
room = []
cursor = 0
}
data = a
version = a.version || version
summary = a.summary || summary
readAt = Date.now()
renderStrip()
if (view) await readAgent()
else render()
} catch (err) {
if (chat?.id === id && !data) renderError(why(err))
}
})()
await reading
reading = null
if (readAgain) {
const ctxToo = readAgain === "context"
readAgain = ""
read(ctxToo)
}
}
// --- The line above the message box, and the header's button ---------------------------------------
/** Both ways into the panel say whether it is open. */
function markOpen() {
for (const id of ["activity-open", "activity-strip-btn"]) $(id)?.setAttribute("aria-expanded", String(panelOpen()))
}
function renderStrip() {
const strip = $("activity-strip")
// Only while something runs, as the server's own line: the header's button is the way into the
// panel at any other time (and a chat whose tasks all finished long ago does not carry
// "12 finished" for ever: the panel still lists them).
const active = chat && summary?.active ? summaryText(summary) : ""
strip.hidden = !active
if (!active) return clear(strip)
strip.replaceChildren(
h(
"button",
{
type: "button",
id: "activity-strip-btn",
class: "strip__btn is-active",
"aria-label": `${active} — ${panelOpen() ? t("Close the activity panel") : t("Open the activity panel")}`,
"aria-controls": "activity",
"aria-expanded": String(panelOpen()),
onclick: togglePanel,
},
h("span", { class: "dot", "aria-hidden": "true" }),
h("span", { class: "strip__text", text: active }),
icon(ICONS.chevron),
),
)
}
/** The header's button: the panel, open or closed, for every open chat. */
export function activityButton() {
return h(
"button",
{
type: "button",
class: "tool",
id: "activity-open",
"aria-label": t("Context and background tasks"),
title: t("Context and background tasks"),
"aria-controls": "activity",
"aria-expanded": String(panelOpen()),
onclick: togglePanel,
},
icon(ICONS.tasks),
)
}
// --- The panel ------------------------------------------------------------------------------------------
/** Open or close the panel (the header's Activity button, the strip, Escape). */
export function showPanel(on) {
if (on && !chat) return
$("activity").hidden = !on
layout(on)
markOpen()
clearInterval(ticker)
ticker = 0
if (!on) {
view = null
renderStrip()
return
}
ticker = setInterval(tick, 1000)
renderStrip()
render()
read(true)
$("activity-close").focus()
}
export function togglePanel() {
showPanel(!panelOpen())
}
/** The elapsed times of agents still at work count on between reads. */
function tick() {
if (document.visibilityState !== "visible") return
const extra = (Date.now() - readAt) / 1000
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
el.textContent = duration(Number(el.dataset.seconds) + extra)
}
}
function renderError(text) {
clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text }))
}
function section(name, title, count, ...body) {
const el = h(
"details",
{ class: "activity__section", open: !openRows.has(`section:${name}`) },
h("summary", { class: "activity__heading" }, h("span", { text: title }), count ? h("span", { class: "activity__count", text: String(count) }) : null),
...body,
)
el.addEventListener("toggle", () => (el.open ? openRows.delete(`section:${name}`) : openRows.add(`section:${name}`)))
return el
}
function render() {
$("activity").dataset.view = "panel"
const head = $("activity-title")
$("activity-back").hidden = true
head.textContent = t("Activity")
const body = $("activity-body")
if (!data) {
clear(body).append(h("p", { class: "activity__empty", text: t("Loading…") }))
return
}
// Kept where the reader was: the panel is drawn again on every change.
const scroll = body.scrollTop
const entries = data.agents || []
const parts = [
chat?.kind === "agent" && chat.device ? section("environment", t("Environment"), 0, environmentPart(chat)) : null,
section("context", t("Context"), 0, contextPart(data.context)),
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
section("tasks", t("Background tasks"), 0, tasksPart(entries)),
data.team ? section("room", t("Room"), room.length, roomPart()) : null,
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
]
body.replaceChildren(...parts.filter(Boolean))
body.scrollTop = scroll
}
/** How many tasks, nested ones included: those still at work, or those finished. */
function count(list, active) {
let n = 0
for (const a of list) {
if (ACTIVE.includes(a.state) === active) n += 1
n += count(a.children || [], active)
}
return n
}
/** Where an agent chat works: its folder, on this computer or another device. */
function environmentPart(c) {
const dir = c.device?.directory || ""
const name = dir.split(/[\\/]/).filter(Boolean).pop() || dir
const here = onThisComputer(c)
return h(
"p",
{ class: "env", title: dir },
icon(ICONS.folder),
h("bdi", { class: "env__name", text: name }),
h("span", { class: "env__where", text: here ? t("This computer") : t("Another device") }),
)
}
function contextPart(c) {
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
const known = c.percent !== null && c.percent !== undefined
const used = h(
"p",
{ class: "ctx__used" },
h("span", { text: known ? percentSentence("%(percent)s%% used", { percent: c.percent }) : t("%(tokens)s tokens", { tokens: compact(c.used) }) }),
h("span", { class: "ctx__of", text: known ? `${compact(c.used)} / ${compact(c.limit)}` : t("window unknown") }),
)
const cats = c.categories || []
const bar = c.limit
? h(
"div",
{ class: "ctx__bar", role: "img", "aria-label": percentSentence("%(percent)s%% of the context window used", { percent: c.percent ?? 0 }) },
cats
.filter((r) => r.key !== "free" && r.tokens)
.map((r) => {
const seg = h("span", { class: `ctx__seg ctx--${safeKey(r.key)}` })
seg.style.setProperty("--share", `${Math.max(0, Math.min(100, Number(r.percent) || 0))}%`)
return seg
}),
)
: null
const rows = h(
"ul",
{ class: "ctx__rows" },
cats.map((r) =>
h(
"li",
{ class: "ctx__row" },
h("span", { class: `ctx__key ctx--${safeKey(r.key)}`, "aria-hidden": "true" }),
h("span", { class: "ctx__name", text: contextWord(r.key) }),
h("span", { class: "ctx__num", text: compact(r.tokens) }),
h("span", { class: "ctx__num", text: r.percent === null || r.percent === undefined ? "—" : `${r.percent}%` }),
),
),
)
const note = h(
"p",
{ class: "ctx__note" },
`${c.measured ? t("Counted by the model at the last reply; the categories are estimated.") : t("Estimated: no reply has been counted yet.")} ${t("%(n)s tools offered.", { n: c.tools_count ?? 0 })}`,
)
return h("div", { class: "ctx" }, used, bar, rows, compactRow(c), note)
}
/**
* How far it is to the automatic compaction -- the server compacts before a turn once the context
* would reach `threshold` % of the window; 0 is off -- and the button that compacts now.
*/
function compactRow(c) {
const limit = Number(c.limit) || 0
const threshold = Number(c.threshold)
let words
if (!(threshold > 0)) words = t("Automatic compaction is off.")
else if (!limit || threshold >= 100) words = t("Compacts by itself once the window is known.")
else {
const until = Math.floor((limit * threshold) / 100) - (Number(c.used) || 0)
words = until > 0 ? t("%(tokens)s until auto-compact", { tokens: compact(until) }) : t("Compacts before the next reply.")
}
const busy = compacting === chat?.id
return h(
"div",
{ class: "ctx__compact" },
h("span", { class: "ctx__until", text: words }),
h("button", {
type: "button",
class: "btn btn--sm",
text: busy ? t("Compacting…") : t("Compact"),
disabled: busy,
title: t("Summarise the earlier turns now and send the summary in their place."),
onclick: compactNow,
}),
compactError ? h("p", { class: "ctx__error", role: "alert", text: compactError }) : null,
)
}
let compacting = null // the chat being compacted
let compactError = ""
async function compactNow() {
if (!chat || compacting) return
const id = chat.id
compacting = id
compactError = ""
render()
try {
await post(`/chats/${enc(id)}/compact`)
compacting = null
if (chat?.id !== id) return
onCompacted(id)
read(true)
} catch (err) {
compacting = null
if (chat?.id !== id) return
// A server without the route in its /api/v2 says 404 or 405: said as what it means.
compactError = err?.status === 404 || err?.status === 405 ? t("This server cannot compact a chat from the app yet; the web interface can.") : why(err)
render()
}
}
/** A category's key as a class name: the server's words, kept to letters and underscores. */
function safeKey(key) {
return String(key).replace(/[^a-z_]/gi, "") || "other"
}
function outputsPart(outputs) {
if (!outputs.length) return h("p", { class: "activity__empty", text: t("No files written in this chat yet.") })
return h(
"ul",
{ class: "outs" },
outputs.map((o) =>
h(
"li",
{ class: "outs__row" },
icon(ICONS.file),
h("button", { type: "button", class: "outs__name", title: `${o.path} — ${t("Open in the viewer")}`, onclick: () => openServerFile(o.path, o.name) }, h("bdi", { text: o.name || o.path })),
h(
"span",
{ class: "outs__diff" },
o.added === null || o.added === undefined ? null : [h("span", { class: "outs__add", text: `+${o.added}` }), h("span", { class: "outs__del", text: `−${o.removed ?? 0}` })],
),
),
),
)
}
function tasksPart(entries) {
const team = data.team
const box = h("div", { class: "tasks", "aria-live": "polite" })
if (!entries.length && !team) {
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Subagents, a team's members and background commands show here while they work.") }))
return box
}
if (team) {
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of at most %(max)s subagents", { size: team.size, max: team.max }) }))
if (team.goal) box.append(h("p", { class: "tasks__goal", text: team.goal }))
}
// At the top level, what still runs -- with its own tasks under it, finished or not -- and then
// what finished with nothing under it still at work, folded.
const live = entries.filter((e) => ACTIVE.includes(e.state) || count(e.children || [], true))
const ended = entries.filter((e) => !live.includes(e))
box.append(h("p", { class: "tasks__group" }, h("span", { text: t("Running") }), h("span", { class: "activity__count", text: String(count(entries, true)) })))
if (live.length) {
box.append(h("div", { class: "tasks__list" }, live.slice(0, SHOWN).map(agentRows)))
if (live.length > SHOWN) box.append(fold("more", t("Show %(n)s more", { n: live.length - SHOWN }), live.slice(SHOWN)))
} else box.append(h("p", { class: "activity__empty", text: t("Nothing running.") }))
if (ended.length) box.append(fold("finished", t("Finished %(n)s", { n: count(ended, false) }), ended, "tasks__finished"))
return box
}
function roomPart() {
if (!room.length) return h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") })
return h(
"ol",
{ class: "room" },
room.map((m) =>
h(
"li",
{ class: `room__entry${m.report ? " is-report" : ""}` },
h(
"div",
{ class: "room__meta" },
h("span", { class: "room__n", text: `#${m.n}` }),
h("span", { class: "room__route", text: `${m.from} → ${(m.to || []).join(", ")}` }),
m.report ? h("span", { class: "chip room__badge", text: t("report") }) : null,
),
h("div", { class: "room__text md" }, markdown(String(m.text || ""))),
),
),
)
}
/** A folded list of tasks ("Show 3 more", "Finished 12", "2 tasks"), remembered across redraws. */
function fold(name, label, list, cls = "") {
const el = h(
"details",
{ class: `tasks__more ${cls}`, open: openRows.has(`fold:${name}`) },
h("summary", null, h("span", { class: "tasks__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), h("span", { text: label })),
h("div", { class: "tasks__list" }, list.map(agentRows)),
)
el.addEventListener("toggle", () => (el.open ? openRows.add(`fold:${name}`) : openRows.delete(`fold:${name}`)))
return el
}
/** A task and, folded under it, the tasks it leads or started ("1 task", "3 tasks"). */
function agentRows(a) {
const kids = a.children || []
if (!kids.length) return agentRow(a)
const nested = fold(`under:${a.kind}:${a.id}`, kids.length === 1 ? t("1 task") : t("%(n)s tasks", { n: kids.length }), kids, "tasks__nested")
nested.style.setProperty("--depth", String(Math.max(0, Math.min(6, (Number(a.depth) || 0) + 1))))
return [agentRow(a), nested]
}
function agentRow(a) {
const key = `${a.kind}:${a.id}`
const active = ACTIVE.includes(a.state)
const time = h("span", { class: "agent__time", title: stateWord(a.state), text: duration(a.seconds) })
if (active) {
time.dataset.active = ""
time.dataset.seconds = String(Number(a.seconds) || 0)
}
const stopBtn = a.stoppable
? h(
"button",
{
type: "button",
class: "agent__stop",
"aria-label": t("Stop %(name)s", { name: a.name }),
title: t("Stop %(name)s", { name: a.name }),
onclick: (e) => {
e.preventDefault()
e.stopPropagation()
stopAgent(a)
},
},
h("span", { class: "agent__square", "aria-hidden": "true" }),
)
: h("span", { class: "agent__stop is-none", "aria-hidden": "true" })
const facts = h(
"p",
{ class: "agent__facts" },
h("span", { class: "agent__state", text: stateWord(a.state) }),
h("span", { text: kindWord(a) }),
a.kind !== "member" && a.role ? h("code", { class: "agent__id", text: a.name || a.id }) : null,
a.agent ? h("span", { text: a.agent }) : null,
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
a.steps ? h("span", { text: t("%(steps)s steps, %(tools)s tools", { steps: a.steps, tools: a.tools }) }) : null,
)
const step = a.step
? h(
"p",
{ class: `agent__step${a.step.error ? " is-error" : ""}` },
h("span", { class: `dot${a.step.error ? " is-error" : a.step.running ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
h("span", { class: "agent__verb", text: a.step.label || a.step.tool || "" }),
a.step.title ? h("code", { class: "agent__title", text: a.step.title }) : null,
)
: null
const el = h(
"details",
{ class: `agent is-${safeKey(a.state)}`, open: openRows.has(key) },
h(
"summary",
{ class: "agent__row" },
h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)),
h("span", { class: "agent__what", title: label(a) }, h("bdi", { class: "agent__name", text: label(a) })),
time,
stopBtn,
),
h(
"div",
{ class: "agent__body" },
facts,
a.kind === "member" && a.role ? h("p", { class: "agent__role", text: a.role }) : null,
a.brief ? h("p", { class: "agent__brief", text: a.brief }) : null,
step,
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
a.transcript ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) }) : null,
),
)
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, Number(a.depth) || 0))))
el.addEventListener("toggle", () => (el.open ? openRows.add(key) : openRows.delete(key)))
return el
}
/**
* A task's line: a team's member by its name (it is how the room and the others call it); a
* subagent or a command by what it was given to do, its id only where there is nothing else.
*/
function label(a) {
if (a.kind === "member") return a.name || a.id
return a.role || a.brief || a.name || a.id
}
/** What a task is: a team's member, a subagent, or a background command. */
function kindWord(a) {
if (a.kind === "member") return t("Team member")
if (a.type === "command") return t("Background command")
return t("Subagent")
}
async function stopAgent(a) {
if (!chat) return
if (a.kind === "member") {
const yes = await ask({
title: t("Stop %(name)s", { name: a.name }),
text: t("Stop %(name)s and the members under it? They leave the team.", { name: a.name }),
ok: t("Stop"),
cancel: t("Cancel"),
danger: true,
})
if (!yes) return
}
try {
await post(`/chats/${enc(chat.id)}/agents/${enc(a.kind)}/${enc(a.id)}/stop`)
} catch (err) {
// 409: it had finished meanwhile; the read below shows that.
if (err?.status !== 409) renderError(why(err))
}
read(false)
}
// --- One agent's conversation, read-only ------------------------------------------------------------
function openAgent(a) {
view = { kind: a.kind, id: a.id, name: a.name || a.id }
$("activity").dataset.view = "agent"
$("activity-title").textContent = view.name
$("activity-back").hidden = false
clear($("activity-body")).append(h("p", { class: "activity__empty", text: t("Loading…") }))
readAgent()
}
function closeAgent() {
view = null
render()
$("activity-close").focus()
}
async function readAgent() {
if (!chat || !view) return
const { kind, id } = view
let r
try {
r = await get(`/chats/${enc(chat.id)}/agents/${enc(kind)}/${enc(id)}`)
} catch (err) {
if (view?.id === id) clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text: why(err) }))
return
}
if (view?.kind !== kind || view?.id !== id) return
const a = r.agent || {}
const body = $("activity-body")
const atEnd = body.scrollHeight - body.scrollTop - body.clientHeight < 40
const head = h(
"div",
{ class: "convo__head" },
h(
"p",
{ class: "agent__facts" },
h("span", { class: "agent__state", text: stateWord(a.state) }),
h("span", { text: duration(a.seconds) }),
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
),
a.role ? h("p", { class: "agent__role", text: a.role }) : null,
a.stoppable ? h("button", { type: "button", class: "btn btn--sm", text: t("Stop %(name)s", { name: a.name || id }), onclick: () => stopAgent(a) }) : null,
)
const turns = (r.transcript || []).map((turn) => {
if (turn.role === "user") {
return h("article", { class: `msg msg--user convo__turn${turn.machine ? " is-machine" : ""}` }, h("div", { class: "msg__text", text: String(turn.text || "") }))
}
return h(
"article",
{ class: "msg msg--assistant convo__turn" },
turn.text ? h("div", { class: "md" }, markdown(String(turn.text))) : null,
turn.tools?.length
? h(
"ul",
{ class: "convo__tools" },
turn.tools.map((c) =>
h(
"li",
{ class: "convo__tool" },
h("span", { class: `dot${c.isError ? " is-error" : " is-ok"}`, "aria-hidden": "true" }),
h("span", { class: "agent__verb", text: String(c.name || "") }),
c.title ? h("code", { class: "agent__title", text: String(c.title) }) : null,
),
),
)
: null,
turn.error ? h("p", { class: "msg__note is-error", text: String(turn.error) }) : null,
turn.running ? h("p", { class: "msg__status muted", text: t("Writing…") }) : null,
)
})
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
if (atEnd) body.scrollTop = body.scrollHeight
}
// --- Start ------------------------------------------------------------------------------------------
export function initActivity({ onLayout, here, compacted }) {
layout = onLayout || layout
onThisComputer = here || onThisComputer
onCompacted = compacted || onCompacted
$("activity-close").replaceChildren(icon(ICONS.close))
$("activity-close").addEventListener("click", () => showPanel(false))
$("activity-back").replaceChildren(icon(ICONS.back))
$("activity-back").addEventListener("click", closeAgent)
// Before the viewer's own Escape (capture): while the viewer is open, it closes first and the
// panel stays; one Escape closes one thing.
document.addEventListener(
"keydown",
(e) => {
if (e.key !== "Escape" || !panelOpen() || e.defaultPrevented || document.querySelector("dialog[open], .menu")) return
if (!$("viewer").hidden) return
e.preventDefault()
if (view) closeAgent()
else showPanel(false)
},
true,
)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible" || !chat) return
if (!stream && !retry) listenTo(chat.id)
if (panelOpen()) read(false)
})
}
+134
View File
@@ -0,0 +1,134 @@
// The agent on this computer as the person sees it: one sentence for its state, and the one thing
// to do about it -- link this computer, link it again, add a folder, start it. The same words in
// three places: the title bar's chip (its popover), above the composer of a new agent chat, and
// Settings. The state is the CLI's word for its link (src-tauri/src/agent.rs); "refused" is the
// server's word only, with the server's reason.
import { h, icon, ICONS } from "./dom.js"
import { invoke, listen, why } from "./bridge.js"
let linking = null // { user_code, uri } while a link waits for its approval
let linkError = ""
const watchers = new Set()
/** Linked and allowed into a folder: what an agent chat needs. */
export function agentReady(s) {
const a = s?.agent || {}
const linked = a.state === "running" || (a.state === "elsewhere" && a.detail?.linked)
return linked && !!(s.remote?.enabled && s.remote.roots?.length)
}
/**
* The agent's state for the page: `tone` (ok, busy, warn, off), the sentence, and `action`: what
* the one button does ("link", "relink", "folder", "start" or null).
*/
export function agentState(s) {
const a = s?.agent || { state: "off" }
const why = typeof a.detail === "string" ? a.detail : ""
const noFolders = s?.remote && !(s.remote.enabled && s.remote.roots?.length)
switch (a.state) {
case "not_linked":
return { tone: "warn", action: "link", sentence: t("This computer is not linked yet."), more: t("Link it to your server, and agent chats can work in its folders.") }
case "refused":
return { tone: "warn", action: "relink", sentence: t("The server refused this computer's link: %(why)s", { why: why || "…" }), more: t("Link it again to give the agent a new token.") }
case "starting":
return { tone: "busy", action: null, sentence: t("The agent is starting…") }
case "connecting":
return { tone: "busy", action: null, sentence: t("The agent is connecting to the server…") }
case "waiting":
return { tone: "warn", action: null, sentence: t("The agent is not connected: %(why)s. It keeps trying.", { why: why || "…" }) }
case "failed":
return { tone: "warn", action: s.linked ? "start" : "link", sentence: t("The agent stopped: %(why)s. Trying again shortly.", { why: why || "…" }) }
case "elsewhere": {
const pid = a.detail?.pid
if (!a.detail?.linked) return { tone: "warn", action: null, sentence: t("A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s", { pid, why: a.detail?.why || "…" }) }
if (noFolders) return { tone: "warn", action: "folder", sentence: t("The agent is connected, but may open no folder yet.") }
return { tone: "ok", action: null, sentence: t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }) }
}
case "running":
if (noFolders) return { tone: "warn", action: "folder", sentence: t("The agent is connected, but may open no folder yet.") }
return { tone: "ok", action: null, sentence: t("The agent is running: agent chats can use this computer.") }
default:
return { tone: "off", action: s?.linked ? "start" : "link", sentence: s?.linked ? t("The agent is off.") : t("This computer is not linked yet.") }
}
}
/** Start linking this computer: the approval opens in the browser; the page hears `linking`. */
export function link() {
linkError = ""
changed()
return invoke("link_computer").catch((err) => {
linkError = why(err)
changed()
})
}
const LABELS = {
link: () => t("Link this computer"),
relink: () => t("Link again"),
folder: () => t("Add folder…"),
start: () => t("Start the agent"),
}
function run(action) {
if (action === "link" || action === "relink") return link()
if (action === "folder") return invoke("remote_add")
if (action === "start") return invoke("set_agent", { on: true })
}
/**
* The agent's state as a block: its sentence, a line more where there is one, and its button;
* while a link waits for approval, the code to compare and Cancel instead. `compact` leaves the
* second line out.
*/
export function agentBlock(s, { compact = false } = {}) {
const st = agentState(s)
const el = h("div", { class: `agentbox is-${st.tone}`, role: "status", "aria-live": "polite" })
el.append(h("span", { class: "agentbox__dot", "aria-hidden": "true" }), h("p", { class: "agentbox__text", text: st.sentence }))
if (!compact && st.more) el.append(h("p", { class: "agentbox__more", text: st.more }))
if (linking) {
el.append(
h("p", { class: "agentbox__more", text: t("Approve this code in the page that opened in your browser:") }),
h("p", { class: "code agentbox__code", text: linking.user_code }),
h(
"div",
{ class: "agentbox__actions" },
h("button", { type: "button", class: "btn btn--sm", text: t("Open the page again"), onclick: () => invoke("open_external", { url: linking.uri }).catch(() => {}) }),
h("button", { type: "button", class: "btn btn--sm", text: t("Cancel"), onclick: () => invoke("link_cancel") }),
),
)
} else if (st.action) {
el.append(
h(
"div",
{ class: "agentbox__actions" },
h("button", { type: "button", class: `btn btn--sm ${st.tone === "warn" || st.tone === "off" ? "btn--primary" : ""}`, onclick: () => run(st.action) }, st.action === "link" || st.action === "relink" ? icon(ICONS.link) : null, LABELS[st.action]()),
),
)
}
if (linkError) el.append(h("p", { class: "agentbox__error", text: linkError }))
return el
}
/** Something that shows `agentBlock` and draws it again when linking moves on. */
export function onAgentChange(fn) {
watchers.add(fn)
return () => watchers.delete(fn)
}
function changed() {
for (const fn of watchers) fn()
}
export function linkingNow() {
return linking
}
export function initAgent() {
listen("linking", (e) => {
const p = e.payload
linking = p.state === "code" ? { user_code: p.user_code, uri: p.uri } : null
linkError = p.state === "failed" ? p.message : ""
changed()
})
}
+176
View File
@@ -0,0 +1,176 @@
// The files the next message carries. Three ways in, each the person's own act: the attach
// button (the system's file dialog, in Rust), files dropped on the window (Rust holds the drop;
// the page never names a path), and an image pasted into the composer (its bytes, as pasted).
// Each is sent to the server at once (`/api/v2/attachments`), shown above the composer, and goes
// with the message that is sent next; one taken off before that is deleted on the server.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke, listen, del, why } from "./bridge.js"
import { picture } from "./models.js"
let pending = [] // { key, name, size, state: "sending" | "ready" | "failed", id?, error?, thumb? }
let seq = 0
let ctx = null // { chat(): the open chat's id, or "" for one not made yet; allowed(): bool }
const REASONS = {
folder: () => t("This is a folder; attach the files in it."),
too_large: () => t("This file is too large to send."),
}
/** The ids of the files sent and ready to go with the message. */
export function readyIds() {
return pending.filter((p) => p.state === "ready").map((p) => p.id)
}
/** A file is still being sent. */
export function sending() {
return pending.some((p) => p.state === "sending")
}
/** After a send: the files belong to the message now. */
export function sent() {
pending = []
render()
}
/** Another chat opened, or a new one: files never sent are deleted on the server. */
export function discard() {
for (const p of pending) if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
pending = []
render()
}
function remove(p) {
if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
pending = pending.filter((x) => x !== p)
render()
}
/**
* One file as the composer shows it: a tile -- a picture's thumbnail (a pasted one from its own
* bytes, a sent one through the bridge), or the file's icon -- its name, its size or state, and
* a button to take it off. The same tracks on every tile.
*/
function row(p) {
const status =
p.state === "sending" ? t("Sending…") : p.state === "failed" ? p.error || "" : p.size ? bytes(p.size) : ""
const tile = h(
"li",
{ class: `tile file is-${p.state}${p.thumb ? " has-thumb" : ""}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
p.thumb ? h("img", { class: "tile__thumb", src: p.thumb, alt: "", draggable: "false" }) : null,
h("span", { class: "tile__icon" }, icon(ICONS.file)),
h("span", { class: "tile__name" }, h("bdi", { text: p.name })),
h("span", { class: "tile__size", text: status }),
h(
"button",
{
type: "button",
class: "file__remove",
"aria-label": t("Remove %(dir)s", { dir: p.name }),
title: t("Remove"),
disabled: p.state === "sending",
onclick: () => remove(p),
},
icon(ICONS.close),
),
)
return tile
}
function render() {
const list = clear($("composer-files"))
list.className = "tiles composer__files"
list.hidden = !pending.length
for (const p of pending) list.append(row(p))
}
/** Show `names` as being sent while `call` sends them; then what became of each. */
async function track(names, call) {
const placeholders = names.map((name) => ({ key: ++seq, name, size: 0, state: "sending" }))
pending.push(...placeholders)
render()
let results
try {
results = await call()
} catch (err) {
results = placeholders.map((p) => ({ name: p.name, size: 0, error: why(err) }))
}
pending = pending.filter((p) => !placeholders.includes(p))
for (const r of results || []) {
pending.push(
r.attachment
? { key: ++seq, name: r.attachment.name || r.name, size: r.attachment.size || r.size, state: "ready", id: r.attachment.id, image: !!r.attachment.image, thumb: r.thumb }
: { key: ++seq, name: r.name, size: r.size, state: "failed", error: (REASONS[r.reason] || (() => r.error))() },
)
}
render()
// A sent picture with no thumbnail of its own yet: the server's copy, through the bridge.
for (const p of pending) {
if (p.state !== "ready" || !p.image || p.thumb) continue
picture(`/attachments/${p.id}`).then((url) => {
if (!url || !url.startsWith("data:image/") || !pending.includes(p)) return
p.thumb = url
render()
})
}
}
/** A pasted picture's own bytes as a `data:` URL: the page's policy allows images from there. */
function dataUrl(file) {
return new Promise((resolve) => {
if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) return resolve(undefined)
const reader = new FileReader()
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : undefined)
reader.onerror = () => resolve(undefined)
reader.readAsDataURL(file)
})
}
/** Choose files in the system's dialog. */
async function pick() {
const b = $("attach")
b.disabled = true
try {
await track([], () => invoke("attach_pick", { chat: ctx.chat() || null, title: t("Attach files") }))
} finally {
b.disabled = false
}
}
/** Files pasted into the composer (a screenshot, mostly): sent from their bytes. */
async function paste(e) {
const files = [...(e.clipboardData?.files || [])]
if (!files.length || !ctx.allowed()) return
e.preventDefault()
for (const f of files) {
const name = f.name || "pasted"
track([name], async () => {
const data = new Uint8Array(await f.arrayBuffer())
const attachment = await invoke("attach_bytes", data, { headers: { "x-name": encodeURIComponent(name), "x-chat": ctx.chat() || "" } })
return [{ name, size: f.size, attachment, thumb: await dataUrl(f) }]
})
}
}
export function initAttach(context) {
ctx = context
$("attach").replaceChildren(icon(ICONS.attach))
$("attach").addEventListener("click", pick)
$("composer-text").addEventListener("paste", paste)
const usable = () => !$("composer").hidden && !$("shell").hidden && ctx.allowed()
listen("drag", (e) => {
$("composer").dataset.drag = String(!!e.payload && usable())
})
listen("drop", (e) => {
$("composer").dataset.drag = "false"
if (!usable()) return
const names = Array.isArray(e.payload) ? e.payload : []
track(names, () => invoke("attach_dropped", { chat: ctx.chat() || null }))
})
}
/** Whether this account may attach files at all: the button shows only then. */
export function allowAttach(on) {
$("attach").hidden = !on
$("composer").dataset.attach = String(on)
}
+72
View File
@@ -0,0 +1,72 @@
// The page's one way out: Tauri's commands (src-tauri/src/lib.rs). The page holds no token and
// makes no request of its own -- the content security policy allows it none but the bridge. The
// server is reached through `api` and `follow`, which Rust sends with the app's sign-in.
const tauri = window.__TAURI__
export const invoke = tauri.core.invoke
export const listen = tauri.event.listen
export const win = tauri.window.getCurrentWindow()
/** A request to the server's /api/v2: `path` from there on. Rejects with {status, detail}. */
export function api(method, path, body) {
return invoke("api", { method, path, body: body === undefined ? null : body })
}
export const get = (path) => api("GET", path)
export const post = (path, body) => api("POST", path, body ?? {})
export const patch = (path, body) => api("PATCH", path, body)
export const del = (path) => api("DELETE", path)
/** A `GET` on /api/v2 that answers plain text (a shown file). Rejects with {status, detail}. */
export const getText = (path) => invoke("api_text", { path })
/** An error's words, whatever shape it came in. */
export function why(err) {
if (err && typeof err === "object" && "detail" in err) return String(err.detail)
return String(err)
}
// Frames of every followed reply arrive as one event; they are handed on by stream id. A frame
// can arrive before `stream_open` has answered with its id, so frames for an unknown id wait.
const followers = new Map()
const early = new Map()
listen("stream", (e) => {
const { id, event, data } = e.payload
const f = followers.get(id)
if (f) deliver(f, id, event, data)
else {
if (!early.has(id)) early.set(id, [])
early.get(id).push([event, data])
}
})
function deliver(f, id, event, data) {
if (event === "end") {
followers.delete(id)
f.onEnd(data)
} else f.onFrame(event, data)
}
/**
* Follow a reply: `onFrame(event, data)` for each frame, `onEnd(error|null)` once. The handle's
* `close()` stops it (onEnd still comes).
*/
export async function follow(path, { onFrame, onEnd }) {
const id = await invoke("stream_open", { path })
const f = { onFrame, onEnd }
followers.set(id, f)
for (const [event, data] of early.get(id) || []) {
if (!followers.has(id)) break
deliver(f, id, event, data)
}
early.delete(id)
return {
id,
close() {
invoke("stream_close", { id })
},
}
}
/** A web link, in the system's browser. */
export function openLink(url) {
return invoke("open_external", { url }).catch(() => {})
}
+1142
View File
File diff suppressed because it is too large. Load diff
+104
View File
@@ -0,0 +1,104 @@
// Source in the viewer: CodeMirror, read-only, from the server's own vendored build (ui/vendor,
// copied and pinned by scripts/fetch-vendor.sh). The look is the server's editor's: colours from
// the theme's tokens, never CodeMirror's own. Loaded the first time a file is shown.
let cm = null
async function load() {
if (cm) return cm
const [state, view, language, search, commands, highlight] = await Promise.all([
import("../vendor/cm-state.mjs"),
import("../vendor/cm-view.mjs"),
import("../vendor/cm-language.mjs"),
import("../vendor/cm-search.mjs"),
import("../vendor/cm-commands.mjs"),
import("../vendor/lezer-highlight.mjs"),
])
const { EditorView } = view
const { HighlightStyle, syntaxHighlighting } = language
const { tags } = highlight
// As the server's static/js/editor.js has them.
const look = EditorView.theme({
"&": { color: "var(--ink)", backgroundColor: "var(--code-bg)", fontSize: "var(--text-sm)", height: "100%" },
".cm-scroller": { fontFamily: "var(--font-mono)", lineHeight: "var(--leading-normal)" },
".cm-content": { caretColor: "var(--accent)" },
".cm-gutters": { backgroundColor: "var(--code-bg)", color: "var(--ink-faint)", border: "none" },
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "var(--surface-hover)" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "var(--selection)" },
"&.cm-focused": { outline: "none" },
".cm-panels": { backgroundColor: "var(--surface)", color: "var(--ink)" },
".cm-searchMatch": { backgroundColor: "var(--accent-soft)" },
})
const colours = HighlightStyle.define([
{ tag: [tags.keyword, tags.operatorKeyword, tags.modifier], color: "var(--accent)" },
{ tag: [tags.string, tags.special(tags.string)], color: "var(--leaf)" },
{ tag: [tags.comment], color: "var(--ink-faint)", fontStyle: "italic" },
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--warning)" },
{ tag: [tags.function(tags.variableName), tags.definition(tags.variableName)], color: "var(--ink)", fontWeight: "600" },
{ tag: [tags.heading], color: "var(--accent)", fontWeight: "700" },
{ tag: [tags.link, tags.url], color: "var(--accent)", textDecoration: "underline" },
{ tag: [tags.tagName, tags.typeName, tags.className], color: "var(--accent-hover)" },
{ tag: [tags.invalid], color: "var(--danger)" },
])
cm = {
state,
view,
base: [
view.lineNumbers(),
view.highlightActiveLine(),
view.highlightActiveLineGutter(),
view.drawSelection(),
view.keymap.of([...search.searchKeymap, ...commands.defaultKeymap]),
search.highlightSelectionMatches(),
state.EditorState.readOnly.of(true),
view.EditorView.editable.of(false),
view.EditorView.lineWrapping,
look,
syntaxHighlighting(colours),
],
}
return cm
}
/** The language support for a name as `show` gives it; nothing for one without support. */
async function language(name) {
try {
switch (name) {
case "markdown":
return (await import("../vendor/cm-lang-markdown.mjs")).markdown()
case "python":
return (await import("../vendor/cm-lang-python.mjs")).python()
case "javascript":
case "jsx":
return (await import("../vendor/cm-lang-javascript.mjs")).javascript({ jsx: name === "jsx" })
case "typescript":
case "tsx":
return (await import("../vendor/cm-lang-javascript.mjs")).javascript({ typescript: true, jsx: name === "tsx" })
case "html":
return (await import("../vendor/cm-lang-html.mjs")).html()
case "css":
case "scss":
return (await import("../vendor/cm-lang-css.mjs")).css()
case "json":
return (await import("../vendor/cm-lang-json.mjs")).json()
default:
return []
}
} catch {
return []
}
}
/** Show `text` in `parent`, read-only, at `line` when given. Returns something to `destroy()`. */
export async function codeView(parent, text, { lang = "", line = 0 } = {}) {
const { state, view, base } = await load()
const ed = new view.EditorView({
parent,
state: state.EditorState.create({ doc: text, extensions: [base, await language(lang)] }),
})
if (line > 0 && line <= ed.state.doc.lines) {
const at = ed.state.doc.line(line).from
ed.dispatch({ selection: { anchor: at }, effects: view.EditorView.scrollIntoView(at, { y: "center" }) })
}
return ed
}
+138
View File
@@ -0,0 +1,138 @@
// Building the page: elements made in code, text always as text. Nothing here ever parses a
// string as HTML -- what the server sends (a reply, a file, a title) is model output or the
// person's own words, and is set with textContent, never innerHTML.
export const $ = (id) => document.getElementById(id)
/**
* h("button", { class: "btn", onclick: fn, "aria-label": "…" }, "text", child, [more])
* Attributes: `class`, `text`, `hidden`, `on<event>` (a function), `dataset` (an object), any
* other as an attribute (false/null/undefined leave it out, true sets it empty).
*/
export function h(tag, attrs, ...children) {
const el = document.createElement(tag)
for (const [key, value] of Object.entries(attrs || {})) {
if (value === null || value === undefined || value === false) continue
if (key === "class") el.className = value
else if (key === "text") el.textContent = value
else if (key === "dataset") Object.assign(el.dataset, value)
else if (key.startsWith("on") && typeof value === "function") el.addEventListener(key.slice(2), value)
else if (key === "hidden") el.hidden = !!value
else if (key === "value") el.value = value
else if (key === "checked") el.checked = !!value
else if (key === "disabled") el.disabled = !!value
else el.setAttribute(key, value === true ? "" : String(value))
}
append(el, children)
return el
}
export function append(el, children) {
for (const c of children.flat(Infinity)) {
if (c === null || c === undefined || c === false) continue
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
}
return el
}
export function clear(el) {
el.replaceChildren()
return el
}
/** An inline SVG icon from a path list, drawn in currentColor. */
export function icon(d, label) {
const ns = "http://www.w3.org/2000/svg"
const svg = document.createElementNS(ns, "svg")
svg.setAttribute("viewBox", "0 0 16 16")
svg.setAttribute("aria-hidden", "true")
svg.classList.add("icon")
for (const one of [].concat(d)) {
const p = document.createElementNS(ns, "path")
p.setAttribute("d", one)
p.setAttribute("fill-rule", "evenodd")
svg.append(p)
}
if (label) {
svg.removeAttribute("aria-hidden")
svg.setAttribute("role", "img")
svg.setAttribute("aria-label", label)
}
return svg
}
export const ICONS = {
plus: "M7.25 3h1.5v4.25H13v1.5H8.75V13h-1.5V8.75H3v-1.5h4.25z",
close: "M4.5 3.4L8 6.9l3.5-3.5 1.1 1.1L9.1 8l3.5 3.5-1.1 1.1L8 9.1l-3.5 3.5-1.1-1.1L6.9 8 3.4 4.5z",
folder: "M1.5 3h5l1.5 1.5h6.5v8.5h-13zM3 6v5.5h10V6z",
file: "M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z",
chevron: "M6 3.5L10.5 8 6 12.5 4.9 11.4 8.3 8 4.9 4.6z",
menu: "M3 4h10v1.5H3zM3 7.25h10v1.5H3zM3 10.5h10V12H3z",
side: "M2 3h12v10H2zM3.5 4.5v7H6v-7z",
gear: "M7 1.5h2l.4 1.8 1.1.5 1.6-1 1.4 1.4-1 1.6.5 1.1 1.8.4v2l-1.8.4-.5 1.1 1 1.6-1.4 1.4-1.6-1-1.1.5L9 14.5H7l-.4-1.8-1.1-.5-1.6 1-1.4-1.4 1-1.6-.5-1.1-1.8-.4v-2l1.8-.4.5-1.1-1-1.6 1.4-1.4 1.6 1 1.1-.5zM8 5.75a2.25 2.25 0 100 4.5 2.25 2.25 0 000-4.5z",
refresh: "M8 2.5a5.5 5.5 0 015.2 3.7H11.5v1.5H15V4.2h-1.5v1A7 7 0 001 8h1.5A5.5 5.5 0 018 2.5zM2.5 11.8v-1h1.7A5.5 5.5 0 0013.5 8H15A7 7 0 012.5 11.8z",
min: "M4 10h8v1.5H4z",
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
chevronDown: "M3.5 6L8 10.5 12.5 6 11.4 4.9 8 8.3 4.6 4.9z",
more: "M2.5 6.75h2.5v2.5H2.5zM6.75 6.75h2.5v2.5h-2.5zM11 6.75h2.5v2.5H11z",
edit: "M10.75 1.75l3.5 3.5-8.5 8.5h-3.5v-3.5zM3.75 10.87v1.38h1.38l6.62-6.62-1.38-1.38z",
copy: "M5 1.5h8.5V11H5zM6.5 3v6.5H12V3zM2.5 5H4v8.5h6.5V15h-8z",
attach: "M7.25 2.5h1.5v4.75h4.75v1.5H8.75v4.75h-1.5V8.75H2.5v-1.5h4.75z",
bolt: "M9.5 1L3 9.25h4.25L6.5 15 13 6.75H8.75z",
stop: "M4.5 4.5h7v7h-7z",
tasks: "M2 3.25h2v2H2zM5.5 3.5H14V5H5.5zM2 7h2v2H2zM5.5 7.25H14v1.5H5.5zM2 10.75h2v2H2zM5.5 11H14v1.5H5.5z",
back: "M10 3.5L5.5 8 10 12.5 11.1 11.4 7.7 8 11.1 4.6z",
terminal: "M1.5 2.5h13v11h-13zM3 4v8h10V4zM4.2 5.4l2.6 2.6-2.6 2.6-1-1L4.8 8 3.2 6.4zM7.5 10h4v1.5h-4z",
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
send: "M8 2.25l5.03 5.03-1.06 1.06-3.22-3.22V13.5h-1.5V5.12L4.03 8.34 2.97 7.28z",
clock: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 4.5h1.5v3.19l2.03 2.03-1.06 1.06-2.47-2.47z"],
chat: "M2 2.5h12v8.5H7l-3.5 3v-3H2zM3.5 4v5.5h1.5v1.25L6.45 9.5h6.05V4z",
bulb: ["M8 1.5a4.5 4.5 0 012.75 8.06v1.94h-5.5V9.56A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-1.83 5.38l.58.45V10h2.5V8.83l.58-.45A3 3 0 008 3z", "M5.75 12.5h4.5V14h-4.5z"],
users: ["M6 2.5a2.5 2.5 0 110 5 2.5 2.5 0 010-5zM6 4a1 1 0 100 2 1 1 0 000-2z", "M1.5 13.5c0-2.5 2-4.25 4.5-4.25s4.5 1.75 4.5 4.25H9c0-1.6-1.3-2.75-3-2.75S3 11.9 3 13.5z", "M11 3.5a2 2 0 110 4 2 2 0 010-4z", "M11.75 9.4c1.65.35 2.75 1.85 2.75 4.1H13c0-1.4-.55-2.4-1.6-2.7z"],
shield: "M8 1l5.5 2v4.5c0 3.3-2.3 6-5.5 7.5-3.2-1.5-5.5-4.2-5.5-7.5V3zM4 4.05V7.5c0 2.45 1.6 4.6 4 5.85 2.4-1.25 4-3.4 4-5.85V4.05L8 2.6z",
link: ["M6.47 8.47l2-2 1.06 1.06-2 2z", "M4.6 7.4l1.06 1.06-1.4 1.4a1.5 1.5 0 002.12 2.12l1.4-1.4 1.06 1.06-1.4 1.4a3 3 0 01-4.24-4.24z", "M11.4 8.6l-1.06-1.06 1.4-1.4a1.5 1.5 0 00-2.12-2.12l-1.4 1.4L7.16 4.36l1.4-1.4a3 3 0 014.24 4.24z"],
pause: ["M4.5 3.5H7v9H4.5z", "M9 3.5h2.5v9H9z"],
play: "M5 2.75L13 8l-8 5.25z",
user: ["M8 2a3 3 0 110 6 3 3 0 010-6zm0 1.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z", "M2.5 14c0-2.9 2.4-4.75 5.5-4.75S13.5 11.1 13.5 14H12c0-2-1.65-3.25-4-3.25S4 12 4 14z"],
check: "M6.5 10.4l6-6 1.06 1.06L6.5 12.53 2.94 8.97 4 7.9z",
bell: ["M8 1.5a4.5 4.5 0 014.5 4.5v3l1.5 2.5H2L3.5 9V6A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-3 3v3.42l-.35.58h6.7L11 9.42V6a3 3 0 00-3-3z", "M6.25 12.5h3.5a1.75 1.75 0 01-3.5 0z"],
info: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 7h1.5v4.5h-1.5zM7.25 4.5h1.5V6h-1.5z"],
}
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
export function when(stamp) {
if (!stamp) return ""
const then = new Date(stamp)
if (Number.isNaN(then.getTime())) return ""
const seconds = Math.round((then.getTime() - Date.now()) / 1000)
const rtf = new Intl.RelativeTimeFormat(document.documentElement.lang || undefined, { numeric: "auto" })
const steps = [
[60, "second"],
[60, "minute"],
[24, "hour"],
[7, "day"],
[4.35, "week"],
[12, "month"],
[Infinity, "year"],
]
let value = seconds
for (const [size, unit] of steps) {
if (Math.abs(value) < size) return rtf.format(Math.round(value), unit)
value /= size
}
return ""
}
/** A size in bytes, as a person reads it. */
export function bytes(n) {
if (!Number.isFinite(n)) return ""
const units = ["B", "KB", "MB", "GB"]
let i = 0
let v = n
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i += 1
}
return `${i === 0 ? v : v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
}
+182
View File
@@ -0,0 +1,182 @@
// The two explorers of the Files page, kept apart as two tabs: this computer's -- the agent's
// folders, read through a command that reaches only them (src-tauri/src/files.rs) -- and the
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke, get, why } from "./bridge.js"
import { openViewer } from "./viewer.js"
// The server's tree as the app's door gives it, read as the person (their own access, no chat):
// GET /files?path=/a -> { path, parent, entries: [{ name, path, kind: "dir"|"file"|"mount", size, mime, updated_at }] }
// GET /files/content?path= -> { path, name, size, mime, kind, language, sha, text, withheld?: { reason, message } }
// A path the person cannot read is the server's 404, in its words.
export const SERVER_FILES = {
list: (path) => `/files?path=${encodeURIComponent(path || "/")}`,
read: (path) => `/files/content?path=${encodeURIComponent(path)}`,
}
/** A directory in either tree: the server's mounts open like its folders. */
const isDir = (e) => e.kind === "dir" || e.kind === "mount"
/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */
function tree(box, { list, open, rootPath }) {
clear(box)
const ul = h("ul", { class: "tree", role: "tree" })
box.append(ul)
const level = async (parent, path, depth) => {
let entries
try {
entries = await list(path)
} catch (err) {
parent.append(h("li", { class: "tree__note is-error", text: why(err) }))
return
}
if (!entries.length) parent.append(h("li", { class: "tree__note", text: t("Empty folder") }))
for (const e of entries) {
const row = h(
"button",
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": isDir(e) ? "false" : null },
h("span", { class: "tree__chevron" }, isDir(e) ? icon(ICONS.chevron) : null),
icon(isDir(e) ? ICONS.folder : ICONS.file),
h("span", { class: "tree__name", text: e.name }),
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
)
row.style.setProperty("--depth", String(depth))
const li = h("li", { role: "treeitem" }, row)
if (isDir(e)) {
let kids = null
row.addEventListener("click", async () => {
const opening = row.getAttribute("aria-expanded") !== "true"
row.setAttribute("aria-expanded", String(opening))
if (!opening) {
kids?.remove()
kids = null
return
}
kids = h("ul", { role: "group" })
li.append(kids)
await level(kids, e.path, depth + 1)
})
} else row.addEventListener("click", () => open(e))
parent.append(li)
}
}
return level(ul, rootPath, 0)
}
// --- This computer ------------------------------------------------------------------------------
let local = { root: "", roots: [], fixed: false }
/**
* Point this computer's pane at a folder: `fixed` (an agent chat's working directory, which is
* what the pane shows while that chat is open) or one of the agent's folders, chosen in the pane.
*/
export function showLocal({ roots, root, fixed }) {
const changed = root !== local.root || fixed !== local.fixed || roots.join("\n") !== local.roots.join("\n")
local = { roots, root: root || (roots.includes(local.root) ? local.root : roots[0] || ""), fixed: !!fixed }
if (changed) renderLocal()
}
function renderLocal() {
const head = clear($("local-where"))
const box = $("local-tree")
if (!local.roots.length) {
clear(box).append(
h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }),
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
)
return
}
if (local.fixed) {
head.append(h("span", { class: "pane__path", title: local.root }, h("bdi", { text: local.root })))
} else {
const pick = h(
"select",
{ class: "pane__pick", "aria-label": t("Folder"), onchange: (e) => showLocal({ roots: local.roots, root: e.target.value, fixed: false }) },
local.roots.map((r) => h("option", { value: r, text: r, selected: r === local.root })),
)
head.append(pick)
}
const root = local.root
tree(box, {
rootPath: "",
list: async (path) => (await invoke("local_list", { root, path })).entries,
open: async (e) => {
const where = t("This computer")
try {
const f = await invoke("local_read", { root, path: e.path })
openViewer({ name: f.name, path: `${f.root}/${f.path}`, where, text: f.text, withheld: f.withheld, size: f.size })
} catch (err) {
openViewer({ name: e.name, path: e.path, where, error: why(err) })
}
},
})
}
/** Read a file the agent showed in a chat on this computer: `path` from the chat's folder. */
export async function readLocal(root, path) {
return invoke("local_read", { root, path })
}
// --- The server ---------------------------------------------------------------------------------
export async function readServer(path) {
return get(SERVER_FILES.read(path))
}
export function renderServer() {
const box = $("server-tree")
tree(box, {
rootPath: "/",
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
open: (e) => openServerFile(e.path, e.name),
})
}
/** A file in the server's tree, in the viewer: from the explorer and from a chat's outputs. */
export async function openServerFile(path, name) {
const where = t("Server")
try {
const f = await readServer(path)
openViewer({
name: f.name || name,
path: f.path || path,
where,
text: f.text,
// The server's own kind and language: its table and the viewer's are the same one.
kind: ["markdown", "code", "text", "html"].includes(f.kind) ? f.kind : undefined,
language: f.language || undefined,
withheld: f.withheld,
size: f.size,
})
} catch (err) {
openViewer({ name, path, where, error: why(err) })
}
}
let tab = "local"
let serverRead = false
/** Show one of the two trees: `local` (this computer) or `server`. The server's is read once. */
export function showFilesTab(which) {
tab = which === "server" ? "server" : "local"
for (const b of $("files-tabs").querySelectorAll("button")) b.setAttribute("aria-selected", String(b.dataset.tab === tab))
$("pane-local").hidden = tab !== "local"
$("pane-server").hidden = tab !== "server"
if (tab === "server" && !serverRead) {
serverRead = true
renderServer()
}
}
export function initExplorer() {
const tabBtn = (which, ico, label) =>
h("button", { type: "button", role: "tab", class: "tabs__tab", dataset: { tab: which }, "aria-selected": String(which === tab), onclick: () => showFilesTab(which) }, icon(ico), h("span", { text: label }))
$("files-tabs").replaceChildren(tabBtn("local", ICONS.folder, t("This computer")), tabBtn("server", ICONS.file, t("Server")))
$("server-where").textContent = t("Your files on the server, as the chats see them.")
$("server-refresh").replaceChildren(icon(ICONS.refresh))
$("server-refresh").addEventListener("click", renderServer)
$("local-refresh").replaceChildren(icon(ICONS.refresh))
$("local-refresh").addEventListener("click", renderLocal)
}
+132
View File
@@ -0,0 +1,132 @@
// The sidebar's chats: agent chats and chats on the server in one list, newest first, under
// Today and Earlier. An agent chat carries the agent's mark and its folder's name; one that works
// on another device of the account says so in its title. The search line above narrows the list
// by title (and an agent chat by its folder) as it is typed; Ctrl+K searches everything.
import { $, h, clear, append, icon, ICONS } from "./dom.js"
import { get, why } from "./bridge.js"
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat) }
let rows = []
let more = false
let query = ""
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
function fold(text) {
return String(text || "")
.normalize("NFD")
.replace(/\p{M}+/gu, "")
.toLowerCase()
}
/** The list again, from the server. */
export async function refreshList() {
try {
const r = await get("/chats?limit=50")
rows = r.chats || []
more = !!r.more
render()
} catch (err) {
clear($("chat-list")).append(h("li", { class: "list__note is-error", text: why(err) }))
}
}
async function loadMore() {
const last = rows[rows.length - 1]
if (!last) return
const r = await get(`/chats?limit=50&before=${encodeURIComponent(last.id)}`)
rows = rows.concat(r.chats || [])
more = !!r.more
render()
}
export function markCurrent() {
const id = ctx.current()
for (const b of $("chat-list").querySelectorAll(".chatrow")) b.setAttribute("aria-current", String(b.dataset.id === id))
}
/** The folder's last part: what tells one project from another at a glance. */
export function folderName(dir) {
const parts = String(dir || "").replace(/\/+$/, "").split("/")
return parts[parts.length - 1] || dir || ""
}
function row(c) {
const agent = c.kind === "agent"
const elsewhere = agent && c.device && !ctx.thisDevices().includes(c.device.token_id)
const title = c.title || t("New chat")
const b = h(
"button",
{
type: "button",
class: `chatrow${c.unread ? " is-unread" : ""}`,
dataset: { id: c.id, kind: c.kind },
title: agent && c.device?.directory ? `${title}\n${c.device.directory}${elsewhere ? ` · ${t("Another device")}` : ""}` : title,
},
h("span", { class: `chatrow__icon${agent ? " is-agent" : ""}` }, icon(agent ? ICONS.terminal : ICONS.chat)),
h("span", { class: "chatrow__title", text: title }),
agent && c.device?.directory ? h("span", { class: "chatrow__where", text: folderName(c.device.directory) }) : h("span", { class: "chatrow__where" }),
h("span", { class: `chatrow__state${c.running ? " is-busy" : c.unread ? " is-new" : ""}`, "aria-hidden": "true" }),
)
b.addEventListener("click", () => ctx.open(c.id, c.kind))
// The chat's own menu (rename, archive, delete…) on a right click, or the context-menu key.
b.addEventListener("contextmenu", (e) => {
e.preventDefault()
ctx.menu(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : b, c)
})
return h("li", null, b)
}
function isToday(stamp) {
const d = new Date(stamp)
if (Number.isNaN(d.getTime())) return false
const now = new Date()
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
}
function render() {
const list = clear($("chat-list"))
const words = fold(query).split(/\s+/).filter(Boolean)
const visible = rows
.filter((r) => !r.archived && !r.temporary)
.filter((r) => {
if (!words.length) return true
const text = fold(`${r.title || t("New chat")} ${r.device?.directory ? folderName(r.device.directory) : ""}`)
return words.every((w) => text.includes(w))
})
if (!visible.length) {
list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
return
}
const today = visible.filter((r) => isToday(r.updated_at))
const earlier = visible.filter((r) => !isToday(r.updated_at))
if (today.length) append(list, [h("li", { class: "list__head", text: t("Today") }), today.map(row)])
if (earlier.length) append(list, [h("li", { class: "list__head", text: t("Earlier") }), earlier.map(row)])
if (more && !words.length) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
markCurrent()
}
/** The chats as the list has them (the palette's and the sidebar's first look). */
export function listed() {
return rows
}
export function initList(context) {
ctx = context
$("side-search-icon").replaceChildren(icon(ICONS.search))
$("side-search").addEventListener("input", (e) => {
query = e.target.value
render()
})
$("side-search").addEventListener("keydown", (e) => {
if (e.key === "Escape" && e.target.value) {
e.stopPropagation()
e.target.value = ""
query = ""
render()
} else if (e.key === "Enter") {
// The first match opens.
$("chat-list").querySelector(".chatrow")?.click()
}
})
}
+360
View File
@@ -0,0 +1,360 @@
// The app's page: which view (the setup card, or the app), and the app itself -- the sidebar (New
// chat, search, Scheduled, Files, the chats, Settings), and in the main area one of: a chat (a new
// one is the same page, empty), the files, the scheduled tasks, or the settings; the activity
// panel and the viewer beside it. Everything it knows comes from Rust: `status` (the app, the
// agent, the server) and `api` (the server's /api/v2, as the signed-in account).
import { $, h, icon, ICONS } from "./dom.js"
import { invoke, listen, get, why } from "./bridge.js"
import { renderBar, initBar } from "./titlebar.js"
import { renderSetup, initSetup } from "./setup.js"
import { renderSettings, initSettings, scrollToSection } from "./settings.js"
import { initViewer, onViewer, closeViewer } from "./viewer.js"
import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
import { initList, refreshList, markCurrent } from "./list.js"
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer } from "./chat.js"
import { initAttach, allowAttach } from "./attach.js"
import { initPalette, openPalette } from "./palette.js"
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
import { initAgent, link } from "./agent.js"
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
import { closePopover } from "./popup.js"
let status = null
let view = "" // "setup" | "app"
let page = "chat" // "chat" | "files" | "scheduled" | "settings"
let me = null
let models = []
let devices = [] // this account's linked devices
let started = false
let currentChat = null
const SIDE_KEY = "lembas.side"
const remembered = (() => {
try {
return JSON.parse(localStorage.getItem("lembas.thisDevices") || "[]")
} catch {
return []
}
})()
/**
* The device ids that are this computer: a linked device whose host is this computer's name (the
* CLI sends it at login). Remembered, so agent chats here are still known as here while the
* agent is off and the device is not linked right now.
*/
function thisDevices() {
let changed = false
for (const d of devices) {
if (status?.host && d.host === status.host && !remembered.includes(d.id)) {
remembered.push(d.id)
changed = true
}
}
if (changed) {
remembered.splice(0, Math.max(0, remembered.length - 8))
try {
localStorage.setItem("lembas.thisDevices", JSON.stringify(remembered))
} catch {}
}
return remembered.slice()
}
function applyTheme(name) {
if (name !== "moria" && name !== "shire") name = "moria"
const root = document.documentElement
root.dataset.theme = name
root.dataset.base = name
}
// --- Views ------------------------------------------------------------------------------------
function route() {
const ready = status.checked && status.discovery && !status.refused && status.account
const want = ready ? "app" : "setup"
if (want !== view) {
view = want
$("setup").hidden = want !== "setup"
$("shell").hidden = want !== "app"
// The sidebar's button keeps its place in the title bar, unseen while there is no sidebar.
$("side-toggle").style.visibility = want === "app" ? "" : "hidden"
if (want === "app") startApp()
else stopApp()
}
if (view === "setup") renderSetup(status)
if (view === "app") {
if (page === "settings") renderSettings(status)
localPane()
statusChanged()
accountButton()
}
}
async function startApp() {
started = true
try {
me = await get("/me")
applyTheme(me.theme)
invoke("set_theme", { name: me.theme === "shire" ? "shire" : "moria" })
} catch (err) {
$("thread-note").textContent = why(err)
$("thread-note").hidden = false
}
await Promise.all([loadModels(), loadDevices()])
$("new-chat-btn").disabled = me?.permissions && me.permissions["chat.create"] === false
allowAttach(me?.permissions?.["files.upload"] !== false)
accountButton()
refreshList()
if (!openChatId()) startNew()
invoke("remote_refresh")
}
function stopApp() {
started = false
closeChat()
closeViewer()
}
async function loadModels() {
try {
models = (await get("/models")).models || []
} catch {
models = []
}
}
async function loadDevices() {
try {
devices = (await get("/devices")).devices || []
} catch {
devices = []
}
}
/** The sidebar's foot: Settings, with the account it is signed in as. */
function accountButton() {
const who = status?.account?.name || me?.name || status?.account?.email || ""
const initial = (who.trim()[0] || "?").toUpperCase()
$("settings-btn").replaceChildren(
h("span", { class: "navrow__avatar", "aria-hidden": "true", text: initial }),
h("span", { class: "navrow__two" }, h("span", { class: "navrow__label", text: t("Settings") }), who ? h("span", { class: "navrow__sub", text: who }) : null),
h("span", { class: "navrow__icon" }, icon(ICONS.gear)),
)
}
// --- The pages ----------------------------------------------------------------------------------
/** Show one page in the main area: the chat, the files, the scheduled tasks, or the settings. */
function showPage(name) {
page = name
closePopover()
$("shell").dataset.view = name
$("main").hidden = name !== "chat"
$("page-files").hidden = name !== "files"
$("page-scheduled").hidden = name !== "scheduled"
$("settings").hidden = name !== "settings"
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
if (name !== "chat") showPanel(false)
if (narrow()) sideOpen(false)
}
/** This computer's tree: the open agent chat's folder when it runs here, else the one chosen. */
function localPane(chat) {
const roots = status?.remote?.enabled ? status.remote.roots : []
const c = chat || currentChat
const here = c?.device && thisDevices().includes(c.device.token_id)
showLocal({ roots, root: here && roots.includes(c.device.directory) ? c.device.directory : "", fixed: false })
}
/** A new chat in the main view, of the kind chosen last (an agent chat, at first). */
function startNew(kind) {
showPage("chat")
currentChat = null
newChat(kind || draftKind())
markCurrent()
localPane()
}
/** Open a chat, from the list or the palette. */
function showChat(id) {
showPage("chat")
openChat(id).then(markCurrent)
}
function showSettings(on, section) {
if (!on) {
showPage("chat")
return
}
showPage("settings")
renderSettings(status)
invoke("remote_refresh")
if (section) scrollToSection(section)
}
function showFiles(which) {
showPage("files")
if (which) showFilesTab(which)
invoke("remote_refresh")
}
/** The palette's commands: what the menus and buttons do, by name. */
function commands() {
const out = [
{ label: t("New agent chat"), run: () => startNew("agent") },
{ label: t("New chat"), run: () => startNew("chat") },
{ label: t("Scheduled"), run: () => (showPage("scheduled"), showScheduled()) },
...scheduledCommands(),
{ label: t("Files"), run: () => showFiles() },
{ label: t("Settings"), run: () => showSettings(true) },
]
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
out.push(
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: link },
{ label: status?.update ? t("Install LLeMbas %(version)s…", { version: status.update }) : t("Check for updates…"), run: () => invoke("update_app") },
)
return out
}
function narrow() {
return window.matchMedia("(max-width: 48rem)").matches
}
/** The sidebar: shown or folded away (remembered); a drawer over the page on a narrow window. */
function sideOpen(on) {
$("shell").dataset.side = String(on)
$("side-toggle").setAttribute("aria-expanded", String(on))
if (!narrow()) {
try {
localStorage.setItem(SIDE_KEY, on ? "open" : "closed")
} catch {}
}
}
// --- Start --------------------------------------------------------------------------------------
async function boot() {
initAgent()
initBar({ status: () => status, openSettings: (section) => showSettings(true, section) })
initViewer()
initExplorer()
initSetup(() => status)
initSettings(() => status)
initList({
thisDevices,
current: () => openChatId(),
open: (id) => {
if (narrow()) sideOpen(false)
showChat(id)
},
menu: (anchor, c) => chatMenu(anchor, c),
})
initChat({
status: () => status,
thisDevices,
models: () => models,
me: () => me,
changed: () => refreshList(),
opened: (chat) => {
currentChat = chat
localPane(chat)
markCurrent()
},
// Archived or deleted: the list without it, and a new chat where it was open.
gone: (chat) => {
if (!openChatId() || openChatId() === chat.id) {
currentChat = null
newChat()
localPane()
}
refreshList()
},
})
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
initPalette({ commands, open: (c) => showChat(c.id) })
initScheduled({ status: () => status, models: () => models, me: () => me, openChat: (id) => showChat(id), show: () => showPage("scheduled") })
onViewer((on) => {
$("shell").dataset.viewer = String(on)
})
initActivity({
onLayout: (on) => {
$("shell").dataset.activity = String(on)
},
here: onThisComputer,
// The conversation as it now stands: the summary's divider, read again, if it is still open.
compacted: (id) => openChatId() === id && openChat(id),
})
$("new-chat-btn").prepend(icon(ICONS.plus))
$("new-chat-btn").addEventListener("click", () => {
if (narrow()) sideOpen(false)
startNew()
})
$("nav-scheduled").querySelector(".navrow__icon").replaceChildren(icon(ICONS.clock))
$("nav-scheduled").addEventListener("click", () => {
showPage("scheduled")
showScheduled()
})
$("nav-files").querySelector(".navrow__icon").replaceChildren(icon(ICONS.folder))
$("nav-files").addEventListener("click", () => showFiles())
accountButton()
$("settings-btn").addEventListener("click", () => showSettings(page !== "settings"))
$("settings-close").replaceChildren(icon(ICONS.close))
$("settings-close").addEventListener("click", () => showSettings(false))
$("side-toggle").addEventListener("click", () => sideOpen($("shell").dataset.side !== "true"))
$("scrim").addEventListener("click", () => sideOpen(false))
// A wide window starts with the sidebar as it was left; a narrow one with it closed.
let saved = "open"
try {
saved = localStorage.getItem(SIDE_KEY) || "open"
} catch {}
sideOpen(!narrow() && saved !== "closed")
window.matchMedia("(max-width: 48rem)").addEventListener("change", (e) => {
if (e.matches) sideOpen(false)
})
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && narrow() && $("shell").dataset.side === "true") sideOpen(false)
if (view !== "app" || document.querySelector("dialog[open]")) return
const mod = e.ctrlKey || e.metaKey
const key = e.key.toLowerCase()
// Ctrl+K: the palette. Ctrl+N: a new chat. Ctrl+,: the settings. Ctrl+B: the sidebar.
if (mod && !e.shiftKey && !e.altKey && key === "k") {
e.preventDefault()
openPalette()
} else if (mod && !e.shiftKey && !e.altKey && key === "n") {
e.preventDefault()
startNew()
} else if (mod && e.key === ",") {
e.preventDefault()
showSettings(true)
} else if (mod && !e.shiftKey && !e.altKey && key === "b") {
e.preventDefault()
sideOpen($("shell").dataset.side !== "true")
}
})
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished."), scheduled: t("A scheduled task has a result."), scheduled_failed: t("A scheduled task failed."), scheduled_pause: t("A scheduled task failed 3 times in a row. Pause it?") } })
listen("status", (e) => {
const before = status
status = e.payload
renderBar(status)
route()
// The agent came up or went: which devices are linked changes with it.
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(refreshList)
})
listen("open-settings", () => {
if (view === "app") showSettings(true)
})
status = await invoke("get_status")
applyTheme(status.theme)
renderBar(status)
route()
// The list keeps up with replies finished elsewhere (the web interface, the phone).
setInterval(() => {
if (view === "app" && document.visibilityState === "visible") refreshList()
}, 30000)
}
boot()
+347
View File
@@ -0,0 +1,347 @@
// Markdown, as a model writes it, to elements -- never to an HTML string. A reply is model
// output and untrusted: no raw HTML passes (it shows as the text it is), no image is fetched
// (an image is its link), and a link opens in the system's browser only when it is http, https
// or mail. What it does draw: headings, paragraphs, emphasis, strike-through, inline code, fenced
// code, block quotes, lists (nested), tables, rules, links and line breaks. A reply still being
// written renders the same way; an unclosed `*` is simply a star.
import { h } from "./dom.js"
import { openLink } from "./bridge.js"
const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)/
const HEADING = /^ {0,3}(#{1,6})(?:\s+(.*?))?\s*#*\s*$/
const RULE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/
const QUOTE = /^ {0,3}> ?/
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
const TABLE_RULE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/
const SAFE_LINK = /^(https?:|mailto:)/i
/** The rendered fragment of `source`. */
export function markdown(source) {
const out = document.createDocumentFragment()
blocks(String(source ?? "").replace(/\r\n?/g, "\n").split("\n"), out)
return out
}
function indentOf(line) {
const m = /^\s*/.exec(line)
return m ? m[0].replace(/\t/g, " ").length : 0
}
function startsBlock(line, next) {
return (
FENCE.test(line) ||
HEADING.test(line) ||
RULE.test(line) ||
QUOTE.test(line) ||
ITEM.test(line) ||
(line.includes("|") && next !== undefined && TABLE_RULE.test(next) && next.includes("-"))
)
}
function blocks(lines, parent) {
let i = 0
while (i < lines.length) {
const line = lines[i]
if (!line.trim()) {
i += 1
continue
}
const fence = FENCE.exec(line)
if (fence) {
const mark = fence[1]
const body = []
i += 1
while (i < lines.length && !new RegExp(`^ {0,3}${mark[0]}{${mark.length},}\\s*$`).test(lines[i])) {
body.push(lines[i])
i += 1
}
i += 1 // the closing fence (or the end)
parent.append(codeBlock(body.join("\n"), fence[2]))
continue
}
const heading = HEADING.exec(line)
if (heading) {
parent.append(h(`h${heading[1].length}`, null, inline(heading[2] || "")))
i += 1
continue
}
if (RULE.test(line)) {
parent.append(h("hr"))
i += 1
continue
}
if (QUOTE.test(line)) {
const inner = []
while (i < lines.length && lines[i].trim() && (QUOTE.test(lines[i]) || !startsBlock(lines[i], lines[i + 1]))) {
inner.push(lines[i].replace(QUOTE, ""))
i += 1
}
const quote = h("blockquote")
blocks(inner, quote)
parent.append(quote)
continue
}
if (ITEM.test(line)) {
i = list(lines, i, parent)
continue
}
if (line.includes("|") && TABLE_RULE.test(lines[i + 1] || "") && (lines[i + 1] || "").includes("-")) {
i = table(lines, i, parent)
continue
}
const para = [line]
i += 1
while (i < lines.length && lines[i].trim() && !startsBlock(lines[i], lines[i + 1])) {
para.push(lines[i])
i += 1
}
parent.append(h("p", null, inline(para.join("\n"))))
}
}
function codeBlock(text, lang) {
const code = h("code", { text })
const copy = h("button", {
type: "button",
class: "md-copy",
text: t("Copy"),
onclick: () => navigator.clipboard?.writeText(text).catch(() => {}),
})
return h("div", { class: "md-code" }, lang ? h("span", { class: "md-lang", text: lang }) : null, copy, h("pre", null, code))
}
function list(lines, start, parent) {
const first = ITEM.exec(lines[start])
const base = indentOf(first[1])
const ordered = /\d/.test(first[2])
const el = h(ordered ? "ol" : "ul")
if (ordered) {
const n = parseInt(first[2], 10)
if (n !== 1) el.setAttribute("start", String(n))
}
let i = start
while (i < lines.length) {
const m = ITEM.exec(lines[i])
if (!m || indentOf(m[1]) !== base || /\d/.test(m[2]) !== ordered) break
const content = [m[3]]
const width = indentOf(m[1]) + m[2].length + 1
i += 1
while (i < lines.length) {
const l = lines[i]
if (!l.trim()) {
// A blank line inside an item, when the item goes on indented after it.
if (i + 1 < lines.length && indentOf(lines[i + 1]) > base && lines[i + 1].trim()) {
content.push("")
i += 1
continue
}
break
}
const sub = ITEM.exec(l)
if (sub && indentOf(sub[1]) <= base) break
if (indentOf(l) <= base && startsBlock(l, lines[i + 1])) break
content.push(l.slice(Math.min(width, indentOf(l))))
i += 1
}
const li = h("li")
const task = /^\[([ xX])\]\s+/.exec(content[0])
if (task) {
content[0] = content[0].slice(task[0].length)
li.classList.add("md-task")
li.append(h("input", { type: "checkbox", disabled: true, checked: task[1] !== " " }))
}
// A tight item is its text; one with blocks in it is blocks.
if (content.length === 1 || !content.slice(1).some((c) => startsBlock(c) || !c.trim())) {
li.append(...inline(content.join("\n")))
} else blocks(content, li)
el.append(li)
while (i < lines.length && !lines[i].trim()) {
const next = ITEM.exec(lines[i + 1] || "")
if (next && indentOf(next[1]) === base) i += 1
else break
}
}
parent.append(el)
return i
}
function cells(line) {
let s = line.trim()
if (s.startsWith("|")) s = s.slice(1)
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1)
return s.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"))
}
function table(lines, start, parent) {
const head = cells(lines[start])
const aligns = cells(lines[start + 1]).map((c) =>
c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : "",
)
let i = start + 2
const rows = []
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) {
rows.push(cells(lines[i]))
i += 1
}
const cell = (tag, text, n) => {
const c = h(tag, null, inline(text ?? ""))
if (aligns[n]) c.dataset.align = aligns[n]
return c
}
const tbl = h(
"table",
null,
h("thead", null, h("tr", null, head.map((c, n) => cell("th", c, n)))),
h("tbody", null, rows.map((r) => h("tr", null, head.map((_, n) => cell("td", r[n], n))))),
)
// A wide table scrolls by itself; the page does not.
parent.append(h("div", { class: "md-table" }, tbl))
return i
}
function link(label, url) {
const safe = SAFE_LINK.test(url)
const a = h("a", { href: safe ? url : null, title: url, class: safe ? null : "md-unsafe" }, label)
if (safe) {
a.addEventListener("click", (e) => {
e.preventDefault()
openLink(url)
})
}
return a
}
/** The closing `mark` for an opening at `from`, or -1. */
function closing(text, mark, from) {
let at = text.indexOf(mark, from)
while (at !== -1) {
const before = text[at - 1]
if (at > from && before !== " " && before !== "\\") {
if (mark !== "_" || !/\w/.test(text[at + 1] || "")) return at
}
at = text.indexOf(mark, at + 1)
}
return -1
}
/** Inline markdown to nodes. */
export function inline(text) {
const out = []
let plain = ""
const flush = () => {
if (plain) out.push(document.createTextNode(plain))
plain = ""
}
let i = 0
while (i < text.length) {
const c = text[i]
const rest = text.slice(i)
if (c === "\\" && i + 1 < text.length) {
if (text[i + 1] === "\n") {
flush()
out.push(h("br"))
i += 2
continue
}
if (/[\\`*_{}[\]()#+\-.!|~<>]/.test(text[i + 1])) {
plain += text[i + 1]
i += 2
continue
}
}
if (c === "`") {
const run = /^`+/.exec(rest)[0]
const end = text.indexOf(run, i + run.length)
if (end !== -1) {
flush()
out.push(h("code", { text: text.slice(i + run.length, end).replace(/^ (.+) $/, "$1") }))
i = end + run.length
continue
}
plain += run
i += run.length
continue
}
const img = /^!\[([^\]]*)\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
if (img) {
flush()
out.push(link(img[1] || img[2], img[2]))
i += img[0].length
continue
}
if (c === "[") {
const m = /^\[((?:[^[\]\\]|\\.|\[[^\]]*\])*)\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
if (m) {
flush()
out.push(link(inline(m[1]), m[2]))
i += m[0].length
continue
}
}
if (c === "<") {
const m = /^<((?:https?:\/\/|mailto:)[^\s>]+)>/i.exec(rest)
if (m) {
flush()
out.push(link(m[1], m[1]))
i += m[0].length
continue
}
}
if ((c === "h" || c === "H") && !/\w/.test(text[i - 1] || "")) {
const m = /^https?:\/\/[^\s<]+/i.exec(rest)
if (m) {
let url = m[0]
// Trailing punctuation belongs to the sentence, and an unmatched ")" to a parenthesis.
while (/[.,;:!?'"]$/.test(url) || (url.endsWith(")") && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length)) {
url = url.slice(0, -1)
}
flush()
out.push(link(url, url))
i += url.length
continue
}
}
const strong = rest.startsWith("**") ? "**" : rest.startsWith("__") && !/\w/.test(text[i - 1] || "") ? "__" : ""
if (strong && text[i + 2] && text[i + 2] !== " ") {
const end = closing(text, strong, i + 2)
if (end !== -1) {
flush()
out.push(h("strong", null, inline(text.slice(i + 2, end))))
i = end + 2
continue
}
}
if (rest.startsWith("~~") && text[i + 2] && text[i + 2] !== " ") {
const end = closing(text, "~~", i + 2)
if (end !== -1) {
flush()
out.push(h("del", null, inline(text.slice(i + 2, end))))
i = end + 2
continue
}
}
if ((c === "*" || (c === "_" && !/\w/.test(text[i - 1] || ""))) && text[i + 1] && !/\s/.test(text[i + 1]) && text[i + 1] !== c) {
const end = closing(text, c, i + 1)
if (end !== -1) {
flush()
out.push(h("em", null, inline(text.slice(i + 1, end))))
i = end + 1
continue
}
}
if (c === "\n") {
if (plain.endsWith(" ")) {
plain = plain.replace(/ +$/, "")
flush()
out.push(h("br"))
} else plain += " "
i += 1
continue
}
plain += c
i += 1
}
flush()
return out
}
+278
View File
@@ -0,0 +1,278 @@
// Models as the person picks them: each with its avatar. A model with a picture (`image` in
// /api/v2/models) shows it, read through the bridge with the app's token and handed to the page
// as a `data:` URL; until it arrives, and for a model without one, the avatar is what the
// server's web interface draws then -- its initial on a colour of its own, the hue worked out
// from the model's id exactly as the server does it (`stable_hue`: the first two bytes of the
// id's MD5, modulo 360), so a model looks the same in the app and in the browser. Saturation and
// lightness are the theme's (app.css), only the hue is the model's.
import { h, icon, ICONS } from "./dom.js"
import { invoke } from "./bridge.js"
import { menu } from "./popup.js"
// Each picture once per run: its path -> a promise of its `data:` URL (null when it cannot be had,
// so a missing picture is not asked for again with every redraw).
const pictures = new Map()
export function picture(path) {
if (!pictures.has(path)) pictures.set(path, invoke("model_image", { path }).catch(() => null))
return pictures.get(path)
}
/** MD5 of a string's UTF-8 bytes (RFC 1321), as 16 bytes. For a colour, not for security. */
function md5(text) {
const bytes = new TextEncoder().encode(text)
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21]
const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0)
const len = bytes.length
const words = (((len + 8) >>> 6) + 1) * 16
const w = new Uint32Array(words)
for (let i = 0; i < len; i++) w[i >> 2] |= bytes[i] << ((i % 4) * 8)
w[len >> 2] |= 0x80 << ((len % 4) * 8)
w[words - 2] = (len * 8) >>> 0
w[words - 1] = Math.floor((len * 8) / 2 ** 32)
let a0 = 0x67452301
let b0 = 0xefcdab89
let c0 = 0x98badcfe
let d0 = 0x10325476
for (let off = 0; off < words; off += 16) {
let [A, B, C, D] = [a0, b0, c0, d0]
for (let i = 0; i < 64; i++) {
let F
let g
if (i < 16) [F, g] = [(B & C) | (~B & D), i]
else if (i < 32) [F, g] = [(D & B) | (~D & C), (5 * i + 1) % 16]
else if (i < 48) [F, g] = [B ^ C ^ D, (3 * i + 5) % 16]
else [F, g] = [C ^ (B | ~D), (7 * i) % 16]
F = (F + A + K[i] + w[off + g]) >>> 0
const s = S[(i >> 4) * 4 + (i % 4)]
A = D
D = C
C = B
B = (B + ((F << s) | (F >>> (32 - s)))) >>> 0
}
a0 = (a0 + A) >>> 0
b0 = (b0 + B) >>> 0
c0 = (c0 + C) >>> 0
d0 = (d0 + D) >>> 0
}
return [a0, b0, c0, d0].flatMap((v) => [v & 255, (v >>> 8) & 255, (v >>> 16) & 255, (v >>> 24) & 255])
}
/** The server's `stable_hue`: 0-359, the same for the same id everywhere. */
export function stableHue(id) {
const d = md5(String(id))
return ((d[0] << 8) | d[1]) % 360
}
/** A model's avatar: its picture, or its initial on its own colour while there is none. */
export function avatar(model) {
const label = String(model?.name || model?.id || "?").trim() || "?"
const el = h("span", { class: "avatar", "aria-hidden": "true", text: label[0].toUpperCase() })
el.style.setProperty("--avatar-hue", String(stableHue(model?.id || label)))
if (typeof model?.image === "string" && model.image) {
picture(model.image).then((url) => {
if (!url || !url.startsWith("data:image/")) return
el.classList.add("avatar--image")
el.replaceChildren(h("img", { src: url, alt: "", draggable: "false" }))
})
}
return el
}
/**
* A model picker: a button showing the chosen model (avatar and name) that opens the list, each
* with its avatar. `onChange(id)` when another is chosen. The element's `value` is the id.
*/
export function modelPicker({ id, models, value, onChange, label }) {
let current = value
const btn = h("button", { type: "button", class: "picker", id, "aria-haspopup": "menu", "aria-label": label })
const draw = () => {
const m = models.find((x) => x.id === current)
btn.replaceChildren(
m ? avatar(m) : h("span", { class: "avatar avatar--none", "aria-hidden": "true" }),
h("span", { class: "picker__name", text: m ? m.name : current || "—" }),
icon(ICONS.chevronDown),
)
btn.title = m ? `${m.name} (${m.id})` : current || ""
}
btn.addEventListener("click", () => {
menu(
btn,
models.map((m) => ({
label: m.name,
lead: avatar(m),
checked: m.id === current,
run: () => {
if (m.id === current) return
current = m.id
draw()
onChange?.(m.id)
},
})),
)
})
Object.defineProperty(btn, "value", {
get: () => current,
set: (v) => {
current = v
draw()
},
})
draw()
return btn
}
// --- How hard a model thinks, and how many subagents a chat may have --------------------------------
//
// The levels are the harness spec's (3.2.0, `models/schema.json`), as /api/v2/models gives them:
// `levels` in offering order (`off` first where the model can switch thinking off, then its
// efforts, or the one level `default`), `effort` the start level and `control` how a chat draws
// them -- `none` (one level: nothing to choose), `switch` (off and one effort: thinking on or
// off) or `slider` (more: a choice of each). The chat says the level in force as its `effort`.
/** A model's levels: { levels, start, control }, read the server's way when a field is missing. */
export function levelsOf(model) {
const given = Array.isArray(model?.levels) && model.levels.length ? model.levels : null
const levels = given || (model?.efforts?.length ? model.efforts : ["default"])
const control =
["none", "switch", "slider"].includes(model?.control) ? model.control
: levels.length < 2 ? "none"
: levels.length === 2 && levels.includes("off") ? "switch"
: "slider"
const start = levels.includes(model?.effort) ? model.effort : levels[0]
return { levels, start, control }
}
/** A level as the person reads it: `off` and `default` in their language, an effort as it is. */
export function levelWord(level) {
return level === "off" ? t("off") : level === "default" ? t("default") : String(level)
}
/**
* The control for a model's levels, or null where it has one level: the Thinking switch (a real
* checkbox with the switch role, the label its tap target) or the selector. `value` is the level
* in force; `onChange(level)` when the person changes it. The element's `value` is the level.
*/
export function effortControl({ model, value, onChange, id, compact }) {
const { levels, start, control } = levelsOf(model)
if (control === "none") return null
let current = levels.includes(value) ? value : start
if (control === "switch") {
const on = levels.find((l) => l !== "off") || start
const box = h("input", { type: "checkbox", role: "switch", id, checked: current !== "off" })
const el = h(
"label",
{ class: `toggle${compact ? " toggle--compact" : ""}`, title: t("Think before answering") },
box,
h("span", { class: "toggle__label", text: t("Think before answering") }),
)
box.addEventListener("change", () => {
current = box.checked ? on : "off"
onChange?.(current)
})
Object.defineProperty(el, "value", {
get: () => current,
set: (v) => {
current = v
box.checked = v !== "off"
},
})
return el
}
const el = h(
"select",
{ id, class: compact ? "head__mode" : null, "aria-label": t("Reasoning effort"), title: t("Reasoning effort") },
levels.map((l) => h("option", { value: l, text: levelWord(l), selected: l === current })),
)
el.addEventListener("change", () => {
current = el.value
onChange?.(current)
})
return el
}
/**
* Whether a new chat on `model` is offered subagents. The server says so where it can: a model's
* `subagents` in `/models` (a flag, or `{offered}`), true when subagents are on for the model and
* the instance and at least one model could serve as one -- the model itself with room for two
* requests at once, or a usable model outside its one-model-at-a-time server. A server that does
* not say it yet: a model that calls tools and serves two requests at once (`parallel` null is a
* hosted API with no stated limit). The server decides again; this only keeps the chip away where
* it could never apply. An open chat asks the server (`GET /chats/{chat}/team`).
*/
export function teamPossible(model) {
const said = model?.subagents
if (said !== undefined && said !== null) return typeof said === "object" ? !!said.offered : !!said
return !!model?.tools && (model.parallel === null || model.parallel === undefined || model.parallel >= 2)
}
/** The most subagents the instance allows a chat (`cap`), where the server says it; else 32. */
export const SUBAGENTS_CAP = 32
export const SUBAGENTS_HINT = () => t("The fewest and the most subagents this chat runs. They work on the same model unless you name another one in the chat.")
export function subagentsCap(...sources) {
for (const s of sources) {
const n = Number(s?.cap ?? s?.subagents_cap ?? s?.subagents?.cap)
if (Number.isFinite(n) && n >= 1) return n
}
return SUBAGENTS_CAP
}
/**
* The subagents a chat may run: the fewest and the most, as two number fields on one shared
* track. `value` is { min, max } (empty fields for a new chat: the server's defaults then stand);
* `cap` the instance's most; `onChange({ min, max })` once a field is changed, with the empty one
* left out. The server checks the pair and says the bounds when it refuses one.
*/
export function agentsControl({ value, onChange, id, compact, cap = SUBAGENTS_CAP }) {
const field = (which, v, label) =>
h("input", {
type: "number",
class: "agents__input",
id: which === "min" ? id : null,
min: "1",
max: String(cap),
step: "1",
inputmode: "numeric",
value: v === undefined || v === null ? "" : String(v),
placeholder: compact ? null : t("default"),
"aria-label": label,
title: label,
})
const low = field("min", value?.min, t("Fewest"))
const high = field("max", value?.max, t("Most"))
const bounds = () => {
high.min = low.value || "1"
low.max = high.value || String(cap)
}
bounds()
const read = () => {
const out = {}
if (low.value) out.min = Number(low.value)
if (high.value) out.max = Number(high.value)
return out
}
for (const input of [low, high]) {
input.addEventListener("change", () => {
bounds()
onChange?.(read())
})
}
const el = h(
"span",
{ class: `agents${compact ? " agents--compact" : ""}`, role: "group", "aria-label": t("Subagents"), title: SUBAGENTS_HINT() },
compact ? h("span", { class: "agents__label", text: t("Subagents") }) : null,
low,
h("span", { class: "agents__dash", "aria-hidden": "true", text: "–" }),
high,
)
Object.defineProperty(el, "value", {
get: read,
set: (v) => {
low.value = v?.min ?? ""
high.value = v?.max ?? ""
bounds()
},
})
return el
}
+124
View File
@@ -0,0 +1,124 @@
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands,
// and the chats of both kinds by title, newest first. Up and Down move, Enter does it, Escape
// closes. The chats are asked of the server each time it opens, so a chat started elsewhere
// is there.
import { h, clear, icon, ICONS, when } from "./dom.js"
import { get } from "./bridge.js"
let ctx = null // { commands(): [{ label, hint?, run }], open(id) }
let dlg = null
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
function fold(text) {
return String(text || "")
.normalize("NFD")
.replace(/\p{M}+/gu, "")
.toLowerCase()
}
function matches(text, words) {
const f = fold(text)
return words.every((w) => f.includes(w))
}
export async function openPalette() {
if (dlg) return
const input = h("input", { type: "text", class: "palette__input", placeholder: t("Type a command or a chat's title"), "aria-label": t("Search"), role: "combobox", "aria-expanded": "true", "aria-controls": "palette-list", autocomplete: "off" })
const list = h("ul", { class: "palette__list", id: "palette-list", role: "listbox" })
dlg = h("dialog", { class: "dialog palette", "aria-label": t("Commands") }, h("div", { class: "palette__line" }, icon(ICONS.search), input), list)
document.body.append(dlg)
let chats = []
let shown = []
let at = 0
const close = () => {
if (!dlg) return
dlg.close()
dlg.remove()
dlg = null
}
const choose = (item) => {
close()
item?.run()
}
const draw = () => {
const words = fold(input.value).split(/\s+/).filter(Boolean)
const commands = ctx.commands().filter((c) => matches(c.label, words))
const found = chats.filter((c) => matches(c.title || t("New chat"), words)).slice(0, 30)
shown = [
...commands.map((c) => ({ kind: "command", label: c.label, hint: c.hint || "", run: c.run })),
...found.map((c) => ({
kind: c.kind,
label: c.title || t("New chat"),
hint: `${c.kind === "agent" ? t("Agent") : t("Chats")} · ${when(c.updated_at)}`,
run: () => ctx.open(c),
})),
]
at = Math.min(at, Math.max(0, shown.length - 1))
clear(list)
if (!shown.length) list.append(h("li", { class: "palette__none", text: t("Nothing matches.") }))
shown.forEach((item, i) => {
list.append(
h(
"li",
{
id: `palette-${i}`,
role: "option",
class: "palette__item",
"aria-selected": String(i === at),
onclick: () => choose(item),
onpointermove: () => {
if (at !== i) {
at = i
mark()
}
},
},
h("span", { class: "palette__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "agent" ? ICONS.folder : ICONS.file)),
h("span", { class: "palette__label", text: item.label }),
h("span", { class: "palette__hint", text: item.hint }),
),
)
})
mark()
}
const mark = () => {
list.querySelectorAll(".palette__item").forEach((el, i) => el.setAttribute("aria-selected", String(i === at)))
input.setAttribute("aria-activedescendant", shown.length ? `palette-${at}` : "")
list.querySelector(`#palette-${at}`)?.scrollIntoView({ block: "nearest" })
}
input.addEventListener("input", () => {
at = 0
draw()
})
input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault()
if (!shown.length) return
at = (at + (e.key === "ArrowDown" ? 1 : shown.length - 1)) % shown.length
mark()
} else if (e.key === "Enter") {
e.preventDefault()
choose(shown[at])
}
})
dlg.addEventListener("cancel", (e) => {
e.preventDefault()
close()
})
dlg.addEventListener("click", (e) => {
if (e.target === dlg) close()
})
dlg.showModal()
input.focus()
draw()
try {
const r = await get("/chats?limit=100")
chats = (r.chats || []).filter((c) => !c.archived && !c.temporary)
if (dlg) draw()
} catch {}
}
export function initPalette(context) {
ctx = context
}
+238
View File
@@ -0,0 +1,238 @@
// Things that open over the page: a menu under a button (or at the pointer), and a small modal
// dialog that asks for a line of text or a yes. Built from elements, text as text; Escape and a
// click outside close them; the focus goes back where it came from.
import { h } from "./dom.js"
let openMenu = null
/** Close the menu that is open, if any. */
export function closeMenu() {
if (!openMenu) return
const { el, back } = openMenu
openMenu = null
el.remove()
document.removeEventListener("pointerdown", outside, true)
back?.focus?.()
}
function outside(e) {
if (openMenu && !openMenu.el.contains(e.target)) closeMenu()
}
/**
* A menu of `items` ({ label, hint?, run, lead?, checked?, danger?, disabled? }, or null for a separator), under
* `anchor` (an element) or at `{ x, y }` (page pixels). Arrow keys move, Enter chooses.
*/
export function menu(anchor, items) {
closeMenu()
closePopover()
const plain = !items.some((i) => i?.lead)
const el = h("div", { class: `menu${plain ? " menu--plain" : ""}`, role: "menu" })
for (const item of items) {
if (!item) {
el.append(h("div", { class: "menu__sep", role: "separator" }))
continue
}
// Every item the same three tracks -- a lead (an avatar), the label, a check -- so the
// labels line up whether or not an item has a lead.
el.append(
h(
"button",
{
type: "button",
role: item.checked === undefined ? "menuitem" : "menuitemradio",
"aria-checked": item.checked === undefined ? null : String(!!item.checked),
class: `menu__item${item.danger ? " is-danger" : ""}`,
disabled: !!item.disabled,
onclick: () => {
closeMenu()
item.run()
},
},
h("span", { class: "menu__lead" }, item.lead || null),
item.hint
? h("span", { class: "menu__label menu__label--two" }, h("span", { text: item.label }), h("span", { class: "menu__hint", text: item.hint }))
: h("span", { class: "menu__label", text: item.label }),
h("span", { class: "menu__check", "aria-hidden": "true", text: item.checked ? "✓" : "" }),
),
)
}
el.addEventListener("keydown", (e) => {
const all = [...el.querySelectorAll(".menu__item:not(:disabled)")]
const i = all.indexOf(document.activeElement)
if (e.key === "Escape") {
e.preventDefault()
closeMenu()
} else if (e.key === "ArrowDown") {
e.preventDefault()
all[(i + 1) % all.length]?.focus()
} else if (e.key === "ArrowUp") {
e.preventDefault()
all[(i - 1 + all.length) % all.length]?.focus()
}
})
document.body.append(el)
// Placed inside the window: under the anchor's bottom-left, moved in where it would overflow.
const at = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, bottom: anchor.y }
const r = el.getBoundingClientRect()
// Its width as measured at the left edge, kept: moved right, it would shrink to fit instead.
el.style.width = `${Math.ceil(r.width)}px`
el.style.left = `${Math.max(0, Math.min(at.left, window.innerWidth - r.width))}px`
// Below the anchor where it fits, else above it (a chip under the message box opens upwards).
const above = at.top !== undefined && at.bottom + r.height > window.innerHeight && at.top - r.height >= 0
el.style.top = above ? `${at.top - r.height}px` : `${Math.max(0, Math.min(at.bottom, window.innerHeight - r.height))}px`
openMenu = { el, back: anchor instanceof Element ? anchor : document.activeElement }
document.addEventListener("pointerdown", outside, true)
el.querySelector(".menu__item:not(:disabled)")?.focus()
}
/**
* A modal dialog: `title`, an optional `text`, an optional line to edit (`value` given), and two
* buttons. Resolves with the line (or true) on OK, null on cancel.
*/
export function ask({ title, text, value, ok, cancel, danger }) {
return new Promise((resolve) => {
const input = value === undefined ? null : h("input", { type: "text", class: "dialog__input", value, "aria-label": title })
const okBtn = h("button", { type: "submit", class: `btn ${danger ? "btn--danger" : "btn--primary"}`, text: ok })
const form = h(
"form",
{ class: "dialog__body", method: "dialog" },
h("h2", { class: "dialog__title", text: title }),
text ? h("p", { class: "muted", text }) : null,
input,
h("div", { class: "dialog__actions" }, h("button", { type: "button", class: "btn", text: cancel, onclick: () => done(null) }), okBtn),
)
const dlg = h("dialog", { class: "dialog" }, form)
let settled = false
const done = (v) => {
if (settled) return
settled = true
dlg.close()
dlg.remove()
resolve(v)
}
form.addEventListener("submit", (e) => {
e.preventDefault()
if (input && !input.value.trim()) return input.focus()
done(input ? input.value.trim() : true)
})
dlg.addEventListener("cancel", (e) => {
e.preventDefault()
done(null)
})
document.body.append(dlg)
dlg.showModal()
if (input) {
input.focus()
input.select()
} else okBtn.focus()
})
}
/**
* A modal dialog that shows a line to copy: `title`, `text`, the line itself (`code`, shown as
* it is and selectable), and Copy beside Close. Resolves when it is closed.
*/
export function showLine({ title, text, code, copy, copied, close }) {
return new Promise((resolve) => {
const line = h("pre", { class: "dialog__code", tabindex: "0", text: code })
const copyBtn = h("button", { type: "button", class: "btn btn--primary", text: copy })
const closeBtn = h("button", { type: "button", class: "btn", text: close })
const dlg = h(
"dialog",
{ class: "dialog" },
h("div", { class: "dialog__body" }, h("h2", { class: "dialog__title", text: title }), text ? h("p", { class: "muted", text }) : null, line, h("div", { class: "dialog__actions" }, closeBtn, copyBtn)),
)
const done = () => {
dlg.close()
dlg.remove()
resolve()
}
copyBtn.addEventListener("click", () => {
navigator.clipboard
?.writeText(code)
.then(() => (copyBtn.textContent = copied))
.catch(() => {
// No clipboard: the line is selected, for Ctrl+C.
const range = document.createRange()
range.selectNodeContents(line)
getSelection()?.removeAllRanges()
getSelection()?.addRange(range)
})
})
closeBtn.addEventListener("click", done)
dlg.addEventListener("cancel", (e) => {
e.preventDefault()
done()
})
document.body.append(dlg)
dlg.showModal()
copyBtn.focus()
})
}
let openPop = null
/** Close the popover that is open, if any. */
export function closePopover() {
if (!openPop) return
const { el, back, onClose } = openPop
openPop = null
el.remove()
document.removeEventListener("pointerdown", outsidePop, true)
document.removeEventListener("keydown", escPop, true)
onClose?.()
back?.focus?.()
}
function outsidePop(e) {
if (openPop && !openPop.el.contains(e.target) && !openPop.back?.contains?.(e.target)) closePopover()
}
function escPop(e) {
if (e.key === "Escape" && openPop) {
e.preventDefault()
e.stopPropagation()
closePopover()
}
}
/**
* A small panel under `anchor` (or above it, `up`), holding `content` (an element, or a function
* that draws it and is called again by `redraw`). Escape and a click outside close it. Returns
* { redraw, close }. `align`: "start" (the anchor's left edge) or "end" (its right edge).
*/
export function popover(anchor, content, { up = false, align = "start", label = "", onClose } = {}) {
closePopover()
closeMenu()
const el = h("div", { class: "pop", role: "dialog", "aria-label": label || null })
const draw = () => el.replaceChildren(typeof content === "function" ? content() : content)
draw()
document.body.append(el)
const place = () => {
const at = anchor.getBoundingClientRect()
const r = el.getBoundingClientRect()
const left = align === "end" ? at.right - r.width : at.left
el.style.left = `${Math.max(8, Math.min(left, window.innerWidth - r.width - 8))}px`
const top = up ? at.top - r.height - 6 : at.bottom + 6
el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px`
}
place()
openPop = { el, back: anchor, onClose }
document.addEventListener("pointerdown", outsidePop, true)
document.addEventListener("keydown", escPop, true)
el.querySelector("button, input, select")?.focus()
return {
redraw() {
if (openPop?.el !== el) return
draw()
place()
},
close: closePopover,
}
}
export function popoverOpen() {
return !!openPop
}
+742
View File
@@ -0,0 +1,742 @@
// Scheduled tasks: standing instructions the server runs on its own -- "every weekday at 8:00,
// summarise …". The list (each task's next run, how its last run went, a switch to pause it), a
// task's page (what it does, when, its settings, the next runs, its history with each run's
// result, Run now, edit, pause, delete), and a task said in words: the server reads them into a
// rule and an instruction (compile), the next runs are shown, the settings chosen, and only then
// is it saved. The server runs them; this page only reads and asks.
//
// The server's /api/v2 for scheduling (LLeMbas `api/v2/schedules.py`; the shapes are its contract
// fixtures `schedules.json`, `schedule*.json`, `event_schedules.json`):
//
// GET /schedules { schedules: [schedule] }, newest first
// POST /schedules { title, instruction, request, rule, model, notify,
// mode, effort, agents } -> 201 { schedule }
// GET /schedules/{id} { schedule }
// PATCH /schedules/{id} any of the create fields -> { schedule }
// DELETE /schedules/{id}?keep_chat=true 204
// POST /schedules/{id}/pause | /resume { schedule }
// POST /schedules/{id}/run 202 { run }: now, not counted against the rule
// GET /schedules/{id}/runs?before=&limit= { runs: [run], more }, newest first
// GET /schedules/{id}/runs/{run} { run, text }: the result, Markdown (untrusted)
// POST /schedules/compile { text } -> { ok, reason, title, instruction,
// target, rule, description, next_runs, timezone }
// POST /schedules/preview { rule } -> { rule, description, next_runs, timezone }
// GET /schedules/models { models: [model + free, busy, chosen], instance, mine }
// PUT /schedules/models { models: [id] }: the person's narrowing
// GET /events `schedules` frames, { version, schedules }: followed
// by the app (Rust, watch.rs), handed here as the
// `schedules` event
//
// A server without them answers 404: the page says this server has no scheduled tasks yet.
// Everything a task says (its title, instruction, a run's result or error) is the person's or a
// model's words, set as text; a result's Markdown goes through markdown.js.
import { $, h, clear, icon, ICONS, when } from "./dom.js"
import { api, get, post, patch, del, why, listen } from "./bridge.js"
import { ask, menu, popover } from "./popup.js"
import { avatar, levelsOf, levelWord } from "./models.js"
import { markdown } from "./markdown.js"
let ctx = null // { status(), models(), me(), openChat(id), show() }
let view = { name: "list" } // { name: "list" } | { name: "task", id } | { name: "form", task? }
let tasks = []
let missing = false // the server has no scheduling routes
let shown = null // the task whose page is open: { id, last, running }
const STATUS = {
ok: () => t("Done"),
silent: () => t("Nothing to report"),
failed: () => t("Failed"),
missed: () => t("Missed"),
skipped: () => t("Skipped"),
waiting: () => t("Waiting for a free model"),
running: () => t("Running"),
}
const NOTIFY = {
matters: () => t("When it matters"),
always: () => t("Always"),
never: () => t("Never"),
}
const NOTIFY_HINT = {
matters: () => t("A result, but not a run with nothing to report."),
always: () => t("Every result, even with nothing to report."),
never: () => t("Only in its history."),
}
const MODE = {
fresh: () => t("A new chat for each run"),
continue: () => t("One chat that grows"),
}
const TIMING = {
late: () => t("late"),
catch_up: () => t("caught up"),
manual: () => t("run by hand"),
}
const MAX_AGENTS = 8 // services/schedules.py MAX_AGENTS
const enc = encodeURIComponent
const RUNS_PAGE = 20
function statusChip(run, running) {
if (running) return h("span", { class: "schip is-running", text: STATUS.running() })
if (!run) return h("span", { class: "schip", text: t("Not run yet") })
const word = STATUS[run.status]?.() || run.status
// A missed run counts the slots that passed unrun; any other run says how many it followed.
const n = run.status === "missed" ? (run.missed > 1 ? ` ×${run.missed}` : "") : run.missed > 0 ? ` · ${t("%(n)s missed", { n: run.missed })}` : ""
return h("span", { class: `schip is-${run.status}`, title: `${word}${n}`, text: `${word}${n}` })
}
/** A stamp as a date and time in the person's language: "Mon 10 Nov, 08:00". */
function stamp(value) {
const d = new Date(value)
if (!value || Number.isNaN(d.getTime())) return ""
return d.toLocaleString(document.documentElement.lang || undefined, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })
}
function nextWords(task) {
if (!task.enabled) return t("Paused")
if (!task.next_run) return t("No more runs")
return `${when(task.next_run)} · ${stamp(task.next_run)}`
}
function onPage() {
return !$("page-scheduled").hidden
}
// --- Reading ------------------------------------------------------------------------------------
async function load() {
try {
tasks = (await get("/schedules")).schedules || []
missing = false
} catch (err) {
tasks = []
missing = err?.status === 404 || err?.status === 405
if (!missing) throw err
}
}
/** The scheduling models, in the order they are tried; null when the server has none to say. */
async function schedulingModels() {
try {
return await get("/schedules/models")
} catch {
return null
}
}
/** Show the page: the list, or one task's page (`id`). */
export async function showScheduled(id) {
view = id ? { name: "task", id } : { name: "list" }
await draw()
}
async function draw() {
const body = clear($("scheduled-body"))
clear($("scheduled-tools"))
clear($("scheduled-place"))
shown = null
body.append(h("p", { class: "muted page__loading", text: t("Loading…") }))
try {
if (view.name === "task") await drawTask(view.id)
else if (view.name === "form") await drawForm(view.task)
else {
await load()
drawList()
}
} catch (err) {
clear(body).append(h("p", { class: "status is-error", text: why(err) }))
}
}
/** The page's title; with a way back, "Scheduled ›" before it. */
function header(title, back) {
const h1 = $("scheduled-title")
if (back) h1.replaceChildren(h("button", { type: "button", class: "crumb", onclick: back, text: t("Scheduled") }), h("span", { class: "crumb__sep", "aria-hidden": "true", text: "›" }), h("span", { text: title }))
else h1.textContent = title
}
function go(next) {
view = next
draw()
}
// --- The list -------------------------------------------------------------------------------------
function drawList() {
header(t("Scheduled"))
const tools = clear($("scheduled-tools"))
const body = clear($("scheduled-body"))
if (missing) {
body.append(empty(t("This server has no scheduled tasks yet."), t("Scheduled tasks need a newer LLeMbas server. Ask its administrator to update it.")))
return
}
tools.append(
h("button", { type: "button", class: "btn btn--sm", id: "sched-models", "aria-haspopup": "dialog", onclick: (e) => modelsPanel(e.currentTarget) }, h("span", { text: t("Models") })),
h("button", { type: "button", class: "btn btn--sm btn--primary", id: "sched-new", onclick: () => go({ name: "form" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") })),
)
if (!tasks.length) {
body.append(
empty(t("Nothing is scheduled."), t("Say what should happen and when — “every weekday at 8:00, summarise the news on my reading list” — and the server runs it for you.")),
h("div", { class: "empty__actions" }, h("button", { type: "button", class: "btn btn--primary", onclick: () => go({ name: "form" }) }, icon(ICONS.plus), h("span", { text: t("New scheduled task") }))),
)
return
}
const list = h("ul", { class: "tasks-list", role: "list" })
for (const task of tasks) list.append(taskRow(task))
body.append(list)
}
function empty(title, text) {
return h("div", { class: "empty" }, h("span", { class: "empty__icon" }, icon(ICONS.clock)), h("h2", { class: "empty__title", text: title }), h("p", { class: "empty__text", text }))
}
/** One task: icon · title and its rule in words · next run · last status · pause switch. */
function taskRow(task) {
const toggle = h("input", {
type: "checkbox",
role: "switch",
checked: !!task.enabled,
"aria-label": task.enabled ? t("Pause %(title)s", { title: task.title }) : t("Resume %(title)s", { title: task.title }),
title: task.enabled ? t("Pause") : t("Resume"),
onclick: (e) => e.stopPropagation(),
onchange: (e) => setEnabled(task, e.target.checked),
})
return h(
"li",
{ class: `task${task.enabled ? "" : " is-paused"}`, dataset: { id: task.id } },
h(
"button",
{ type: "button", class: "task__open", onclick: () => go({ name: "task", id: task.id }) },
h("span", { class: "task__icon" }, icon(ICONS.clock)),
h("span", { class: "task__what" }, h("span", { class: "task__title", text: task.title || task.request || "" }), h("span", { class: "task__rule", text: task.description || "" })),
h(
"span",
{ class: "task__next", title: task.enabled && task.next_run ? stamp(task.next_run) : "" },
h("span", { class: "task__label", text: t("Next run") }),
h("span", { class: "task__value", text: task.enabled && task.next_run ? when(task.next_run) : nextWords(task) }),
),
h("span", { class: "task__last" }, statusChip(task.last_run, task.running)),
),
h("label", { class: "toggle task__toggle" }, toggle),
)
}
/** Pause or resume: the server's own verbs, which answer the schedule as it now is. */
async function setEnabled(task, on) {
try {
const r = await post(`/schedules/${enc(task.id)}/${on ? "resume" : "pause"}`)
Object.assign(task, r.schedule || { enabled: on })
} catch (err) {
await ask({ title: t("Scheduled"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
}
if (view.name === "list") drawList()
else draw()
}
/**
* The scheduling models: the ones scheduled work may run on, in the order they are tried, each
* free now or busy (and why). A task without a model of its own runs on the first free one of
* those ticked; ticking none means all of them.
*/
async function modelsPanel(anchor) {
const data = await schedulingModels()
if (!data) return
let mine = new Set(data.mine || [])
const said = h("p", { class: "status is-error", hidden: true })
const save = async () => {
try {
const r = await api("PUT", "/schedules/models", { models: data.models.filter((m) => mine.has(m.id)).map((m) => m.id) })
Object.assign(data, r)
mine = new Set(r.mine || [])
said.hidden = true
} catch (err) {
said.textContent = why(err)
said.hidden = false
}
pop.redraw()
}
const content = () =>
h(
"div",
{ class: "pop__body smodels" },
h("p", { class: "pop__title", text: t("Models for scheduled tasks") }),
h("p", { class: "smodels__hint", text: t("A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.") }),
h(
"ul",
{ class: "smodels__list", role: "list" },
data.models.map((m) =>
h(
"li",
null,
h(
"label",
{ class: "smodel" },
h("input", {
type: "checkbox",
checked: mine.has(m.id),
onchange: (e) => {
if (e.target.checked) mine.add(m.id)
else mine.delete(m.id)
save()
},
}),
avatar(m),
h("span", { class: "smodel__name", text: m.name }),
h("span", { class: `smodel__state${m.free ? " is-free" : ""}`, title: m.busy || "", text: m.free ? t("free") : t("busy") }),
),
),
),
),
data.models.length ? null : h("p", { class: "muted", text: t("No model is available to this account.") }),
said,
)
const pop = popover(anchor, content, { align: "end", label: t("Models for scheduled tasks") })
}
// --- One task -----------------------------------------------------------------------------------
async function drawTask(id) {
const [r, page] = await Promise.all([get(`/schedules/${enc(id)}`), get(`/schedules/${enc(id)}/runs?limit=${RUNS_PAGE}`)])
const task = r.schedule
shown = { id: task.id, last: task.last_run?.id || null, status: task.last_run?.status || null, running: task.running }
header(task.title || t("Scheduled"), () => go({ name: "list" }))
const tools = clear($("scheduled-tools"))
tools.append(
h("button", { type: "button", class: "btn btn--sm btn--primary", id: "sched-run", disabled: !!task.running, onclick: (e) => runNow(task, e.currentTarget) }, icon(ICONS.play), h("span", { text: t("Run now") })),
h("button", { type: "button", class: "btn btn--sm", id: "sched-edit", onclick: () => go({ name: "form", task }) }, icon(ICONS.edit), h("span", { text: t("Edit") })),
h(
"button",
{
type: "button",
class: "tool",
"aria-label": t("More"),
title: t("More"),
onclick: (e) =>
menu(e.currentTarget, [
{ label: task.enabled ? t("Pause") : t("Resume"), run: () => setEnabled(task, !task.enabled) },
...(task.chat_id ? [{ label: t("Open its chat"), run: () => ctx.openChat(task.chat_id) }] : []),
null,
{ label: t("Delete"), danger: true, run: () => remove(task) },
]),
},
icon(ICONS.more),
),
)
const models = (await schedulingModels())?.models || ctx.models()
const body = clear($("scheduled-body"))
body.append(
h(
"section",
{ class: "sched" },
task.failure_streak > 0 && task.last_error ? h("p", { class: "status is-error sched__trouble", text: t("The last %(n)s runs failed: %(why)s", { n: task.failure_streak, why: task.last_error }) }) : null,
h(
"div",
{ class: "sched__facts" },
fact(t("When"), h("span", { text: task.description || "" }), task.timezone ? t("Times in %(zone)s", { zone: task.timezone }) : ""),
fact(t("Next run"), h("span", { text: nextWords(task) })),
fact(t("Last run"), statusChip(task.last_run, task.running)),
fact(t("Model"), modelWords(models, task.model)),
fact(t("Reasoning effort"), h("span", { text: task.effort ? levelWord(task.effort) : t("The model's own") })),
fact(t("Subagents"), h("span", { text: task.agents ? t("Up to %(n)s at once", { n: task.agents }) : t("None") })),
fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })),
),
h("div", { class: "sched__what" }, h("h2", { class: "sched__h", text: t("What it does") }), h("p", { class: "sched__instruction", text: task.instruction || task.request || "" }), task.request && task.request !== task.instruction ? h("p", { class: "sched__asked", text: t("You asked: “%(text)s”", { text: task.request }) }) : null),
upcoming(task.next_runs || []),
),
history(task, page.runs || [], page.more),
)
}
function fact(label, value, hint) {
return h("div", { class: "fact" }, h("span", { class: "fact__label", text: label }), h("span", { class: "fact__value" }, value), hint ? h("span", { class: "fact__hint", text: hint }) : null)
}
function modelWords(models, id) {
if (!id) return h("span", { text: t("The first free scheduling model") })
const m = models.find((x) => x.id === id)
return m ? h("span", { class: "sched__model" }, avatar(m), h("span", { text: m.name })) : h("span", { text: id })
}
function upcoming(next) {
if (!next.length) return null
return h("div", { class: "sched__next" }, h("h2", { class: "sched__h", text: t("The next runs") }), h("ol", { class: "runs-next" }, next.slice(0, 5).map((s) => h("li", null, h("span", { text: stamp(s) }), h("span", { class: "muted", text: when(s) })))))
}
/** The history: one row per run on fixed tracks -- status, when, model, and a way to its result. */
function history(task, runs, more) {
const box = h("section", { class: "sched__history" }, h("h2", { class: "sched__h", text: t("History") }))
if (!runs.length) {
box.append(h("p", { class: "muted", text: t("It has not run yet.") }))
return box
}
const list = h("ul", { class: "runs" })
const add = (page) => {
for (const run of page) list.append(runRow(task, run))
}
add(runs)
box.append(list)
if (more) {
const earlier = h("button", { type: "button", class: "btn btn--sm sched__more", text: t("Earlier runs") })
earlier.addEventListener("click", async () => {
earlier.disabled = true
try {
const last = list.lastElementChild?.dataset.id || ""
const r = await get(`/schedules/${enc(task.id)}/runs?limit=${RUNS_PAGE}&before=${enc(last)}`)
add(r.runs || [])
if (!r.more) earlier.remove()
else earlier.disabled = false
} catch (err) {
earlier.disabled = false
await ask({ title: t("History"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
}
})
box.append(earlier)
}
return box
}
/** What a run that did not finish well says: waiting and until when, or its error. */
function runNote(run) {
if (run.status === "waiting") {
const again = run.retry_at ? t("tried again %(when)s", { when: when(run.retry_at) }) : ""
return [run.error?.text || STATUS.waiting(), again].filter(Boolean).join(" · ")
}
return run.error?.text || ""
}
function runRow(task, run) {
const model = run.model || {}
const switched = model.reason === "switched" && model.switched_from ? t("%(model)s was busy, so it ran on this one.", { model: model.switched_from }) : ""
const hasResult = run.message_id || run.report_path || run.preview
const detail = h("div", { class: "run__detail", hidden: true })
const open = hasResult
? h("button", { type: "button", class: "btn btn--sm run__open", "aria-expanded": "false", text: t("Result") })
: null
open?.addEventListener("click", async () => {
const opening = detail.hidden
open.setAttribute("aria-expanded", String(opening))
if (!opening) {
detail.hidden = true
return
}
detail.hidden = false
detail.replaceChildren(h("p", { class: "muted", text: t("Loading…") }))
try {
const r = await get(`/schedules/${enc(task.id)}/runs/${enc(run.id)}`)
detail.replaceChildren(
r.text ? h("div", { class: "md run__text" }, markdown(r.text)) : h("p", { class: "muted", text: run.preview || t("The result is no longer there.") }),
r.run?.report_path ? h("p", { class: "run__report", text: t("Written to %(path)s", { path: r.run.report_path }) }) : null,
r.run?.chat_id ? h("div", { class: "set__buttons" }, h("button", { type: "button", class: "btn btn--sm", text: t("Open the chat"), onclick: () => ctx.openChat(r.run.chat_id) })) : null,
)
} catch (err) {
detail.replaceChildren(h("p", { class: "status is-error", text: why(err) }))
}
})
const note = runNote(run)
return h(
"li",
{ class: "run", dataset: { id: run.id } },
h("span", { class: "run__status" }, statusChip(run)),
h(
"span",
{ class: "run__when", title: run.due_at ? t("Due %(when)s", { when: stamp(run.due_at) }) : "" },
h("span", { text: stamp(run.started_at || run.due_at) }),
TIMING[run.timing] ? h("span", { class: "run__late", text: TIMING[run.timing]() }) : null,
),
h("span", { class: "run__model", title: switched }, h("span", { text: model.name || "" }), switched ? h("span", { class: "run__switched", text: t("switched") }) : null),
open || h("span", { class: "run__error", title: note, text: note }),
detail,
)
}
async function runNow(task, btn) {
btn.disabled = true
try {
await post(`/schedules/${enc(task.id)}/run`)
await draw()
} catch (err) {
btn.disabled = false
await ask({ title: t("Run now"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
}
}
async function remove(task) {
const yes = await ask({ title: t("Delete this scheduled task?"), text: t("“%(title)s” stops, and its history goes. The chats it wrote stay."), ok: t("Delete"), cancel: t("Cancel"), danger: true })
if (!yes) return
try {
await del(`/schedules/${enc(task.id)}?keep_chat=true`)
go({ name: "list" })
} catch (err) {
await ask({ title: t("Delete"), text: why(err), ok: t("Close"), cancel: t("Cancel") })
}
}
// --- A task said in words: new, or an existing one edited ---------------------------------------
/**
* The form. The words first: Read them asks the server (compile) for the rule, its title and
* instruction, and the next runs; the title and instruction can then be changed. Below, the
* settings the server keeps per task. An existing task starts from what it is: its words, title,
* instruction, and its rule read back (preview) -- a paused task's next runs too.
*/
async function drawForm(task) {
const editing = !!task
header(editing ? t("Edit %(title)s", { title: task.title }) : t("New scheduled task"), () => go(editing ? { name: "task", id: task.id } : { name: "list" }))
clear($("scheduled-tools"))
const smodels = (await schedulingModels())?.models || []
// What will be saved: the rule only from the server's reading of words (or the task's own).
const draft = {
request: task?.request || "",
title: task?.title || "",
instruction: task?.instruction || "",
rule: task?.rule || null,
target: task?.target || "chat",
model: task?.model || "",
effort: task?.effort || "",
agents: task?.agents || null,
notify: task?.notify || "matters",
mode: task?.mode || "fresh",
}
let reading = editing ? await post("/schedules/preview", { rule: task.rule }).catch(() => null) : null
const words = h("textarea", { id: "sched-words", class: "sched__words", rows: "3", placeholder: t("Every weekday at 8:00, summarise the news on my reading list"), "aria-label": t("What should happen, and when"), value: draft.request })
const readBtn = h("button", { type: "submit", class: "btn", text: editing ? t("Read again") : t("Preview") })
const said = h("p", { class: "status is-error", hidden: true })
const when_ = h("span", { class: "sched__desc", id: "sched-when" })
const zone = h("p", { class: "hint" })
const title = h("input", { type: "text", id: "sched-title", value: draft.title, "aria-label": t("Title") })
const instruction = h("textarea", { id: "sched-instruction", class: "sched__words", rows: "3", "aria-label": t("What it does"), value: draft.instruction })
const settings = h("div", { class: "fields sched__settings" })
const saveBtn = h("button", { type: "button", class: "btn btn--primary", id: "sched-save", text: t("Save") })
const saveSaid = h("p", { class: "status is-error", hidden: true })
const nextBox = h("div", { class: "sched__nextbox" })
const manual = manualRule((rule) => {
post("/schedules/preview", { rule })
.then((r) => {
reading = r
draft.rule = r.rule || {}
drawWhen()
})
.catch((err) => {
saveSaid.textContent = why(err)
saveSaid.hidden = false
})
})
const manualBtn = h("button", { type: "button", class: "btn btn--sm sched__manual-open", text: t("Set the time yourself"), onclick: () => ((manual.hidden = false), (manualBtn.hidden = true), manual.querySelector("input")?.focus()) })
const rest = h(
"div",
{ class: "sched__card", hidden: !editing },
h("div", { class: "fields" }, field(t("Title"), title, "", "sched-title"), h("div", { class: "field" }, h("span", { class: "label", text: t("When") }), when_, zone)),
manualBtn,
manual,
nextBox,
h("label", { class: "sched__h", for: "sched-instruction", text: t("What it does") }),
instruction,
h("p", { class: "hint", text: t("The instruction each run is given, on its own: say everything it needs.") }),
settings,
saveSaid,
h("div", { class: "set__buttons" }, saveBtn),
)
const drawWhen = () => {
const r = reading
const next = r?.next_runs || []
const runs = !!(draft.rule && Object.keys(draft.rule).length)
when_.textContent = (runs && r?.description) || t("These words give no run in the future.")
when_.title = when_.textContent
when_.classList.toggle("is-error", !runs)
zone.textContent = runs && r?.timezone ? t("Times in %(zone)s", { zone: r.timezone }) : ""
nextBox.replaceChildren(upcoming(next) || "")
saveBtn.disabled = !(draft.rule && Object.keys(draft.rule).length)
}
const drawSettings = () => {
const model = smodels.find((m) => m.id === draft.model) || ctx.models().find((m) => m.id === draft.model)
const select = (id, table, key, onChange) =>
h(
"select",
{ id, onchange: (e) => ((draft[key] = e.target.value), onChange?.()) },
Object.entries(table).map(([v, word]) => h("option", { value: v, text: word(), selected: draft[key] === v })),
)
const modelSelect = h(
"select",
{ id: "sched-model", onchange: (e) => ((draft.model = e.target.value), (draft.effort = ""), drawSettings()) },
h("option", { value: "", text: t("The first free scheduling model"), selected: !draft.model }),
smodels.map((m) => h("option", { value: m.id, text: m.free ? m.name : `${m.name} · ${t("busy")}`, selected: draft.model === m.id })),
draft.model && !smodels.some((m) => m.id === draft.model) ? h("option", { value: draft.model, text: draft.model, selected: true }) : null,
)
const levels = model ? levelsOf(model).levels.filter((l) => l !== "default") : []
const effort = h(
"select",
{ id: "sched-effort", disabled: !levels.length && !draft.effort, onchange: (e) => (draft.effort = e.target.value) },
h("option", { value: "", text: t("The model's own"), selected: !draft.effort }),
levels.map((l) => h("option", { value: l, text: levelWord(l), selected: draft.effort === l })),
// A level the task has that its model no longer offers stays, as it is, until changed.
draft.effort && !levels.includes(draft.effort) ? h("option", { value: draft.effort, text: levelWord(draft.effort), selected: true }) : null,
)
const agents = h(
"select",
{ id: "sched-agents", onchange: (e) => (draft.agents = Number(e.target.value) || null) },
h("option", { value: "", text: t("None"), selected: !draft.agents }),
Array.from({ length: MAX_AGENTS - 1 }, (_, i) => i + 2).map((n) => h("option", { value: String(n), text: t("Up to %(n)s at once", { n }), selected: draft.agents === n })),
)
settings.replaceChildren(
field(t("Tell me"), select("sched-notify", NOTIFY, "notify", () => (settings.querySelector("#sched-notify-hint").textContent = NOTIFY_HINT[draft.notify]())), NOTIFY_HINT[draft.notify]?.() || "", "sched-notify", "sched-notify-hint"),
field(t("Runs"), select("sched-mode", MODE, "mode"), draft.mode === "continue" ? t("Each run sees the ones before it.") : t("Each run starts with nothing but the instruction."), "sched-mode"),
field(t("Model"), modelSelect, t("A busy model is waited for, then the next free one is used."), "sched-model"),
field(t("Reasoning effort"), effort, levels.length ? "" : t("Choose a model to choose its effort."), "sched-effort"),
field(t("Subagents"), agents, t("How many work on it at once, the run's own agent among them, on the same model."), "sched-agents"),
)
settings.querySelector("#sched-mode").addEventListener("change", drawSettings)
}
const form = h(
"form",
{ class: "sched sched--new" },
h("label", { class: "sched__h", for: "sched-words", text: t("What should happen, and when") }),
words,
h("p", { class: "hint", text: t("In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.") }),
h("div", { class: "set__buttons" }, readBtn),
said,
rest,
)
form.addEventListener("submit", async (e) => {
e.preventDefault()
const text = words.value.trim()
if (!text) return words.focus()
readBtn.disabled = true
said.hidden = true
try {
const c = await post("/schedules/compile", { text })
draft.request = text
draft.rule = c.rule || {}
if (c.title || !editing) title.value = c.title || ""
if (c.instruction || !editing) instruction.value = c.instruction || text
if (c.target) draft.target = c.target
reading = c
if (!c.ok && c.reason) {
said.textContent = c.reason
said.hidden = false
}
// The words gave no rule: the form for one, rather than a task that cannot run.
if (!(c.rule && Object.keys(c.rule).length)) {
manual.hidden = false
manualBtn.hidden = true
}
rest.hidden = false
drawWhen()
;(c.ok ? saveBtn : instruction).focus()
} catch (err) {
said.textContent = why(err)
said.hidden = false
} finally {
readBtn.disabled = false
}
})
words.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
})
saveBtn.addEventListener("click", async () => {
saveBtn.disabled = true
saveSaid.hidden = true
const body = {
request: draft.request || words.value.trim(),
title: title.value.trim(),
instruction: instruction.value.trim(),
rule: draft.rule,
target: draft.target,
model: draft.model,
effort: draft.effort,
agents: draft.agents,
notify: draft.notify,
mode: draft.mode,
}
try {
const r = editing ? await patch(`/schedules/${enc(task.id)}`, body) : await post("/schedules", body)
go({ name: "task", id: r.schedule.id })
} catch (err) {
saveSaid.textContent = why(err)
saveSaid.hidden = false
saveBtn.disabled = false
}
})
clear($("scheduled-body")).append(form)
drawSettings()
drawWhen()
words.focus()
}
/**
* The rule set by hand, for words the server could not read into one: the days of the week (none
* ticked: every day) and a time, as the rule's calendar (`{ at: { weekdays, times } }`, Monday 0).
* `onRule(rule)` on every change; the server reads it back (preview).
*/
function manualRule(onRule) {
const lang = document.documentElement.lang || undefined
const days = Array.from({ length: 7 }, (_, i) => {
const d = new Date(2024, 0, 1 + i) // 1 January 2024 was a Monday
return h("label", { class: "sday", title: d.toLocaleDateString(lang, { weekday: "long" }) }, h("input", { type: "checkbox", value: String(i) }), h("span", { text: d.toLocaleDateString(lang, { weekday: "short" }) }))
})
const time = h("input", { type: "time", id: "sched-time", value: "08:00", required: true })
const box = h(
"div",
{ class: "sched__manual", hidden: true },
h("div", { class: "fields" }, h("div", { class: "field" }, h("span", { class: "label", text: t("Days") }), h("div", { class: "sdays", role: "group", "aria-label": t("Days") }, days), h("p", { class: "hint", text: t("No day ticked: every day.") })), field(t("Time"), time, "", "sched-time")),
)
const changed = () => {
if (!time.value) return
const weekdays = days.map((d) => d.querySelector("input")).filter((i) => i.checked).map((i) => Number(i.value))
onRule({ at: { ...(weekdays.length ? { weekdays } : {}), times: [time.value] } })
}
box.addEventListener("change", changed)
return box
}
/** A field on the form's shared tracks: label, control, hint -- always all three. */
function field(label, control, hint, forId, hintId) {
return h("div", { class: "field" }, h("label", { for: forId || null, text: label }), control, h("p", { class: "hint", id: hintId || null, text: hint || "" }))
}
// --- Live: the list as the server's event stream gives it ---------------------------------------
/** A `schedules` frame (`{ version, schedules }`), handed on by the app: redraw what it changes. */
function onSchedules(list) {
if (!Array.isArray(list?.schedules)) return
tasks = list.schedules
missing = false
if (!onPage() || popoverOpenHere()) return
if (view.name === "list") drawList()
else if (view.name === "task" && shown) {
const now = tasks.find((x) => x.id === shown.id)
if (!now) return go({ name: "list" })
const last = now.last_run || {}
if ((last.id || null) !== shown.last || (last.status || null) !== shown.status || now.running !== shown.running) draw()
}
}
function popoverOpenHere() {
return !!document.querySelector(".pop .smodels")
}
/** The palette's commands for scheduled tasks. */
export function scheduledCommands() {
return [
{
label: t("New scheduled task"),
run: () => {
ctx.show()
go({ name: "form" })
},
},
]
}
export function initScheduled(context) {
ctx = context
listen("schedules", (e) => onSchedules(e.payload))
}
+165
View File
@@ -0,0 +1,165 @@
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
// sees). Grouped, with the groups listed beside them: the account (the server and the app's
// sign-in), the agent and the folders it may work in, notifications, and this release.
import { $, h, clear, icon, ICONS } from "./dom.js"
import { invoke, listen } from "./bridge.js"
import { agentBlock, onAgentChange, link } from "./agent.js"
let folderError = ""
let current = () => null
const SECTIONS = [
["account", ICONS.user, () => t("Account")],
["agent", ICONS.terminal, () => t("Agent")],
["folders", ICONS.folder, () => t("Project folders")],
["notify", ICONS.bell, () => t("Notifications")],
["about", ICONS.info, () => t("About")],
]
/** Bring one group into view (the title bar's agent panel opens Settings at Agent). */
export function scrollToSection(id) {
const el = $(`set-${id}`)
if (!el) return
el.scrollIntoView({ block: "start" })
markNav(id)
el.querySelector("button, select, input")?.focus({ preventScroll: true })
}
function markNav(id) {
for (const b of $("settings-nav").querySelectorAll("button")) b.setAttribute("aria-current", String(b.dataset.id === id))
}
function section(id, title, ...body) {
return h("section", { class: "set", id: `set-${id}`, "aria-labelledby": `set-${id}-h` }, h("h2", { class: "set__title", id: `set-${id}-h`, text: title }), h("div", { class: "set__card" }, body))
}
/** One line of a group: its words on the left (a label and a hint), its control on the right. */
function line(label, hint, control) {
return h("div", { class: "set__line" }, h("div", { class: "set__words" }, h("p", { class: "set__label", text: label }), h("p", { class: "set__hint", text: hint || "" })), h("div", { class: "set__control" }, control))
}
export function renderSettings(s) {
if (!s) return
const nav = clear($("settings-nav"))
for (const [id, ico, title] of SECTIONS) {
if (id === "folders" && !(s.linked && s.remote)) continue
nav.append(h("button", { type: "button", class: "navrow", dataset: { id }, onclick: () => scrollToSection(id) }, h("span", { class: "navrow__icon" }, icon(ico)), h("span", { text: title() })))
}
const box = clear($("settings-body"))
const who = s.account ? [s.account.name, s.account.email].filter(Boolean).join(" · ") : ""
box.append(
section(
"account",
t("Account"),
line(t("Server"), s.discovery ? `LLeMbas ${s.discovery.version}` : "", h("bdi", { class: "set__value", text: s.server || "" })),
line(
who ? t("Signed in as %(who)s.", { who }) : t("Sign in"),
t("Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server."),
h("button", { type: "button", class: "btn btn--sm", text: t("Sign out"), onclick: () => invoke("sign_out") }),
),
),
)
// The agent: what it says, and the one thing to do about it; then start or stop, link again.
box.append(
section(
"agent",
t("Agent"),
h("p", { class: "set__intro", text: t("The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.") }),
agentBlock(s),
h(
"div",
{ class: "set__buttons" },
h("button", { type: "button", class: "btn btn--sm", text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"), disabled: !s.linked && !s.agent_wanted, onclick: () => invoke("set_agent", { on: !s.agent_wanted }) }),
s.linked && s.agent.state !== "refused" ? h("button", { type: "button", class: "btn btn--sm", text: t("Link again"), title: t("Link it again to give the agent a new token."), onclick: () => link() }) : null,
),
),
)
// The folders.
const r = s.remote
if (s.linked && r) {
const list = h("ul", { class: "folders" })
if (!r.enabled || !r.roots.length) list.append(h("li", { class: "empty", text: t("No folder yet: the agent can open nothing. Add the folder of a project.") }))
for (const dir of r.enabled ? r.roots : []) {
list.append(
h(
"li",
null,
h("span", { class: "folders__icon" }, icon(ICONS.folder)),
h("span", { class: "path", title: dir }, h("bdi", { text: dir })),
h("button", { type: "button", class: "btn btn--sm", text: t("Remove"), "aria-label": t("Remove %(dir)s", { dir }), onclick: () => invoke("remote_remove", { dir }) }),
),
)
}
const mode = h(
"select",
{ id: "max-mode", "aria-label": t("The most an agent chat may do here"), onchange: (e) => invoke("remote_mode", { mode: e.target.value }) },
[
["manual", t("Manual — asks first")],
["edit", t("Edit — asks before commands")],
["auto", t("Auto — asks nothing")],
["plan", t("Plan — changes nothing")],
].map(([v, label]) => h("option", { value: v, text: label, selected: r.max_mode === v })),
)
const terminal = h("input", { type: "checkbox", role: "switch", id: "terminal", checked: r.terminal, "aria-label": t("Let the server open a terminal here"), onchange: (e) => invoke("remote_terminal", { on: e.target.checked }) })
box.append(
section(
"folders",
t("Project folders"),
h("p", { class: "set__intro", text: t("Agent chats can work in these folders and anything inside them — nowhere else on this computer.") }),
list,
folderError ? h("p", { class: "status is-error", text: folderError }) : null,
h("div", { class: "set__buttons" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") })),
line(t("The most an agent chat may do here"), t("A chat asking for more is lowered to this."), mode),
line(t("Let the server open a terminal here"), t("A shell as you, in one of these folders."), h("label", { class: "toggle" }, terminal)),
),
)
}
box.append(
section(
"notify",
t("Notifications"),
line(
t("Tell me when a reply or an agent's work is finished while I am elsewhere"),
t("Scheduled tasks tell you of a result, too, while the app runs."),
h("label", { class: "toggle" }, h("input", { type: "checkbox", role: "switch", checked: s.notify, "aria-label": t("Notifications"), onchange: (e) => invoke("set_notify", { on: e.target.checked }) })),
),
),
)
box.append(
section(
"about",
t("About"),
line(
`LLeMbas Desktop ${s.version}`,
s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : "",
h("button", { type: "button", class: "btn btn--sm", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
),
),
)
}
export function initSettings(getStatus) {
current = getStatus
listen("remote-error", (e) => {
folderError = String(e.payload)
if (!$("settings").hidden) renderSettings(current())
})
listen("status", () => {
folderError = ""
})
onAgentChange(() => {
if (!$("settings").hidden) renderSettings(current())
})
$("settings-body").addEventListener("scroll", () => {
// The group at the top is the one marked beside them.
const top = $("settings-body").getBoundingClientRect().top
let at = ""
for (const sec of $("settings-body").querySelectorAll(".set")) if (sec.getBoundingClientRect().top - top < 64) at = sec.id.replace(/^set-/, "")
if (at) markNav(at)
})
}
+117
View File
@@ -0,0 +1,117 @@
// Before the app: which server, and the app's sign-in to it. A server older than LLeMbas 2.0 is
// refused here, with the reason Rust gave (src-tauri/src/server.rs, `check`). Signing in is the
// server's device login: the approval page opens in the system's browser; this page shows the
// code to compare and waits. Where the server offers it (`one_approval`) and the agent's link does
// not work yet, the same approval links this computer for the agent: there is no second one.
import { $, h, clear } from "./dom.js"
import { invoke, listen, why } from "./bridge.js"
let code = null // the code being approved, while signing in
let failed = ""
export function renderSetup(s) {
const box = clear($("setup-body"))
$("setup-version").textContent = s.version
const server = h("section", { "aria-labelledby": "setup-server-h" }, h("h2", { id: "setup-server-h", text: t("Server") }))
const address = h("input", {
id: "address",
name: "address",
type: "text",
inputmode: "url",
autocomplete: "url",
placeholder: "https://ai.example",
spellcheck: "false",
value: s.server || "",
})
const hint = h("p", { class: "hint", id: "server-hint", "aria-live": "polite" })
if (!s.checked) hint.textContent = t("Looking for a LLeMbas server there…")
else if (s.refused) {
hint.textContent = s.refused
hint.classList.add("is-error")
} else if (s.discovery) hint.textContent = t("%(name)s, LLeMbas %(version)s.", { name: s.discovery.name, version: s.discovery.version })
else hint.textContent = t("LLeMbas Desktop needs a LLeMbas 2.0 server or later.")
const connect = h("button", { type: "submit", class: "btn btn--primary", text: t("Connect") })
const form = h(
"form",
{ class: "field", novalidate: true },
h("label", { for: "address", text: t("Address of your LLeMbas server") }),
h("div", { class: "row" }, address, connect),
hint,
)
form.addEventListener("submit", async (e) => {
e.preventDefault()
connect.disabled = true
hint.className = "hint"
hint.textContent = t("Looking for a LLeMbas server there…")
try {
await invoke("set_server", { address: address.value })
} catch (err) {
hint.className = "hint is-error"
hint.textContent = why(err)
} finally {
connect.disabled = false
}
})
server.append(form)
if (s.refused && s.server) {
server.append(h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn", text: t("Try again"), onclick: () => invoke("recheck_server") })))
}
box.append(server)
if (s.discovery && !s.account) {
const sign = h("section", { "aria-labelledby": "setup-sign-h" }, h("h2", { id: "setup-sign-h", text: t("Sign in") }))
// The same approval links this computer unless the agent's link works already (Rust decides
// the same way, `link_works`).
const linked = s.agent?.state === "running" || (s.agent?.state === "elsewhere" && s.agent.detail?.linked)
const both = s.discovery.one_approval && !linked
sign.append(h("p", { class: "muted", text: both ? t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does, and the same approval links this computer for the agent.") : t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.") }))
if (s.signing_in && code) {
sign.append(
h("p", null, t("Approve this code in the page that opened in your browser:")),
h("p", { class: "code", text: code.user_code }),
h(
"div",
{ class: "row--buttons" },
h("button", { type: "button", class: "btn", text: t("Open the page again"), onclick: () => invoke("open_external", { url: code.verification_uri_complete }) }),
h("button", { type: "button", class: "btn", text: t("Cancel"), onclick: () => invoke("sign_in_cancel") }),
),
)
} else {
sign.append(
h(
"div",
{ class: "row--buttons" },
h("button", {
type: "button",
class: "btn btn--primary",
text: s.signing_in ? t("Waiting for the browser…") : t("Sign in"),
disabled: s.signing_in,
onclick: () => {
failed = ""
invoke("sign_in").catch((err) => {
failed = why(err)
renderSetup(s)
})
},
}),
),
)
}
if (failed) sign.append(h("p", { class: "status is-error", text: failed }))
box.append(sign)
}
}
export function initSetup(current) {
listen("signin", (e) => {
const p = e.payload
if (p.state === "code") code = p.code
if (p.state === "done") code = null
if (p.state === "failed") {
code = null
failed = p.message
}
renderSetup(current())
})
}
+132
View File
@@ -0,0 +1,132 @@
// The title bar: the app's own, in the page (the window has no system decoration). Moving and
// double-click maximising come from `data-tauri-drag-region`; the edges resize the window (Tauri
// does that for an undecorated one). The menu opens the app's native menu; the agent's chip opens
// a small panel with what the agent says and the one thing to do about it.
import { $, h, icon, ICONS } from "./dom.js"
import { invoke, listen, win } from "./bridge.js"
import { agentBlock, onAgentChange } from "./agent.js"
import { popover, closePopover } from "./popup.js"
const WORDS = {
off: () => t("agent off"),
not_linked: () => t("not linked"),
starting: () => t("agent starting…"),
connecting: () => t("agent connecting…"),
running: () => t("agent running"),
waiting: () => t("agent waiting"),
refused: () => t("agent refused"),
elsewhere: () => t("agent running (service)"),
elsewhere_waiting: () => t("agent waiting"),
failed: () => t("agent stopped"),
no_folders: () => t("agent: no folders"),
}
let ctx = null // { status(), openSettings() }
let pop = null
/**
* The chip's state: what the CLI says of its link, not whether a process runs. Linked but allowed
* into no folder is said too -- a green light would say it works when it cannot.
*/
export function pillState(s) {
const a = s.agent
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
const linked = a.state === "running" || (a.state === "elsewhere" && a.detail?.linked)
if (linked && noFolders) return "no_folders"
if (a.state === "elsewhere" && !a.detail?.linked) return "elsewhere_waiting"
return a.state
}
/** Why the agent is not working, in the CLI's words, when it said. */
export function agentWhy(a) {
if (a.state === "elsewhere") return a.detail?.why || ""
return typeof a.detail === "string" ? a.detail : ""
}
export function renderBar(s) {
let host = ""
try {
host = s.server ? new URL(s.server).host : ""
} catch {}
$("host").textContent = host
$("host").title = s.server || ""
const state = pillState(s)
const reason = agentWhy(s.agent)
$("agent").dataset.state = state
$("agent-text").textContent = WORDS[state] ? WORDS[state]() : state
$("agent").title = reason ? t("Agent: %(why)s", { why: reason }) : t("Agent")
$("update").hidden = !s.update
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update")
$("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""
pop?.redraw()
}
/** The chip's panel: the agent's state and its button, then start or stop, and Settings. */
function agentPanel() {
const s = ctx.status()
const box = h("div", { class: "pop__body" }, h("p", { class: "pop__title", text: t("Agent") }), agentBlock(s))
const row = h("div", { class: "pop__actions" })
if (s.linked) {
row.append(h("button", { type: "button", class: "btn btn--sm", text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"), onclick: () => invoke("set_agent", { on: !s.agent_wanted }) }))
}
row.append(
h("button", {
type: "button",
class: "btn btn--sm",
text: t("Settings"),
onclick: () => {
closePopover()
ctx.openSettings("agent")
},
}),
)
box.append(row)
return box
}
function toggleAgent() {
if (pop) {
closePopover()
return
}
$("agent").setAttribute("aria-expanded", "true")
pop = popover($("agent"), agentPanel, {
align: "end",
label: t("Agent"),
onClose: () => {
pop = null
$("agent").setAttribute("aria-expanded", "false")
},
})
}
function maximized(on) {
const b = $("maximize")
b.replaceChildren(icon(on ? ICONS.restore : ICONS.max))
b.setAttribute("aria-label", on ? t("Restore") : t("Maximise"))
b.title = on ? t("Restore") : t("Maximise")
}
// The menu opens under the button that was clicked: its bottom-left corner, in page pixels.
function menuAt(button) {
const r = button.getBoundingClientRect()
return invoke("titlebar_menu", { x: r.left, y: r.bottom })
}
export function initBar(context) {
ctx = context
$("minimize").replaceChildren(icon(ICONS.min))
$("close").replaceChildren(icon(ICONS.close))
$("menu").replaceChildren(icon(ICONS.menu))
$("side-toggle").replaceChildren(icon(ICONS.side))
$("minimize").addEventListener("click", () => win.minimize())
$("maximize").addEventListener("click", () => win.toggleMaximize())
$("close").addEventListener("click", () => win.close())
$("update").addEventListener("click", () => invoke("update_app"))
$("menu").addEventListener("click", (e) => menuAt(e.currentTarget))
$("agent").addEventListener("click", toggleAgent)
onAgentChange(() => pop?.redraw())
listen("maximized", (e) => maximized(!!e.payload))
win.isMaximized().then(maximized).catch(() => maximized(false))
}
+124
View File
@@ -0,0 +1,124 @@
// The viewer beside the chat: a file from this computer, from the server, or one the agent showed
// (`show`). Markdown is drawn (`markdown.js`), code and text are CodeMirror, read-only, and HTML
// is a sealed frame (src-tauri/src/sealed.rs): its own scheme, no bridge, no network, no way
// out. What a file says is never part of the app's page.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke } from "./bridge.js"
import { markdown } from "./markdown.js"
import { codeView } from "./code.js"
// The viewer's kind by a file's name: the CLI's and the server's `showKind`, the same table.
const LANGUAGES = {
ts: "typescript", tsx: "tsx", js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", json: "json", py: "python", rs: "rust", go: "go",
rb: "ruby", php: "php", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp", cs: "csharp",
sh: "bash", bash: "bash", zsh: "bash", fish: "fish", css: "css", scss: "scss", sql: "sql", yaml: "yaml", yml: "yaml", toml: "toml",
xml: "xml", svg: "xml", lua: "lua", zig: "zig", dart: "dart", vue: "vue", svelte: "svelte",
}
export function showKind(path) {
const name = String(path).split("/").pop() || ""
const ext = name.includes(".") ? name.split(".").pop().toLowerCase() : ""
if (ext === "md" || ext === "markdown") return { kind: "markdown", language: "markdown" }
if (ext === "html" || ext === "htm") return { kind: "html", language: "html" }
const language = LANGUAGES[ext] || (name === "Dockerfile" ? "dockerfile" : name === "Makefile" ? "makefile" : "")
return language ? { kind: "code", language } : { kind: "text", language: "" }
}
let current = null // { editor, sealed }
let onChange = () => {}
/** Called when the viewer opens or closes (the layout makes room for it). */
export function onViewer(fn) {
onChange = fn
}
function teardown() {
if (!current) return
current.editor?.destroy()
if (current.sealed) invoke("sealed_close", { url: current.sealed })
current = null
}
export function closeViewer() {
teardown()
$("viewer").hidden = true
clear($("viewer-body"))
onChange(false)
}
/**
* Why a file has no text, in words: this computer's `withheld` is "binary" | "too_big"; the
* server's is { reason: "not_text" | "too_large", message } with the message in the person's
* language already.
*/
function withheldWords(withheld, size) {
if (typeof withheld === "object" && withheld.message) return String(withheld.message)
const reason = typeof withheld === "object" ? withheld.reason : withheld
return reason === "too_big" || reason === "too_large"
? t("This file is too big to show here (%(size)s).", { size: bytes(size) })
: t("This is not a text file; it cannot be shown here.")
}
/**
* Show a file. `file`: { name, path, where (words for its place), text, kind?, language?, line?,
* withheld? (see withheldWords), size?, error? }.
*/
export async function openViewer(file) {
teardown()
const kind = file.kind || showKind(file.path || file.name).kind
const lang = file.language ?? showKind(file.path || file.name).language
$("viewer").hidden = false
onChange(true)
$("viewer-name").textContent = file.name || file.path || ""
$("viewer-name").title = file.path || ""
$("viewer-where").textContent = file.where || ""
const body = clear($("viewer-body"))
const modes = clear($("viewer-modes"))
current = {}
if (file.error) {
body.append(h("p", { class: "viewer__note is-error", text: file.error }))
return
}
if (file.withheld) {
body.append(h("p", { class: "viewer__note", text: withheldWords(file.withheld, file.size) }))
return
}
const text = file.text ?? ""
// Markdown and HTML can be seen drawn or as their source.
const drawn = kind === "markdown" || kind === "html"
const show = async (asSource) => {
teardown()
current = {}
clear(body)
for (const b of modes.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.source === String(asSource)))
if (asSource || !drawn) {
const holder = h("div", { class: "viewer__code" })
body.append(holder)
current.editor = await codeView(holder, text, { lang, line: file.line || 0 })
} else if (kind === "markdown") {
body.append(h("div", { class: "md viewer__md" }, markdown(text)))
} else {
const url = await invoke("sealed_open", { html: text })
current.sealed = url
// No allow-same-origin, no allow-top-navigation: an opaque page that can run its own
// scripts and nothing else. The address's own policy says the same again.
body.append(h("iframe", { class: "viewer__frame", src: url, sandbox: "allow-scripts", referrerpolicy: "no-referrer", title: file.name || "" }))
}
}
if (drawn) {
modes.append(
h("button", { type: "button", class: "seg", dataset: { source: "false" }, text: kind === "html" ? t("Page") : t("Preview"), onclick: () => show(false) }),
h("button", { type: "button", class: "seg", dataset: { source: "true" }, text: t("Source"), onclick: () => show(true) }),
)
}
await show(false)
}
export function initViewer() {
$("viewer-close").replaceChildren(icon(ICONS.close))
$("viewer-close").addEventListener("click", closeViewer)
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !$("viewer").hidden && !e.defaultPrevented) closeViewer()
})
}
-15
View File
@@ -1,15 +0,0 @@
// The app's own pages are painted in the server page's theme, which the app reports in `status`
// (see src-tauri/src/lib.rs: the content view says, the app remembers). tokens.css selects a
// theme by `data-theme` and `data-base` on <html>, as the server's pages do; with nothing set
// it is Moria, the server's own default, so the page needs no script to be right at first.
"use strict"
window.lembasTheme = {
apply(name) {
if (name !== "moria" && name !== "shire") return
const root = document.documentElement
if (root.dataset.theme === name) return
root.dataset.theme = name
root.dataset.base = name
},
}
-99
View File
@@ -1,99 +0,0 @@
/* The title bar: one row, fixed height, every slot a fixed or shared track so nothing in it
moves when the host name or the agent's state changes length. Colours, type and space are
tokens from tokens.css (the server's); the bar's height is this page's own. */
* { box-sizing: border-box; }
:root {
/* `TITLE_H` in src-tauri/src/lib.rs is this number: the app lays the bar out at it. */
--titlebar-h: 36px;
}
html, body {
margin: 0;
height: var(--titlebar-h);
overflow: hidden;
background: var(--bg);
color: var(--ink);
user-select: none;
-webkit-user-select: none;
cursor: default;
}
.bar {
display: grid;
/* leaf · name · host (takes the rest, and is the drag area) · update · agent · menu · sep · 3 controls */
grid-template-columns: var(--titlebar-h) auto minmax(0, 1fr) auto auto var(--titlebar-h) var(--border-w) repeat(3, 2.875rem);
align-items: center;
height: var(--titlebar-h);
border-bottom: var(--border-w) solid var(--border);
font: 600 var(--text-sm) / 1 var(--font-ui);
}
.bar__leaf { justify-self: center; width: 18px; height: 18px; image-rendering: pixelated; }
.bar__name b { color: var(--leaf); font-weight: 700; }
.bar__host {
padding: 0 var(--sp-3);
color: var(--ink-muted);
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
align-self: stretch;
display: flex;
align-items: center;
}
button {
height: var(--titlebar-h);
margin: 0;
padding: 0;
font: inherit;
color: var(--ink-muted);
background: transparent;
border: 0;
border-radius: 0;
cursor: pointer;
}
button:hover { color: var(--ink); background: var(--surface-hover); }
button:focus-visible { outline: var(--outline-w) solid var(--accent); outline-offset: calc(-1 * var(--outline-w)); }
.bar__agent {
display: grid;
grid-template-columns: var(--sp-2) auto;
gap: var(--sp-2);
align-items: center;
padding: 0 var(--sp-3);
font-weight: 400;
}
.dot { width: var(--sp-2); height: var(--sp-2); background: var(--ink-muted); }
.bar__agent[data-state="running"] .dot,
.bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); }
.bar__agent[data-state="starting"] .dot { background: var(--accent); }
.bar__agent[data-state="failed"] .dot,
.bar__agent[data-state="not_linked"] .dot,
.bar__agent[data-state="no_folders"] .dot { background: var(--warning); }
.bar__update {
padding: 0 var(--sp-3);
color: var(--accent-ink);
background: var(--accent);
}
.bar__update:hover { color: var(--accent-ink); background: var(--accent-hover); }
/* Hidden keeps its grid track (auto, so zero wide): the slots after it never move. */
.bar__update[hidden] { display: block; width: 0; padding: 0; overflow: hidden; }
.bar__sep { width: var(--border-w); height: var(--sp-5); background: var(--border); }
.bar__btn { display: grid; place-items: center; }
.bar__btn svg { width: 16px; height: 16px; }
.bar__btn--close:hover { color: var(--ink-inverse); background: var(--danger); }
#maximize .i-restore { display: none; }
#maximize[data-maximized] .i-max { display: none; }
#maximize[data-maximized] .i-restore { display: block; }
/* Narrow windows: the host goes first, then the agent's words (its dot stays). */
@media (max-width: 34rem) {
.bar__host { visibility: hidden; }
#agent-text { display: none; }
}
-39
View File
@@ -1,39 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="dark light">
<title>LLeMbas</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="titlebar.css">
<script src="theme.js"></script>
<script src="i18n.js" defer></script>
<script src="titlebar.js" defer></script>
</head>
<body>
<header class="bar" data-tauri-drag-region>
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
<span class="bar__host" id="host" data-tauri-drag-region></span>
<button class="bar__update" id="update" type="button" hidden data-i18n>update</button>
<button class="bar__agent" id="agent" type="button" title="Agent" data-i18n-title>
<span class="dot" aria-hidden="true"></span><span id="agent-text" data-i18n>agent off</span>
</button>
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu" data-i18n-aria-label data-i18n-title>
<svg viewBox="0 0 16 16" aria-hidden="true"><g fill="currentColor"><rect x="3" y="4" width="10" height="1.5"/><rect x="3" y="7.25" width="10" height="1.5"/><rect x="3" y="10.5" width="10" height="1.5"/></g></svg>
</button>
<span class="bar__sep" aria-hidden="true"></span>
<button class="bar__btn" id="minimize" type="button" aria-label="Minimise" title="Minimise" data-i18n-aria-label data-i18n-title>
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="10" width="8" height="1.5" fill="currentColor"/></svg>
</button>
<button class="bar__btn" id="maximize" type="button" aria-label="Maximise" title="Maximise" data-i18n-aria-label data-i18n-title>
<svg class="i-max" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.75 4.75h6.5v6.5h-6.5z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
<svg class="i-restore" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.75 6.25h6v6h-6z M6.25 6.25v-2.5h6v6h-2.5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<button class="bar__btn bar__btn--close" id="close" type="button" aria-label="Close" title="Close (LLeMbas stays in the tray)" data-i18n-aria-label data-i18n-title>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
</header>
</body>
</html>
-66
View File
@@ -1,66 +0,0 @@
// The title bar. Moving and double-click maximising come from `data-tauri-drag-region`; the
// buttons call the window directly; the menu and the agent pill open the app's native menu (the
// tray's items), which can reach past this 36-pixel-high page. The bar is painted in the theme
// the app reports in `status` (the server page's); every word goes through `t` (i18n.js).
"use strict"
const { invoke } = window.__TAURI__.core
const { listen } = window.__TAURI__.event
const win = window.__TAURI__.window.getCurrentWindow()
const $ = (id) => document.getElementById(id)
const WORDS = {
off: () => t("agent off"),
not_linked: () => t("not linked"),
starting: () => t("agent starting…"),
running: () => t("agent running"),
elsewhere: () => t("agent running (service)"),
failed: () => t("agent stopped"),
no_folders: () => t("agent: no folders"),
}
function render(s) {
window.lembasTheme.apply(s.theme)
let host = ""
try {
host = s.server ? new URL(s.server).host : ""
} catch {}
$("host").textContent = host
$("host").title = s.server || ""
// Connected but allowed into no folder: a green light would say it works when it cannot.
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
const state = s.agent.state === "running" && noFolders ? "no_folders" : s.agent.state
$("agent").dataset.state = state
$("agent-text").textContent = WORDS[state] ? WORDS[state]() : state
$("agent").title = state === "failed" ? t("Agent stopped: %(why)s", { why: s.agent.detail }) : t("Agent")
$("update").hidden = !s.update
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update")
$("update").title = s.update
? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version })
: ""
}
function maximized(on) {
const b = $("maximize")
b.toggleAttribute("data-maximized", on)
b.setAttribute("aria-label", on ? t("Restore") : t("Maximise"))
b.title = on ? t("Restore") : t("Maximise")
}
$("minimize").addEventListener("click", () => win.minimize())
$("maximize").addEventListener("click", () => win.toggleMaximize())
$("close").addEventListener("click", () => win.close())
$("update").addEventListener("click", () => invoke("update_app"))
// The menu opens under the button that was clicked: its bottom-left corner, in page pixels.
function menuAt(button) {
const r = button.getBoundingClientRect()
return invoke("titlebar_menu", { x: r.left, y: r.bottom })
}
$("menu").addEventListener("click", (e) => menuAt(e.currentTarget))
$("agent").addEventListener("click", (e) => menuAt(e.currentTarget))
listen("status", (e) => render(e.payload))
listen("maximized", (e) => maximized(!!e.payload))
invoke("get_status").then(render)
win.isMaximized().then(maximized)
+7 -32
View File
@@ -1,4 +1,4 @@
/* LLeMbas v1.3.0 (61cf4c267c98) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit. /* LLeMbas v2.0.0-beta.6 (a71e9b4bdb8a) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
The font-face rules are left out; the app bundles no font. */ The font-face rules are left out; the app bundles no font. */
/* /*
Design tokens. Design tokens.
@@ -99,37 +99,6 @@
re-wraps everything a program prints. */ re-wraps everything a program prints. */
--terminal-width: 34rem; --terminal-width: 34rem;
--terminal-width-min: 24rem; --terminal-width-min: 24rem;
/* Wider again: a source line is longer than eighty columns once nothing is
re-wrapping it, and this one holds prose as well. The minimum is 24rem =
384px and must equal both `data-resize-min` in chat/_canvas.html and the
lower bound in api/preferences.py:LAYOUT_BOUNDS -- a width outside those
bounds is silently dropped, so the three are pinned equal by a test. */
--canvas-width: 40rem;
--canvas-width-min: 24rem;
/* The prototype panel. The canvas's width, not wider: the page inside is not
laid out at the panel's width anyway (see --proto-desktop-width), so a wider
panel only buys a larger picture at the cost of the conversation beside it
-- at 52rem a 1280px window had 180px of chat left. The minimum is pinned to
`data-resize-min` and LAYOUT_BOUNDS exactly as the canvas's is. */
--prototype-width: 40rem;
--prototype-width-min: 24rem;
/* The two views are two viewports, each scaled to fit the panel rather than
squeezed into it. A desktop design shown at 640px is its tablet layout, and
the switch says "desktop". So the page is laid out at a desktop's width and
the picture is shrunk, the way a browser zoomed out would show it -- and a
phone is an iPhone-sized viewport, the width most designs are checked at.
A frame either way, not an emulation: the page's own media queries answer
to these numbers, which is the whole point of the switch. */
--proto-desktop-width: 80rem;
--proto-phone-width: 24.375rem;
--proto-phone-height: 52.75rem;
/* An inline prototype is as tall as the page it holds, within these. The
floor keeps a frame that has not reported yet from collapsing to nothing;
the ceiling is what the model is told ("under about 600 pixels"), with a
little room, so a page sized in `100vh` cannot grow a frame forever. */
--proto-inline-min: 6rem;
--proto-inline-start: 16rem;
--proto-inline-max: 40rem;
--thread-max-width: 48rem; --thread-max-width: 48rem;
--header-height: 3.5rem; --header-height: 3.5rem;
@@ -140,6 +109,7 @@
one that names what it is: a theme can then widen every menu at once, and one that names what it is: a theme can then widen every menu at once, and
two popovers cannot drift a rem apart by accident. Control heights are two popovers cannot drift a rem apart by accident. Control heights are
`--control-h`; these are the boxes around them. */ `--control-h`; these are the boxes around them. */
--w-num: 4.5rem; /* a column of figures: tokens, a share, an elapsed time */
--w-field-xs: 8rem; /* a short input: a count, a duration */ --w-field-xs: 8rem; /* a short input: a count, a duration */
--w-field-sm: 10rem; /* a short select's floor */ --w-field-sm: 10rem; /* a short select's floor */
--w-field: 14rem; /* a capped field, a label column, a small menu */ --w-field: 14rem; /* a capped field, a label column, a small menu */
@@ -352,6 +322,10 @@
--accent-hover: #B4E6F1; --accent-hover: #B4E6F1;
--accent-ink: #061418; --accent-ink: #061418;
--accent-soft: rgba(143, 216, 232, 0.12); --accent-soft: rgba(143, 216, 232, 0.12);
/* Selected text where the glyphs are drawn by something other than the
selection -- the composer, whose visible text is its mirror. Stronger
than --accent-soft, which is a tint for a background nobody has to find. */
--selection: rgba(143, 216, 232, 0.32);
/* Mallorn: the brand, the assistant's mark, the leaf badges -- phosphor /* Mallorn: the brand, the assistant's mark, the leaf badges -- phosphor
green on the dark, the colour of an old terminal's text. */ green on the dark, the colour of an old terminal's text. */
@@ -495,6 +469,7 @@
--accent-hover: #1F4D30; --accent-hover: #1F4D30;
--accent-ink: #F8F3E5; --accent-ink: #F8F3E5;
--accent-soft: rgba(40, 95, 60, 0.1); --accent-soft: rgba(40, 95, 60, 0.1);
--selection: rgba(40, 95, 60, 0.24);
/* The same mallorn as moss, dark enough to be text on parchment. */ /* The same mallorn as moss, dark enough to be text on parchment. */
--leaf: #3D6B1C; --leaf: #3D6B1C;
+3
View File
@@ -0,0 +1,3 @@
/* esm.sh - @codemirror/autocomplete@6.20.4 */
import{Annotation as je,StateEffect as O,EditorSelection as v,codePointAt as w,codePointSize as T,fromCodePoint as Ee,Facet as De,combineConfig as He,StateField as ce,Prec as fe,Transaction as Ve,Text as qe,MapMode as J,RangeValue as Ke,RangeSet as ze,CharCategory as Z}from"./cm-state.mjs";import{Direction as Ye,logException as _,showTooltip as Qe,EditorView as L,ViewPlugin as Xe,getTooltip as Re,Decoration as q,WidgetType as Ge,keymap as Pe}from"./cm-view.mjs";import{syntaxTree as $,indentUnit as Je}from"./cm-language.mjs";var U=class{constructor(e,t,n,o){this.state=e,this.pos=t,this.explicit=n,this.view=o,this.abortListeners=[],this.abortOnDocChange=!1}tokenBefore(e){let t=$(this.state).resolveInner(this.pos,-1);for(;t&&e.indexOf(t.name)<0;)t=t.parent;return t?{from:t.from,to:this.pos,text:this.state.sliceDoc(t.from,this.pos),type:t.type}:null}matchBefore(e){let t=this.state.doc.lineAt(this.pos),n=Math.max(t.from,this.pos-250),o=t.text.slice(n-t.from,this.pos-t.from),s=o.search(Le(e,!1));return s<0?null:{from:n+s,to:this.pos,text:o.slice(s)}}get aborted(){return this.abortListeners==null}addEventListener(e,t,n){e=="abort"&&this.abortListeners&&(this.abortListeners.push(t),n&&n.onDocChange&&(this.abortOnDocChange=!0))}};function ge(i){let e=Object.keys(i).join(""),t=/\w/.test(e);return t&&(e=e.replace(/\w/g,"")),`[${t?"\\w":""}${e.replace(/[^\w\s]/g,"\\$&")}]`}function Ze(i){let e=Object.create(null),t=Object.create(null);for(let{label:o}of i){e[o[0]]=!0;for(let s=1;s<o.length;s++)t[o[s]]=!0}let n=ge(e)+ge(t)+"*$";return[new RegExp("^"+n),new RegExp(n)]}function _e(i){let e=i.map(o=>typeof o=="string"?{label:o}:o),[t,n]=e.every(o=>/^\w+$/.test(o.label))?[/\w*$/,/\w+$/]:Ze(e);return o=>{let s=o.matchBefore(n);return s||o.explicit?{from:s?s.from:o.pos,options:e,validFor:t}:null}}function zt(i,e){return t=>{for(let n=$(t.state).resolveInner(t.pos,-1);n;n=n.parent){if(i.indexOf(n.name)>-1)return e(t);if(n.type.isTop)break}return null}}function Yt(i,e){return t=>{for(let n=$(t.state).resolveInner(t.pos,-1);n;n=n.parent){if(i.indexOf(n.name)>-1)return null;if(n.type.isTop)break}return e(t)}}var j=class{constructor(e,t,n,o){this.completion=e,this.source=t,this.match=n,this.score=o}};function E(i){return i.selection.main.from}function Le(i,e){var t;let{source:n}=i,o=e&&n[0]!="^",s=n[n.length-1]!="$";return!o&&!s?i:new RegExp(`${o?"^":""}(?:${n})${s?"$":""}`,(t=i.flags)!==null&&t!==void 0?t:i.ignoreCase?"i":"")}var he=je.define();function et(i,e,t,n){let{main:o}=i.selection,s=t-o.from,l=n-o.from;return{...i.changeByRange(a=>{if(a!=o&&t!=n&&i.sliceDoc(a.from+s,a.from+l)!=i.sliceDoc(t,n))return{range:a};let r=i.toText(e);return{changes:{from:a.from+s,to:n==o.from?a.to:a.from+l,insert:r},range:v.cursor(a.from+s+r.length)}}),scrollIntoView:!0,userEvent:"input.complete"}}var ye=new WeakMap;function tt(i){if(!Array.isArray(i))return i;let e=ye.get(i);return e||ye.set(i,e=_e(i)),e}var H=O.define(),M=O.define(),ee=class{constructor(e){this.pattern=e,this.chars=[],this.folded=[],this.any=[],this.precise=[],this.byWord=[],this.score=0,this.matched=[];for(let t=0;t<e.length;){let n=w(e,t),o=T(n);this.chars.push(n);let s=e.slice(t,t+o),l=s.toUpperCase();this.folded.push(w(l==s?s.toLowerCase():l,0)),t+=o}this.astral=e.length!=this.chars.length}ret(e,t){return this.score=e,this.matched=t,this}match(e){if(this.pattern.length==0)return this.ret(-100,[]);if(e.length<this.pattern.length)return null;let{chars:t,folded:n,any:o,precise:s,byWord:l}=this;if(t.length==1){let d=w(e,0),R=T(d),C=R==e.length?0:-100;if(d!=t[0])if(d==n[0])C+=-200;else return null;return this.ret(C,[0,R])}let a=e.indexOf(this.pattern);if(a==0)return this.ret(e.length==this.pattern.length?0:-100,[0,this.pattern.length]);let r=t.length,h=0;if(a<0){for(let d=0,R=Math.min(e.length,200);d<R&&h<r;){let C=w(e,d);(C==t[h]||C==n[h])&&(o[h++]=d),d+=T(C)}if(h<r)return null}let c=0,f=0,u=!1,p=0,m=-1,b=-1,k=/[a-z]/.test(e),I=!0;for(let d=0,R=Math.min(e.length,200),C=0;d<R&&f<r;){let x=w(e,d);a<0&&(c<r&&x==t[c]&&(s[c++]=d),p<r&&(x==t[p]||x==n[p]?(p==0&&(m=d),b=d+1,p++):p=0));let W,Y=x<255?x>=48&&x<=57||x>=97&&x<=122?2:x>=65&&x<=90?1:0:(W=Ee(x))!=W.toLowerCase()?1:W!=W.toUpperCase()?2:0;(!d||Y==1&&k||C==0&&Y!=0)&&(t[f]==x||n[f]==x&&(u=!0)?l[f++]=d:l.length&&(I=!1)),C=Y,d+=T(x)}return f==r&&l[0]==0&&I?this.result(-100+(u?-200:0),l,e):p==r&&m==0?this.ret(-200-e.length+(b==e.length?0:-100),[0,b]):a>-1?this.ret(-700-e.length,[a,a+this.pattern.length]):p==r?this.ret(-900-e.length,[m,b]):f==r?this.result(-100+(u?-200:0)+-700+(I?0:-1100),l,e):t.length==2?null:this.result((o[0]?-700:0)+-200+-1100,o,e)}result(e,t,n){let o=[],s=0;for(let l of t){let a=l+(this.astral?T(w(n,l)):1);s&&o[s-1]==l?o[s-1]=a:(o[s++]=l,o[s++]=a)}return this.ret(e-n.length,o)}},te=class{constructor(e){this.pattern=e,this.matched=[],this.score=0,this.folded=e.toLowerCase()}match(e){if(e.length<this.pattern.length)return null;let t=e.slice(0,this.pattern.length),n=t==this.pattern?0:t.toLowerCase()==this.folded?-20Line truncated
//# sourceMappingURL=autocomplete.bundle.mjs.map
+3
View File
@@ -0,0 +1,3 @@
/* esm.sh - codemirror@6.0.2 */
import{lineNumbers as p,highlightActiveLineGutter as c,highlightSpecialChars as t,drawSelection as e,dropCursor as h,rectangularSelection as n,crosshairCursor as s,highlightActiveLine as f,keymap as i}from"./cm-view.mjs";import{EditorView as j}from"./cm-view.mjs";import{EditorState as u}from"./cm-state.mjs";import{foldGutter as g,indentOnInput as y,syntaxHighlighting as o,defaultHighlightStyle as r,bracketMatching as d,foldKeymap as S}from"./cm-language.mjs";import{history as a,defaultKeymap as l,historyKeymap as m}from"./cm-commands.mjs";import{highlightSelectionMatches as K,searchKeymap as k}from"./cm-search.mjs";import{closeBrackets as b,autocompletion as w,closeBracketsKeymap as x,completionKeymap as C}from"./cm-autocomplete.mjs";import{lintKeymap as M}from"./cm-lint.mjs";var I=[p(),c(),t(),a(),g(),e(),h(),u.allowMultipleSelections.of(!0),y(),o(r,{fallback:!0}),d(),b(),w(),n(),s(),f(),K(),i.of([...x,...l,...k,...m,...S,...C,...M])],N=[t(),a(),e(),o(r,{fallback:!0}),i.of([...l,...m])];export{j as EditorView,I as basicSetup,N as minimalSetup};
//# sourceMappingURL=codemirror.bundle.mjs.map
+3
View File
@@ -0,0 +1,3 @@
/* esm.sh - @codemirror/commands@6.11.1 */
import{Annotation as ke,Facet as Se,combineConfig as nt,StateField as rt,Transaction as K,ChangeSet as ot,ChangeDesc as lt,EditorSelection as h,StateEffect as Ae,Text as Be,findClusterBreak as T,countColumn as ee,CharCategory as C}from"./cm-state.mjs";import{EditorView as O,Direction as ct}from"./cm-view.mjs";import{IndentContext as Ce,getIndentation as De,indentString as G,matchBrackets as E,syntaxTree as te,getIndentUnit as Q,indentUnit as st}from"./cm-language.mjs";import{NodeProp as X}from"./lezer-common.mjs";var it=e=>{let{state:t}=e,n=t.doc.lineAt(t.selection.main.from),r=ne(e.state,n.from);return r.line?ut(e):r.block?at(e):!1};function x(e,t){return({state:n,dispatch:r})=>{if(n.readOnly)return!1;let l=e(t,n);return l?(r(n.update(l)),!0):!1}}var ut=x(re,0),An=x(re,1),Bn=x(re,2),ft=x(N,0),Cn=x(N,1),Dn=x(N,2),at=x((e,t)=>N(e,t,dt(t)),0);function ne(e,t){let n=e.languageDataAt("commentTokens",t,1);return n.length?n[0]:{}}var I=50;function ht(e,{open:t,close:n},r,l){let o=e.sliceDoc(r-I,r),c=e.sliceDoc(l,l+I),s=/\s*$/.exec(o)[0].length,i=/^\s*/.exec(c)[0].length,f=o.length-s;if(o.slice(f-t.length,f)==t&&c.slice(i,i+n.length)==n)return{open:{pos:r-s,margin:s&&1},close:{pos:l+i,margin:i&&1}};let u,a;l-r<=2*I?u=a=e.sliceDoc(r,l):(u=e.sliceDoc(r,r+I),a=e.sliceDoc(l-I,l));let d=/^\s*/.exec(u)[0].length,S=/\s*$/.exec(a)[0].length,y=a.length-S-n.length;return u.slice(d,d+t.length)==t&&a.slice(y,y+n.length)==n?{open:{pos:r+d+t.length,margin:/\s/.test(u.charAt(d+t.length))?1:0},close:{pos:l-S-n.length,margin:/\s/.test(a.charAt(y-1))?1:0}}:null}function dt(e){let t=[];for(let n of e.selection.ranges){let r=e.doc.lineAt(n.from),l=n.to<=r.to?r:e.doc.lineAt(n.to);l.from>r.from&&l.from==n.to&&(l=n.to==r.to+1?r:e.doc.lineAt(n.to-1));let o=t.length-1;o>=0&&t[o].to>r.from?t[o].to=l.to:t.push({from:r.from+/^\s*/.exec(r.text)[0].length,to:l.to})}return t}function N(e,t,n=t.selection.ranges){let r=n.map(o=>ne(t,o.from).block);if(!r.every(o=>o))return null;let l=n.map((o,c)=>ht(t,r[c],o.from,o.to));if(e!=2&&!l.every(o=>o))return{changes:t.changes(n.map((o,c)=>l[c]?[]:[{from:o.from,insert:r[c].open+" "},{from:o.to,insert:" "+r[c].close}]))};if(e!=1&&l.some(o=>o)){let o=[];for(let c=0,s;c<l.length;c++)if(s=l[c]){let i=r[c],{open:f,close:u}=s;o.push({from:f.pos-i.open.length,to:f.pos+f.margin},{from:u.pos-u.margin,to:u.pos+i.close.length})}return{changes:o}}return null}function re(e,t,n=t.selection.ranges){let r=[],l=-1;e:for(let{from:o,to:c}of n){let s=r.length,i=1e9,f;for(let u=o;u<=c;){let a=t.doc.lineAt(u);if(f==null&&(f=ne(t,a.from).line,!f))continue e;if(a.from>l&&(o==c||c>a.from)){l=a.from;let d=/^\s*/.exec(a.text)[0].length,S=d==a.length,y=a.text.slice(d,d+f.length)==f?d:-1;d<a.text.length&&d<i&&(i=d),r.push({line:a,comment:y,token:f,indent:d,empty:S,single:!1})}u=a.to+1}if(i<1e9)for(let u=s;u<r.length;u++)r[u].indent<r[u].line.text.length&&(r[u].indent=i);r.length==s+1&&(r[s].single=!0)}if(e!=2&&r.some(o=>o.comment<0&&(!o.empty||o.single))){let o=[];for(let{line:s,token:i,indent:f,empty:u,single:a}of r)(a||!u)&&o.push({from:s.from+f,insert:i+" "});let c=t.changes(o);return{changes:c,selection:t.selection.map(c,1)}}else if(e!=1&&r.some(o=>o.comment>=0)){let o=[];for(let{line:c,comment:s,token:i}of r)if(s>=0){let f=c.from+s,u=f+i.length;c.text[u-c.from]==" "&&u++,o.push({from:f,to:u})}return{changes:o}}return null}var Y=ke.define(),mt=ke.define(),pt=Se.define(),Le=Se.define({combine(e){return nt(e,{minDepth:100,newGroupDelay:500,joinToEvent:(t,n)=>n},{minDepth:Math.max,newGroupDelay:Math.min,joinToEvent:(t,n)=>(r,l)=>t(r,l)||n(r,l)})}}),R=rt.define({create(){return L.empty},update(e,t){let n=t.state.facet(Le),r=t.annotation(Y);if(r){let i=A.fromTransaction(t,r.selection),f=r.side,u=f==0?e.undone:e.done;return i?u=V(u,u.length,n.minDepth,i):u=Te(u,t.startState.selection),new L(f==0?r.rest:u,f==0?u:r.rest)}let l=t.annotation(mt);if((l=="full"||l=="before")&&(e=e.isolate()),t.annotation(K.addToHistory)===!1)return t.changes.empty?e:e.addMapping(t.changes.desc);let o=A.fromTransaction(t),c=t.annotation(K.time),s=t.annotation(K.userEvent);return o?e=e.addChanges(o,c,s,n,t):t.selection&&(e=e.addSelection(t.startState.selection,c,s,n.newGroupDelay)),(l=="full"||l=="after")&&(e=e.isolate()),e},toJSON(e){return{done:e.done.map(t=>t.toJSON()),undone:e.undone.map(t=>t.toJSON())}},fromJSON(e){return new L(e.done.map(A.fromJSON),e.undone.map(A.fromJSON))}});function Ln(e={}){return[R,Le.of(e),O.domEventHandlers({beforeinput(t,n){let r=t.inputType=="historyUndo"?xe:t.inputType=="historyRedo"?Z:null;return r?(t.preventDefault(),r(n)):!1}})]}var xn=R;function P(e,t){return function({state:n,dispatch:r}){if(!t&&n.readOnly)return!1;let l=n.field(R,!1);if(!l)return!1;let o=l.pop(e,n,t);return o?(r(o),!0):!1}}var xe=P(0,!1),Z=P(1,!1),gt=P(0,!0),yt=P(1,!0);function Me(e){return function(t){let n=t.field(R,!1);if(!n)return 0;let r=e==0?n.done:n.undone;return r.length-(r.length&&!r[0].changes?1:0)}}var Mn=Me(0),bn=Me(1);function be(e){return fLine truncated
//# sourceMappingURL=commands.bundle.mjs.map
+3
View File
@@ -0,0 +1,3 @@
/* esm.sh - @codemirror/lang-css@6.3.1 */
import{ExternalTokenizer as c,LRParser as x,LocalTokenGroup as Y}from"./lezer-lr.mjs";import{styleTags as R,tags as t}from"./lezer-highlight.mjs";var W=148,S=1,q=149,Z=150,h=2,G=151,T=3,_=4,F=152,P=[9,10,11,12,13,32,133,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288],E=58,U=40,f=95,N=91,d=45,C=46,V=35,D=37,I=38,B=92,A=10,K=42;function Q(e){return e>=65&&e<=90||e>=97&&e<=122||e>=161}function m(e){return e>=48&&e<=57}function g(e){return m(e)||e>=97&&e<=102||e>=65&&e<=70}var X=(e,a,i)=>(O,r)=>{for(let o=!1,l=0,n=0;;n++){let{next:s}=O;if(Q(s)||s==d||s==f||o&&m(s))!o&&(s!=d||n>0)&&(o=!0),l===n&&s==d&&l++,O.advance();else if(s==B&&O.peek(1)!=A){if(O.advance(),g(O.next)){do O.advance();while(g(O.next));O.next==32&&O.advance()}else O.next>-1&&O.advance();o=!0}else{o&&O.acceptToken(l>=2&&r.canShift(h)?a:s==U?i:e);break}}},L=new c(X(q,h,Z),{contextual:!0}),J=new c(X(G,T,_),{contextual:!0}),H=new c(e=>{if(P.includes(e.peek(-1))){let{next:a}=e;(Q(a)||a==f||a==V||a==C||a==K||a==N||a==E&&Q(e.peek(1))||a==d||a==I)&&e.acceptToken(W)}}),M=new c(e=>{if(!P.includes(e.peek(-1))){let{next:a}=e;if(a==D&&(e.advance(),e.acceptToken(S)),Q(a)){do e.advance();while(Q(e.next)||m(e.next));e.acceptToken(S)}}});function j(e){let a=e.length;return(a==4||a==5||a==7||a==9)&&/^#[a-f\d]+$/i.test(e)?F:-1}var ee=R({"AtKeyword import charset namespace keyframes media supports font-feature-values":t.definitionKeyword,"from to selector scope MatchFlag":t.keyword,NamespaceName:t.namespace,KeyframeName:t.labelName,KeyframeRangeName:t.operatorKeyword,TagName:t.tagName,ClassName:t.className,PseudoClassName:t.constant(t.className),IdName:t.labelName,"FeatureName PropertyName":t.propertyName,AttributeName:t.attributeName,NumberLiteral:t.number,KeywordQuery:t.keyword,UnaryQueryOp:t.operatorKeyword,"CallTag ValueName FontName":t.atom,VariableName:t.variableName,Callee:t.operatorKeyword,Unit:t.unit,"UniversalSelector NestingSelector":t.definitionOperator,"MatchOp CompareOp":t.compareOperator,"ChildOp SiblingOp, LogicOp":t.logicOperator,BinOp:t.arithmeticOperator,Important:t.modifier,Comment:t.blockComment,ColorLiteral:t.color,"ParenthesizedContent StringLiteral":t.string,":":t.punctuation,PseudoOp:t.derefOperator,"; , |":t.separator,"( )":t.paren,"[ ]":t.squareBracket,"{ }":t.brace}),Oe={__proto__:null,lang:44,"nth-child":44,"nth-last-child":44,"nth-of-type":44,"nth-last-of-type":44,dir:44,"host-context":44,if:88,url:158,"url-prefix":158,domain:158,regexp:158},ae={__proto__:null,or:102,and:102,not:112,only:112,layer:212},re={__proto__:null,selector:118,style:124,layer:208},te={__proto__:null,"@import":204,"@media":216,"@charset":220,"@namespace":224,"@keyframes":230,"@supports":242,"@scope":246,"@font-feature-values":252},oe={__proto__:null,to:249},y=x.deserialize({version:14,states:"MrQYQdOOO$TQdOOP$[O`OOO%XQaO'#CfOOQP'#Ce'#CeO%`QdO'#CgO%eQ`O'#CgO%jQaO'#FrO&eQdO'#CkO'XQaO'#CcO'cQdO'#CnOOQP'#ES'#ESOOQP'#ER'#ERO'nQdO'#ETO'yQdO'#E[O'yQdO'#E_OOQP'#Fr'#FrO)`QhO'#FQOOQS'#Fq'#FqOOQS'#FT'#FTQYQdOOO)gQdO'#EeO*vQhO'#EkO)gQdO'#EmO*}QdO'#EoO+YQdO'#ErO*[QhO'#ExO+bQdO'#EzO+mQdO'#E}O+rQaO'#CfO+yQ`O'#EbO,OQ`O'#GPO,ZQdO'#GPQOQ`OOP,eO&jO'#CaPOOO)CAa)CAaOOQP'#Ci'#CiOOQP,59R,59RO%`QdO,59ROOQP'#Cm'#CmOOQP,59V,59VO&eQdO,59VO,pQdO,59YOOQP,5:m,5:mO'nQdO,5:oO'yQdO,5:vO'yQdO,5:xO'yQdO,5:yO'yQdO'#F[O,{Q`O,58}O-TQdO'#EaOOQS,58},58}OOQP'#Cq'#CqOOQO'#EP'#EPOOQP,59Y,59YO-[Q`O,59YO-aQ`O,59YO-fQpO'#EUO-qQdO'#EVO-vQ`O'#EVO-{QpO,5:oO.iQaO,5:vO/PQaO,5:yOOQW'#D]'#D]O0OQhO'#DgO0cQhO,5;lO*[QhO'#DeO0pQ`O'#DnO0uQhO'#D{OOQW'#Fx'#FxOOQS,5;l,5;lO0zQ`O'#DhO1PQ`O'#DkOOQS-E9R-E9ROOQ['#Cv'#CvO1UQdO'#CwO1iQdO'#C|O1|QdO'#DPOOQ['#DQ'#DQO2aQ!pO'#DRO4jQ!jO,5;POOQO'#DW'#DWO-aQ`O'#DVO4zQ!nO'#FuO6}Q`O'#DXO7SQ`O'#D|OOQ['#Fu'#FuO7XQhO'#GSO7gQ`O,5;VO7lQ!bO,5;XOOQS'#Eq'#EqO7tQ`O,5;ZO7yQdO,5;ZOOQO'#Et'#EtO8RQ`O,5;^O8WQhO,5;dO'yQdO'#DjOOQS,5;f,5;fO0zQ`O,5;fO8`QdO,5;fOOQS'#Fc'#FcO8hQdO'#FPO7gQ`O,5;iO8pQdO,5:|O9QQdO'#F^O9_Q`O,5<kO9_Q`O,5<kPOOO'#FS'#FSP9jO&jO,58{POOO,58{,58{OOQP1G.m1G.mOOQP1G.q1G.qOOQP1G.t1G.tO-[Q`O1G.tO-aQ`O1G.tO9uQpO1G0ZO9}QaO1G0bO:eQaO1G0dO:{QaO1G0eO;cQaO,5;vOOQO-E9Y-E9YOOQS1G.i1G.iO;mQ`O,5:{O;rQdO'#EQO;yQdO'#CuOOQO'#EX'#EXOOQO,5:q,5:qO-qQdO,5:qOOQP1G0Z1G0ZO)gQdO1G0ZO<QQ!jO'#D]O<`Q!bO,59xO<hQhO,5:ROOQO'#Dc'#DcOOQO'#Fy'#FyO<cQ!bO,59|O<pQhO'#FdO*[QhO,59zO*[QhO'#FdO=hQhO1G1WOOQS1G1W1G1WO=rQhO,5:PO>mQhO'#DoOOQW,5:Y,5:YOOQW,5:g,5:gOOQW,5:S,5:SO>wQhO,5:VO?cQ!fO'#FvOOQS'#Fv'#FvOOQS'#FV'#FVO@pQdO,59cOOQ[,59c,59cOATQdO,59hOOQ[,59h,59hOAhQdO,59kOOQ[,59k,59kOOQ[,59m,59mO)gQdO,59oOA{QhO'#EgOOQW'#Eg'#EgOBjQ`O1G0kO4sQhO1G0kOOQ[,59q,59qO*[QhO'#DZOOQ[,59s,59sOBoQ#tO,5:hOBzQhO'#F`OCXQ`O,5<nOOQS1G0q1G0qOOQS1G0s1G0sOOQS1G0u1G0uOCdQ`O1G0uOCiQdO'#EuOOQS1G0x1G0xOOQS1G1O1G1OOCtQaO,5:UO7gQ`O1G1QOOQS1G1Q1G1QO0zQ`O1G1QOOQS-E9a-E9aOOQS1G1T1G1TOC{Q!fO1G0hODcQ`O'#EdOOQO1G0h1G0hOOQO,5;x,5;xODhQdO,5;xOOQO-E9[-E9[ODuQ`O1G2VPOOO-E9Q-E9QPOOO1G.g1G.gOOQP7+$`7+$`OOQP7+%u7+%uO)gQdO7+%uOOQS1G0g1G0gOEQQaO'#F}OE[Q`O,5:lOEaQ!fO'#FUOF_QdO'#FtOFiQ`O,59aOOQO1G0]1G0]OFnQ!bO7+%uO)gQdO1G/dOFyQhO1G/hOO Line truncated
//# sourceMappingURL=lang-css.bundle.mjs.map
+3
View File
@@ -0,0 +1,3 @@
/* esm.sh - @codemirror/lang-html@6.4.12 */
import{ContextTracker as be,ExternalTokenizer as w,LRParser as he}from"./lezer-lr.mjs";import{styleTags as Te,tags as S}from"./lezer-highlight.mjs";import{parseMixed as Pe}from"./lezer-common.mjs";var xe=55,Ve=1,ye=56,we=2,ve=57,Xe=3,z=4,$e=5,C=6,L=7,F=8,H=9,K=10,qe=11,_e=12,Ce=13,X=58,ke=14,Qe=15,D=59,J=21,Ae=23,ee=24,Ye=25,q=27,te=28,Me=29,Re=32,Ee=35,Ze=37,Be=38,ze=0,De=1,We={area:!0,base:!0,br:!0,col:!0,command:!0,embed:!0,frame:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0,menuitem:!0},Ne={dd:!0,li:!0,optgroup:!0,option:!0,p:!0,rp:!0,rt:!0,tbody:!0,td:!0,tfoot:!0,th:!0,tr:!0},W={dd:{dd:!0,dt:!0},dt:{dd:!0,dt:!0},li:{li:!0},option:{option:!0,optgroup:!0},optgroup:{optgroup:!0},p:{address:!0,article:!0,aside:!0,blockquote:!0,dir:!0,div:!0,dl:!0,fieldset:!0,footer:!0,form:!0,h1:!0,h2:!0,h3:!0,h4:!0,h5:!0,h6:!0,header:!0,hgroup:!0,hr:!0,menu:!0,nav:!0,ol:!0,p:!0,pre:!0,section:!0,table:!0,ul:!0},rp:{rp:!0,rt:!0},rt:{rp:!0,rt:!0},tbody:{tbody:!0,tfoot:!0},td:{td:!0,th:!0},tfoot:{tbody:!0},th:{td:!0,th:!0},thead:{tbody:!0,tfoot:!0},tr:{tr:!0}};function Ge(e){return e==45||e==46||e==58||e>=65&&e<=90||e==95||e>=97&&e<=122||e>=161}var N=null,G=null,I=0;function _(e,a){let r=e.pos+a;if(I==r&&G==e)return N;let l=e.peek(a),t="";for(;Ge(l);)t+=String.fromCharCode(l),l=e.peek(++a);return G=e,I=r,N=t?t.toLowerCase():l==Ie||l==je?void 0:null}var ae=60,y=62,k=47,Ie=63,je=33,Ue=45;function j(e,a){this.name=e,this.parent=a}var Le=[C,K,L,F,H],Fe=new be({start:null,shift(e,a,r,l){return Le.indexOf(a)>-1?new j(_(l,1)||"",e):e},reduce(e,a){return a==J&&e?e.parent:e},reuse(e,a,r,l){let t=a.type.id;return t==C||t==Ze?new j(_(l,1)||"",e):e},strict:!1}),He=new w((e,a)=>{if(e.next!=ae){e.next<0&&a.context&&e.acceptToken(X);return}e.advance();let r=e.next==k;r&&e.advance();let l=_(e,0);if(l===void 0)return;if(!l)return e.acceptToken(r?Qe:ke);let t=a.context?a.context.name:null;if(r){if(l==t)return e.acceptToken(qe);if(t&&Ne[t])return e.acceptToken(X,-2);if(a.dialectEnabled(ze))return e.acceptToken(_e);for(let s=a.context;s;s=s.parent)if(s.name==l)return;e.acceptToken(Ce)}else{if(l=="script")return e.acceptToken(L);if(l=="style")return e.acceptToken(F);if(l=="textarea")return e.acceptToken(H);if(We.hasOwnProperty(l))return e.acceptToken(K);t&&W[t]&&W[t][l]?e.acceptToken(X,-1):e.acceptToken(C)}},{contextual:!0}),Ke=new w(e=>{for(let a=0,r=0;;r++){if(e.next<0){r&&e.acceptToken(D);break}if(e.next==Ue)a++;else if(e.next==y&&a>=2){r>=3&&e.acceptToken(D,-2);break}else a=0;e.advance()}});function Je(e){for(;e;e=e.parent)if(e.name=="svg"||e.name=="math")return!0;return!1}var et=new w((e,a)=>{if(e.next==k&&e.peek(1)==y){let r=a.dialectEnabled(De)||Je(a.context);e.acceptToken(r?$e:z,2)}else e.next==y&&e.acceptToken(z,1)});function Q(e,a,r){let l=2+e.length;return new w(t=>{for(let s=0,o=0,i=0;;i++){if(t.next<0){i&&t.acceptToken(a);break}if(s==0&&t.next==ae||s==1&&t.next==k||s>=2&&s<l&&t.next==e.charCodeAt(s-2))s++,o++;else if(s==l&&t.next==y){i>o?t.acceptToken(a,-o):t.acceptToken(r,-(o-2));break}else if((t.next==10||t.next==13)&&i){t.acceptToken(a,1);break}else s=o=0;t.advance()}})}var tt=Q("script",xe,Ve),at=Q("style",ye,we),lt=Q("textarea",ve,Xe),rt=Te({"Text RawText IncompleteTag IncompleteCloseTag":S.content,"StartTag StartCloseTag SelfClosingEndTag EndTag":S.angleBracket,TagName:S.tagName,"MismatchedCloseTag/TagName":[S.tagName,S.invalid],AttributeName:S.attributeName,"AttributeValue UnquotedAttributeValue":S.attributeValue,Is:S.definitionOperator,"EntityReference CharacterReference":S.character,Comment:S.blockComment,ProcessingInst:S.processingInstruction,DoctypeDecl:S.documentMeta}),le=he.deserialize({version:14,states:",xOVO!rOOO!ZQ#tO'#CrO!`Q#tO'#C{O!eQ#tO'#DOO!jQ#tO'#DRO!oQ#tO'#DTO!tOaO'#CqO#PObO'#CqO#[OdO'#CqO$kO!rO'#CqOOO`'#Cq'#CqO$rO$fO'#DUO$zQ#tO'#DWO%PQ#tO'#DXOOO`'#Dl'#DlOOO`'#DZ'#DZQVO!rOOO%UQ&rO,59^O%aQ&rO,59gO%lQ&rO,59jO%wQ&rO,59mO&SQ&rO,59oOOOa'#D_'#D_O&_OaO'#CyO&jOaO,59]OOOb'#D`'#D`O&rObO'#C|O&}ObO,59]OOOd'#Da'#DaO'VOdO'#DPO'bOdO,59]OOO`'#Db'#DbO'jO!rO,59]O'qQ#tO'#DSOOO`,59],59]OOOp'#Dc'#DcO'vO$fO,59pOOO`,59p,59pO(OQ#|O,59rO(TQ#|O,59sOOO`-E7X-E7XO(YQ&rO'#CtOOQW'#D['#D[O(hQ&rO1G.xOOOa1G.x1G.xOOO`1G/Z1G/ZO(sQ&rO1G/ROOOb1G/R1G/RO)OQ&rO1G/UOOOd1G/U1G/UO)ZQ&rO1G/XOOO`1G/X1G/XO)fQ&rO1G/ZOOOa-E7]-E7]O)qQ#tO'#CzOOO`1G.w1G.wOOOb-E7^-E7^O)vQ#tO'#C}OOOd-E7_-E7_O){Q#tO'#DQOOO`-E7`-E7`O*QQ#|O,59nOOOp-E7a-E7aOOO`1G/[1G/[OOO`1G/^1G/^OOO`1G/_1G/_O*VQ,UO,59`OOQW-E7Y-E7YOOOa7+$d7+$dOOO`7+$u7+$uOOOb7+$m7+$mOOOd7+$p7+$pOOO`7+$s7+$sO*bQ#|O,59fO*gQ#|O,59iO*lQ#|O,59lOOO`1G/Y1G/YO*qO7[O'#CwO+SOMhO'#CwOOQW1G.z1G.zOOO`1G/Q1G/QOOO`1G/T1G/TOOO`1G/W1G/WOOOO'#D]'#D]O+eO7[O,59cOOQW,59c,59cOOOO'#D^'#D^O+vOMhO,59cOOOO-E7Z-E7ZOOQW1G.}1G.}OOOO-E7[-E7[",stateData:",c~O!_OS~OUSOVPOWQOXROYTO[]O][O^^O_^Oa^Ob^Oc^Od^Oy^O|_O!eZO~OgaO~OgbO~OgcO~OgdO~OgeO~O!XfOPmP![mP~O!YiOQpP![pP~O!ZlORsP![sP~OUSOVPOWQOXROYTOZqO[]O][O^^O_^Oa^Ob^Oc^Od^Oy^O!eZO~O![rO~P#gO!]sO!fuO~OgvO~OgwO~OS|OT}OiyO~OS!POT}OiyO~OS!ROT}OiyO~OS!TOT}OiyO~OS}OT}Line truncated
//# sourceMappingURL=lang-html.bundle.mjs.map
+15
View File
@@ -0,0 +1,15 @@
/* esm.sh - @codemirror/lang-javascript@6.2.5 */
import{ContextTracker as E,ExternalTokenizer as S,LRParser as A,LocalTokenGroup as k}from"./lezer-lr.mjs";import{styleTags as C,tags as e}from"./lezer-highlight.mjs";var I=317,J=318,w=1,D=2,N=3,K=4,B=319,F=321,H=322,OO=5,aO=6,QO=0,m=[9,10,11,12,13,32,133,160,5760,8192,8193,8194,8195,8196,8197,8198,8199,8200,8201,8202,8232,8233,8239,8287,12288],v=125,rO=59,u=47,eO=42,iO=43,$O=45,tO=60,nO=44,lO=63,ZO=46,oO=91,PO=new E({start:!1,shift(O,a){return a==OO||a==aO||a==F?O:a==H},strict:!1}),sO=new S((O,a)=>{let{next:Q}=O;(Q==v||Q==-1||a.context)&&O.acceptToken(B)},{contextual:!0,fallback:!0}),pO=new S((O,a)=>{let{next:Q}=O,r;m.indexOf(Q)>-1||Q==u&&((r=O.peek(1))==u||r==eO)||Q!=v&&Q!=rO&&Q!=-1&&!a.context&&O.acceptToken(I)},{contextual:!0}),XO=new S((O,a)=>{O.next==oO&&!a.context&&O.acceptToken(J)},{contextual:!0}),SO=new S((O,a)=>{let{next:Q}=O;if(Q==iO||Q==$O){if(O.advance(),Q==O.next){O.advance();let r=!a.context&&a.canShift(w);O.acceptToken(r?w:D)}}else Q==lO&&O.peek(1)==ZO&&(O.advance(),O.advance(),(O.next<48||O.next>57)&&O.acceptToken(N))},{contextual:!0});function f(O,a){return O>=65&&O<=90||O>=97&&O<=122||O==95||O>=192||!a&&O>=48&&O<=57}var cO=new S((O,a)=>{if(O.next!=tO||!a.dialectEnabled(QO)||(O.advance(),O.next==u))return;let Q=0;for(;m.indexOf(O.next)>-1;)O.advance(),Q++;if(f(O.next,!0)){for(O.advance(),Q++;f(O.next,!1);)O.advance(),Q++;for(;m.indexOf(O.next)>-1;)O.advance(),Q++;if(O.next==nO)return;for(let r=0;;r++){if(r==7){if(!f(O.next,!0))return;break}if(O.next!="extends".charCodeAt(r))break;O.advance(),Q++}}O.acceptToken(K,-Q)}),dO=C({"get set async static":e.modifier,"for while do if else switch try catch finally return throw break continue default case defer":e.controlKeyword,"in of await yield void typeof delete instanceof as satisfies":e.operatorKeyword,"let var const using function class extends":e.definitionKeyword,"import export from":e.moduleKeyword,"with debugger new":e.keyword,TemplateString:e.special(e.string),super:e.atom,BooleanLiteral:e.bool,this:e.self,null:e.null,Star:e.modifier,VariableName:e.variableName,"CallExpression/VariableName TaggedTemplateExpression/VariableName":e.function(e.variableName),VariableDefinition:e.definition(e.variableName),Label:e.labelName,PropertyName:e.propertyName,PrivatePropertyName:e.special(e.propertyName),"CallExpression/MemberExpression/PropertyName":e.function(e.propertyName),"FunctionDeclaration/VariableDefinition":e.function(e.definition(e.variableName)),"ClassDeclaration/VariableDefinition":e.definition(e.className),"NewExpression/VariableName":e.className,PropertyDefinition:e.definition(e.propertyName),PrivatePropertyDefinition:e.definition(e.special(e.propertyName)),UpdateOp:e.updateOperator,"LineComment Hashbang":e.lineComment,BlockComment:e.blockComment,Number:e.number,String:e.string,Escape:e.escape,ArithOp:e.arithmeticOperator,LogicOp:e.logicOperator,BitOp:e.bitwiseOperator,CompareOp:e.compareOperator,RegExp:e.regexp,Equals:e.definitionOperator,Arrow:e.function(e.punctuation),": Spread":e.punctuation,"( )":e.paren,"[ ]":e.squareBracket,"{ }":e.brace,"InterpolationStart InterpolationEnd":e.special(e.brace),".":e.derefOperator,", ;":e.separator,"@":e.meta,TypeName:e.typeName,TypeDefinition:e.definition(e.typeName),"type enum interface implements namespace module declare":e.definitionKeyword,"abstract global Privacy readonly override":e.modifier,"is keyof unique infer asserts":e.operatorKeyword,JSXAttributeValue:e.attributeValue,JSXText:e.content,"JSXStartTag JSXStartCloseTag JSXSelfCloseEndTag JSXEndTag":e.angleBracket,"JSXIdentifier JSXNameSpacedName":e.tagName,"JSXAttribute/JSXIdentifier JSXAttribute/JSXNameSpacedName":e.attributeName,"JSXBuiltin/JSXIdentifier":e.standard(e.tagName)}),gO={__proto__:null,export:20,as:25,from:33,default:36,async:41,function:42,in:52,out:55,const:56,extends:60,this:64,true:72,false:72,null:84,void:88,typeof:92,super:108,new:142,delete:154,yield:163,await:167,class:172,public:237,private:237,protected:237,readonly:239,instanceof:258,satisfies:261,import:294,keyof:351,unique:355,infer:361,asserts:397,is:399,abstract:419,implements:421,type:423,let:426,var:428,using:431,interface:437,enum:441,namespace:447,module:449,declare:453,global:457,defer:473,for:478,of:487,while:490,with:494,do:498,if:502,else:504,switch:508,case:514,try:520,catch:524,finally:528,return:532,throw:536,break:540,continue:544,debugger:548},bO={__proto__:null,async:129,get:131,set:133,declare:195,public:197,private:197,protected:197,static:199,abstract:201,override:203,readonly:209,accessor:211,new:403},fO={__proto__:null,"<":193},y=A.deserialize({version:14,states:"$GSQ%TQlOOO%[QlOOO'_QpOOP(lO`OOO*zQ!0MxO'#CiO+RO#tO'#CjO+aO&jO'#CjO+oO#@ItO'#DaO.QQlO'#DgO.bQlO'#DrO%[QlO'#DzO0fQlO'#ESOOQ!0Lf'#E['#E[O1PQ`O'#EXOOQO'#Ep'#EpOOQO'#Im'#ImO1XQ`O'#GtO1dQ`O'#EoO1iQ`O'#EoO3hQ!0MxO'#JsO6[Q!0MxO'#JtO6uQ`O'#F^O6zQ,UO'#FuOOQ!0Lf'#Fg'#FgO7VO7dO'#FgO9XQMhO'#F}O9`Q`O'#F|OOQ!0Lf'#Jt'#JtOOQ!0Lb'#Js'#JsO9eQ`O'#GxOOQ['#K`'#K`O9pQ`O'#IZO9uQ!0LrO'#I[OOQ['#Ja'#JaOOQ[' Line truncated
\${}
} catch (\${error}) {
\${}
}`,{label:"try",detail:"/ catch block",type:"keyword"}),Z("if (${}) {\n ${}\n}",{label:"if",detail:"block",type:"keyword"}),Z(`if (\${}) {
\${}
} else {
\${}
}`,{label:"if",detail:"/ else block",type:"keyword"}),Z(`class \${name} {
constructor(\${params}) {
\${}
}
}`,{label:"class",detail:"definition",type:"keyword"}),Z('import {${names}} from "${module}"\n${}',{label:"import",detail:"named",type:"keyword"}),Z('import ${name} from "${module}"\n${}',{label:"import",detail:"default",type:"keyword"})],WO=G.concat([Z("interface ${name} {\n ${}\n}",{label:"interface",detail:"definition",type:"keyword"}),Z("type ${name} = ${type}",{label:"type",detail:"definition",type:"keyword"}),Z("enum ${name} {\n ${}\n}",{label:"enum",detail:"definition",type:"keyword"})]),q=new RO,z=new Set(["Script","Block","FunctionExpression","FunctionDeclaration","ArrowFunction","MethodDeclaration","ForStatement"]);function c(O){return(a,Q)=>{let r=a.node.getChild("VariableDefinition");return r&&Q(r,O),!0}}var GO=["FunctionDeclaration"],zO={FunctionDeclaration:c("function"),ClassDeclaration:c("class"),ClassExpression:()=>!0,EnumDeclaration:c("constant"),TypeAliasDeclaration:c("type"),NamespaceDeclaration:c("namespace"),VariableDefinition(O,a){O.matchContext(GO)||a(O,"variable")},TypeDefinition(O,a){a(O,"type")},__proto__:null};function T(O,a){let Q=q.get(a);if(Q)return Q;let r=[],i=!0;function t($,n){let o=O.sliceString($.from,$.to);r.push({label:o,type:n})}return a.cursor(UO.IncludeAnonymous).iterate($=>{if(i)i=!1;else if($.name){let n=zO[$.name];if(n&&n($,t)||z.has($.name))return!1}else if($.to-$.from>8192){for(let n of T(O,$.node))r.push(n);return!1}}),q.set(a,r),r}var g=/^[\w$\xa1-\uffff][\w$\d\xa1-\uffff]*$/,h=["TemplateString","String","RegExp","LineComment","BlockComment","VariableDefinition","TypeDefinition","Label","PropertyDefinition","PropertyName","PrivatePropertyDefinition","PrivatePropertyName","JSXText","JSXAttributeValue","JSXOpenTag","JSXCloseTag","JSXSelfClosingTag",".","?."];function TO(O){let a=_(O.state).resolveInner(O.pos,-1);if(h.indexOf(a.name)>-1)return null;let Q=a.name=="VariableName"||a.to-a.from<20&&g.test(O.state.sliceDoc(a.from,a.to));if(!Q&&!O.explicit)return null;let r=[];for(let i=a;i;i=i.parent)z.has(i.name)&&(r=r.concat(T(O.state.doc,i)));return{options:r,from:Q?a.from:O.pos,validFor:g}}function x(O,a,Q){var r;let i=[];for(;;){let t=a.firstChild,$;if(t?.name=="VariableName")return i.push(O(t)),{path:i.reverse(),name:Q};if(t?.name=="MemberExpression"&&((r=$=t.lastChild)===null||r===void 0?void 0:r.name)=="PropertyName")i.push(O($)),a=t;else return null}}function VO(O){let a=r=>O.state.doc.sliceString(r.from,r.to),Q=_(O.state).resolveInner(O.pos,-1);return Q.name=="PropertyName"?x(a,Q.parent,a(Q)):(Q.name=="."||Q.name=="?.")&&Q.parent.name=="MemberExpression"?x(a,Q.parent,""):h.indexOf(Q.name)>-1?null:Q.name=="VariableName"||Q.to-Q.from<20&&g.test(a(Q))?{path:[],name:a(Q)}:Q.name=="MemberExpression"?x(a,Q,""):O.explicit?{path:[],name:""}:null}function LO(O,a){let Q=O,r=[],i=new Set;for(let t=0;;t++){for(let n of(Object.getOwnPropertyNames||Object.keys)(O)){if(!/^[a-zA-Z_$\xaa-\uffdc][\w$\xaa-\uffdc]*$/.test(n)||i.has(n))continue;i.add(n);let o;try{o=Q[n]}catch{continue}r.push({label:n,type:typeof o=="function"?/^[A-Z]/.test(n)?"class":a?"function":"method":a?"variable":"property",boost:-t})}let $=Object.getPrototypeOf(O);if(!$)return r;O=$}}function ia(O){let a=new Map;return Q=>{let r=VO(Q);if(!r)return null;let i=O;for(let $ of r.path)if(i=i[$],!i)return null;let t=a.get(i);return t||a.set(i,t=LO(i,!r.path.length)),{from:Q.pos-r.name.length,options:t,validFor:g}}}var p=mO.define({name:"javascript",parser:y.configure({props:[uO.add({IfStatement:Y({except:/^\s*({|else\b)/}),TryStatement:Y({except:/^\s*({|catch\b|finally\b)/}),LabeledStatement:YO,SwitchBody:O=>{let a=O.textAfter,Q=/^\s*\}/.test(a),r=/^\s*(case|default)\b/.test(a);return O.baseIndent+(Q?0:r?1:2)*O.unit},Block:xO({closing:"}"}),ArrowFunction:O=>O.baseIndent+O.unit,"TemplateString BlockComment":()=>null,"Statement Property":Y({except:/^\s*{/}),JSXElement(O){let a=/^\s*<\//.test(O.textAfter);return O.lineIndent(O.node.from)+(a?0:O.unit)},JSXEscape(O){let a=/\s*\}/.test(O.textAfter);return O.lineIndent(O.node.from)+(a?0:O.unit)},"JSXOpenTag JSXSelfClosingTag"(O){return O.column(O.node.from)+O.unit}}),_O.add({"Block ClassBody SwitchBody EnumBody ObjectExpression ArrayExpression ObjectType":hO,BlockComment(O){return{from:O.from+2,to:O.to-2}},JSXElement(O){let a=O.firstChild;if(!a||a.name=="JSXSelfClosingTag")return null;let Q=O.lastChild;return{from:a.to,to:Q.type.isError?O.to:Q.from}},"JSXSelfClosingTag JSXOpenTag"(O){var a;let Q=(a=O.firstChild)===null||a===void 0?void 0:a.nextSibling,r=O.lastChild;return!Q||Q.type.isError?null:{from:Q.to,to:r.type.isError?O.to:r.from}}})]}),languageData:{closeBrackets:{brackets:["(","[","{","'",'"',"`"]},commentTokens:{line:"//",block:{open:"/*",close:"*/"}},indentOnInput:/^\s*(?:case |default:|\{|\}|<\/)$/,wordChars:"$"}}),V={test:O=>/^JSX/.test(O.name),facet:jO({commentTokens:{block:{open:"{/*",close:"*/}"}}})},MO=p.configure({dialect:"ts"},"typescript"),EO=p.configure({dialect:"jsx",props:[W.add(O=>O.isTop?[V]:void 0)]}),AO=p.configure({dialect:"jsx ts",props:[W.ad Line truncated
//# sourceMappingURL=lang-javascript.bundle.mjs.map
+3
View File
@@ -0,0 +1,3 @@
/* esm.sh - @codemirror/lang-json@6.0.2 */
import{LRParser as n}from"./lezer-lr.mjs";import{styleTags as s,tags as r}from"./lezer-highlight.mjs";var P=s({String:r.string,Number:r.number,"True False":r.bool,PropertyName:r.propertyName,Null:r.null,", :":r.separator,"[ ]":r.squareBracket,"{ }":r.brace}),o=n.deserialize({version:14,states:"$bOVQPOOOOQO'#Cb'#CbOnQPO'#CeOvQPO'#ClOOQO'#Cr'#CrQOQPOOOOQO'#Cg'#CgO}QPO'#CfO!SQPO'#CtOOQO,59P,59PO![QPO,59PO!aQPO'#CuOOQO,59W,59WO!iQPO,59WOVQPO,59QOqQPO'#CmO!nQPO,59`OOQO1G.k1G.kOVQPO'#CnO!vQPO,59aOOQO1G.r1G.rOOQO1G.l1G.lOOQO,59X,59XOOQO-E6k-E6kOOQO,59Y,59YOOQO-E6l-E6l",stateData:"#O~OeOS~OQSORSOSSOTSOWQO_ROgPO~OVXOgUO~O^[O~PVO[^O~O]_OVhX~OVaO~O]bO^iX~O^dO~O]_OVha~O]bO^ia~O",goto:"!kjPPPPPPkPPkqwPPPPk{!RPPP!XP!e!hXSOR^bQWQRf_TVQ_Q`WRg`QcZRicQTOQZRQe^RhbRYQR]R",nodeNames:"\u26A0 JsonText True False Null Number String } { Object Property PropertyName : , ] [ Array",maxTerm:25,nodeProps:[["isolate",-2,6,11,""],["openedBy",7,"{",14,"["],["closedBy",8,"}",15,"]"]],propSources:[P],skippedNodes:[0],repeatNodeCount:2,tokenData:"(|~RaXY!WYZ!W]^!Wpq!Wrs!]|}$u}!O$z!Q!R%T!R![&c![!]&t!}#O&y#P#Q'O#Y#Z'T#b#c'r#h#i(Z#o#p(r#q#r(w~!]Oe~~!`Wpq!]qr!]rs!xs#O!]#O#P!}#P;'S!];'S;=`$o<%lO!]~!}Og~~#QXrs!]!P!Q!]#O#P!]#U#V!]#Y#Z!]#b#c!]#f#g!]#h#i!]#i#j#m~#pR!Q![#y!c!i#y#T#Z#y~#|R!Q![$V!c!i$V#T#Z$V~$YR!Q![$c!c!i$c#T#Z$c~$fR!Q![!]!c!i!]#T#Z!]~$rP;=`<%l!]~$zO]~~$}Q!Q!R%T!R![&c~%YRT~!O!P%c!g!h%w#X#Y%w~%fP!Q![%i~%nRT~!Q![%i!g!h%w#X#Y%w~%zR{|&T}!O&T!Q![&Z~&WP!Q![&Z~&`PT~!Q![&Z~&hST~!O!P%c!Q![&c!g!h%w#X#Y%w~&yO[~~'OO_~~'TO^~~'WP#T#U'Z~'^P#`#a'a~'dP#g#h'g~'jP#X#Y'm~'rOR~~'uP#i#j'x~'{P#`#a(O~(RP#`#a(U~(ZOS~~(^P#f#g(a~(dP#i#j(g~(jP#X#Y(m~(rOQ~~(wOW~~(|OV~",tokenizers:[0],topRules:{JsonText:[0,1]},tokenPrec:0});import{LRLanguage as Q,indentNodeProp as i,continuedIndent as a,foldNodeProp as c,foldInside as g,LanguageSupport as p}from"./cm-language.mjs";var h=()=>t=>{try{JSON.parse(t.state.doc.toString())}catch(O){if(!(O instanceof SyntaxError))throw O;let e=m(O,t.state.doc);return[{from:e,message:O.message,severity:"error",to:e}]}return[]};function m(t,O){let e;return(e=t.message.match(/at position (\d+)/))?Math.min(+e[1],O.length):(e=t.message.match(/at line (\d+) column (\d+)/))?Math.min(O.line(+e[1]).from+ +e[2]-1,O.length):0}var l=Q.define({name:"json",parser:o.configure({props:[i.add({Object:a({except:/^\s*\}/}),Array:a({except:/^\s*\]/})}),c.add({"Object Array":g})]}),languageData:{closeBrackets:{brackets:["[","{",'"']},indentOnInput:/^\s*[\}\]]$/}});function S(){return new p(l)}export{S as json,l as jsonLanguage,h as jsonParseLinter};
//# sourceMappingURL=lang-json.bundle.mjs.map
+8
View File
@@ -0,0 +1,8 @@
/* esm.sh - @codemirror/lang-markdown@6.5.2 */
import{EditorSelection as N,countColumn as U,Prec as zt,EditorState as Rt}from"./cm-state.mjs";import{EditorView as Tt,keymap as Dt}from"./cm-view.mjs";import{defineLanguageFacet as Ft,foldNodeProp as ut,indentNodeProp as Xt,languageDataProp as $t,foldService as Ut,syntaxTree as j,Language as jt,LanguageDescription as at,ParseContext as _t,indentUnit as qt,LanguageSupport as lt}from"./cm-language.mjs";import{CompletionContext as Qt}from"./cm-autocomplete.mjs";import{NodeType as J,NodeProp as D,NodeSet as Oe,Tree as x,Parser as bt,IterMode as Ce,parseMixed as wt}from"./lezer-common.mjs";import{styleTags as ze,tags as m,Tag as St}from"./lezer-highlight.mjs";var Q=class t{static create(e,r,n,s,i){let o=s+(s<<8)+e+(r<<4)|0;return new t(e,r,n,o,i,[],[])}constructor(e,r,n,s,i,o,a){this.type=e,this.value=r,this.from=n,this.hash=s,this.end=i,this.children=o,this.positions=a,this.blankFirstLine=!1,this.hashProp=[[D.contextHash,s]]}addChild(e,r){e.prop(D.contextHash)!=this.hash&&(e=new x(e.type,e.children,e.positions,e.length,this.hashProp)),this.children.push(e),this.positions.push(r)}toTree(e,r=this.end){let n=this.children.length-1;return n>=0&&(r=Math.max(r,this.positions[n]+this.children[n].length+this.from)),new x(e.types[this.type],this.children,this.positions,r-this.from).balance({makeTree:(s,i,o)=>new x(J.none,s,i,o,this.hashProp)})}},u;(function(t){t[t.Document=1]="Document",t[t.CodeBlock=2]="CodeBlock",t[t.FencedCode=3]="FencedCode",t[t.Blockquote=4]="Blockquote",t[t.HorizontalRule=5]="HorizontalRule",t[t.BulletList=6]="BulletList",t[t.OrderedList=7]="OrderedList",t[t.ListItem=8]="ListItem",t[t.ATXHeading1=9]="ATXHeading1",t[t.ATXHeading2=10]="ATXHeading2",t[t.ATXHeading3=11]="ATXHeading3",t[t.ATXHeading4=12]="ATXHeading4",t[t.ATXHeading5=13]="ATXHeading5",t[t.ATXHeading6=14]="ATXHeading6",t[t.SetextHeading1=15]="SetextHeading1",t[t.SetextHeading2=16]="SetextHeading2",t[t.HTMLBlock=17]="HTMLBlock",t[t.LinkReference=18]="LinkReference",t[t.Paragraph=19]="Paragraph",t[t.CommentBlock=20]="CommentBlock",t[t.ProcessingInstructionBlock=21]="ProcessingInstructionBlock",t[t.Escape=22]="Escape",t[t.Entity=23]="Entity",t[t.HardBreak=24]="HardBreak",t[t.Emphasis=25]="Emphasis",t[t.StrongEmphasis=26]="StrongEmphasis",t[t.Link=27]="Link",t[t.Image=28]="Image",t[t.InlineCode=29]="InlineCode",t[t.HTMLTag=30]="HTMLTag",t[t.Comment=31]="Comment",t[t.ProcessingInstruction=32]="ProcessingInstruction",t[t.Autolink=33]="Autolink",t[t.HeaderMark=34]="HeaderMark",t[t.QuoteMark=35]="QuoteMark",t[t.ListMark=36]="ListMark",t[t.LinkMark=37]="LinkMark",t[t.EmphasisMark=38]="EmphasisMark",t[t.CodeMark=39]="CodeMark",t[t.CodeText=40]="CodeText",t[t.CodeInfo=41]="CodeInfo",t[t.LinkTitle=42]="LinkTitle",t[t.LinkLabel=43]="LinkLabel",t[t.URL=44]="URL"})(u||(u={}));var te=class{constructor(e,r){this.start=e,this.content=r,this.marks=[],this.parsers=[]}},re=class{constructor(){this.text="",this.baseIndent=0,this.basePos=0,this.depth=0,this.markers=[],this.pos=0,this.indent=0,this.next=-1}forward(){this.basePos>this.pos&&this.forwardInner()}forwardInner(){let e=this.skipSpace(this.basePos);this.indent=this.countIndent(e,this.pos,this.indent),this.pos=e,this.next=e==this.text.length?-1:this.text.charCodeAt(e)}skipSpace(e){return R(this.text,e)}reset(e){for(this.text=e,this.baseIndent=this.basePos=this.pos=this.indent=0,this.forwardInner(),this.depth=1;this.markers.length;)this.markers.pop()}moveBase(e){this.basePos=e,this.baseIndent=this.countIndent(e,this.pos,this.indent)}moveBaseColumn(e){this.baseIndent=e,this.basePos=this.findColumn(e)}addMarker(e){this.markers.push(e)}countIndent(e,r=0,n=0){for(let s=r;s<e;s++)n+=this.text.charCodeAt(s)==9?4-n%4:1;return n}findColumn(e){let r=0;for(let n=0;r<this.text.length&&n<e;r++)n+=this.text.charCodeAt(r)==9?4-n%4:1;return r}scrub(){if(!this.baseIndent)return this.text;let e="";for(let r=0;r<this.basePos;r++)e+=" ";return e+this.text.slice(this.basePos)}};function Ae(t,e,r){if(r.pos==r.text.length||t!=e.block&&r.indent>=e.stack[r.depth+1].value+r.baseIndent)return!0;if(r.indent>=r.baseIndent+4)return!1;let n=(t.type==u.OrderedList?ce:pe)(r,e,!1);return n>0&&(t.type!=u.BulletList||de(r,e,!1)<0)&&r.text.charCodeAt(r.pos+n-1)==t.value}var Re={[u.Blockquote](t,e,r){return r.next!=62?!1:(r.markers.push(g(u.QuoteMark,e.lineStart+r.pos,e.lineStart+r.pos+1)),r.moveBase(r.pos+(A(r.text.charCodeAt(r.pos+1))?2:1)),t.end=e.lineStart+r.text.length,!0)},[u.ListItem](t,e,r){return(r.next<0?t.blankFirstLine&&t.children.length==1:r.indent<r.baseIndent+t.value)?!1:(r.moveBaseColumn(r.baseIndent+t.value),!0)},[u.OrderedList]:Ae,[u.BulletList]:Ae,[u.Document](){return!0}};function A(t){return t==32||t==9||t==10||t==13}function R(t,e=0){for(;e<t.length&&A(t.charCodeAt(e));)e++;return e}function xe(t,e,r){for(;e>r&&A(t.charCodeAt(e-1));)e--;return e}function Te(t){if(t.next!=96&&t.next!=126)return-1;let e=t.pos+1;for(;e<t.text.length&&t.text.charCodeAt(e)==t.next;)e++;if(e<t.pos+3)return-1;if(t.next==96){for(let r=e;r<tLine truncated
`+r.scrub(),i=this.advance(s);return i>-1&&i<s.length?this.complete(e,n,i):!1}finish(e,r){return(this.stage==2||this.stage==3)&&R(r.content,this.pos)==r.content.length?this.complete(e,r,r.content.length):!1}complete(e,r,n){return e.addLeafElement(r,g(u.LinkReference,this.start,this.start+n,this.elts)),!0}nextStage(e){return e?(this.pos=e.to-this.start,this.elts.push(e),this.stage++,!0):(e===!1&&(this.stage=-1),!1)}advance(e){for(;;){if(this.stage==-1)return-1;if(this.stage==0){if(!this.nextStage(Ke(e,this.pos,this.start,!0)))return-1;if(e.charCodeAt(this.pos)!=58)return this.stage=-1;this.elts.push(g(u.LinkMark,this.pos+this.start,this.pos+this.start+1)),this.pos++}else if(this.stage==1){if(!this.nextStage(Ge(e,R(e,this.pos),this.start)))return-1}else if(this.stage==2){let r=R(e,this.pos),n=0;if(r>this.pos){let s=Ve(e,r,this.start);if(s){let i=W(e,s.to-this.start);i>0&&(this.nextStage(s),n=i)}}return n||(n=W(e,this.pos)),n>0&&n<e.length?n:-1}else return W(e,this.pos)}}};function W(t,e){for(;e<t.length;e++){let r=t.charCodeAt(e);if(r==10)break;if(!A(r))return-1}return e}var oe=class{nextLine(e,r,n){let s=r.depth<e.stack.length?-1:$e(r),i=r.next;if(s<0)return!1;let o=g(u.HeaderMark,e.lineStart+r.pos,e.lineStart+s);return e.nextLine(),e.addLeafElement(n,g(i==61?u.SetextHeading1:u.SetextHeading2,n.start,e.prevLineEnd(),[...e.parser.parseInline(n.content,n.start),o])),!0}finish(){return!1}},qe={LinkReference(t,e){return e.content.charCodeAt(0)==91?new ie(e):null},SetextHeading(){return new oe}},Ct=[(t,e)=>Xe(e)>=0,(t,e)=>Te(e)>=0,(t,e)=>De(e)>=0,(t,e)=>pe(e,t,!0)>=0,(t,e)=>ce(e,t,!0)>=0,(t,e)=>de(e,t,!0)>=0,(t,e)=>_e(e,t,!0)>=0],At={text:"",end:0},ae=class{constructor(e,r,n,s){this.parser=e,this.input=r,this.ranges=s,this.line=new re,this.atEnd=!1,this.reusePlaceholders=new Map,this.stoppedAt=null,this.rangeI=0,this.to=s[s.length-1].to,this.lineStart=this.absoluteLineStart=this.absoluteLineEnd=s[0].from,this.block=Q.create(u.Document,0,this.lineStart,0,0),this.stack=[this.block],this.fragments=n.length?new fe(n,r):null,this.readLine()}get parsedPos(){return this.absoluteLineStart}advance(){if(this.stoppedAt!=null&&this.absoluteLineStart>this.stoppedAt)return this.finish();let{line:e}=this;for(;;){for(let n=0;;){let s=e.depth<this.stack.length?this.stack[this.stack.length-1]:null;for(;n<e.markers.length&&(!s||e.markers[n].from<s.end);){let i=e.markers[n++];this.addNode(i.type,i.from,i.to)}if(!s)break;this.finishContext()}if(e.pos<e.text.length)break;if(!this.nextLine())return this.finish()}if(this.fragments&&this.reuseFragment(e.basePos))return null;e:for(;;){for(let n of this.parser.blockParsers)if(n){let s=n(this,e);if(s!=!1){if(s==!0)return null;e.forward();continue e}}break}if(e.pos==e.text.length)return this.nextLine()?null:this.finish();let r=new te(this.lineStart+e.pos,e.text.slice(e.pos));for(let n of this.parser.leafBlockParsers)if(n){let s=n(this,r);s&&r.parsers.push(s)}e:for(;this.nextLine()&&e.pos!=e.text.length;){if(e.indent<e.baseIndent+4){for(let n of this.parser.endLeafBlock)if(n(this,e,r))break e}for(let n of r.parsers)if(n.nextLine(this,e,r))return null;r.content+=`
`+e.scrub();for(let n of e.markers)r.marks.push(n)}return this.finishLeaf(r),null}stopAt(e){if(this.stoppedAt!=null&&this.stoppedAt<e)throw new RangeError("Can't move stoppedAt forward");this.stoppedAt=e}reuseFragment(e){if(!this.fragments.moveTo(this.absoluteLineStart+e,this.absoluteLineStart)||!this.fragments.matches(this.block.hash))return!1;let r=this.fragments.takeNodes(this);return r?(this.absoluteLineStart+=r,this.lineStart=Ye(this.absoluteLineStart,this.ranges),this.moveRangeI(),this.absoluteLineStart<this.to?(this.lineStart++,this.absoluteLineStart++,this.readLine()):(this.atEnd=!0,this.readLine()),!0):!1}get depth(){return this.stack.length}parentType(e=this.depth-1){return this.parser.nodeSet.types[this.stack[e].type]}nextLine(){return this.lineStart+=this.line.text.length,this.absoluteLineEnd>=this.to?(this.absoluteLineStart=this.absoluteLineEnd,this.atEnd=!0,this.readLine(),!1):(this.lineStart++,this.absoluteLineStart=this.absoluteLineEnd+1,this.moveRangeI(),this.readLine(),!0)}peekLine(){return this.scanLine(this.absoluteLineEnd+1).text}moveRangeI(){for(;this.rangeI<this.ranges.length-1&&this.absoluteLineStart>=this.ranges[this.rangeI].to;)this.rangeI++,this.absoluteLineStart=Math.max(this.absoluteLineStart,this.ranges[this.rangeI].from)}scanLine(e){let r=At;if(r.end=e,e>=this.to)r.text="";else if(r.text=this.lineChunkAt(e),r.end+=r.text.length,this.ranges.length>1){let n=this.absoluteLineStart,s=this.rangeI;for(;this.ranges[s].to<r.end;){s++;let i=this.ranges[s].from,o=this.lineChunkAt(i);r.end=i+o.length,r.text=r.text.slice(0,this.ranges[s-1].to-n)+o,n=r.end-r.text.length}}return r}readLine(){let{line:e}=this,{text:r,end:n}=this.scanLine(this.absoluteLineStart);for(this.absoluteLineEnd=n,e.reset(r);e.depth<this.stack.length;e.depth++){let s=this.stack[e.depth],i=this.parser.skipContextMarkup[s.type];if(!i)throw new Error("Unhandled block context "+u[s.type]);let o=this.line.markers.length;if(!i(s,this,e)){this.line.markers.length>o&&(s.end=this.line.markers[this.line.markers.length-1].to),e.forward();break}e.forward()}}lineChunkAt(e){let r=this.input.chunk(e),n;if(this.input.lineChunks)n=r==`
`?"":r;else{let s=r.indexOf(`
`);n=s<0?r:r.slice(0,s)}return e+n.length>this.to?n.slice(0,this.to-e):n}prevLineEnd(){return this.atEnd?this.lineStart:this.lineStart-1}startContext(e,r,n=0){this.block=Q.create(e,n,this.lineStart+r,this.block.hash,this.lineStart+this.line.text.length),this.stack.push(this.block)}startComposite(e,r,n=0){this.startContext(this.parser.getNodeType(e),r,n)}addNode(e,r,n){typeof e=="number"&&(e=new x(this.parser.nodeSet.types[e],y,y,(n??this.prevLineEnd())-r)),this.block.addChild(e,r-this.block.from)}addElement(e){this.block.addChild(e.toTree(this.parser.nodeSet),e.from-this.block.from)}addLeafElement(e,r){this.addNode(this.buffer.writeElements(he(r.children,e.marks),-r.from).finish(r.type,r.to-r.from),r.from)}finishContext(){let e=this.stack.pop(),r=this.stack[this.stack.length-1];r.addChild(e.toTree(this.parser.nodeSet),e.from-r.from),this.block=r}finish(){for(;this.stack.length>1;)this.finishContext();return this.addGaps(this.block.toTree(this.parser.nodeSet,this.lineStart))}addGaps(e){return this.ranges.length>1?Qe(this.ranges,0,e.topNode,this.ranges[0].from,this.reusePlaceholders):e}finishLeaf(e){for(let n of e.parsers)if(n.finish(this,e))return;let r=he(this.parser.parseInline(e.content,e.start),e.marks);this.addNode(this.buffer.writeElements(r,-e.start).finish(u.Paragraph,e.content.length),e.start)}elt(e,r,n,s){return typeof e=="string"?g(this.parser.getNodeType(e),r,n,s):new G(e,r)}get buffer(){return new Z(this.parser.nodeSet)}};function Qe(t,e,r,n,s){let i=t[e].to,o=[],a=[],l=r.from+n;function h(f,p){for(;p?f>=i:f>i;){let d=t[e+1].from-i;n+=d,f+=d,e++,i=t[e].to}}for(let f=r.firstChild;f;f=f.nextSibling){h(f.from+n,!0);let p=f.from+n,d,c=s.get(f.tree);c?d=c:f.to+n>i?(d=Qe(t,e,f,n,s),h(f.to+n,!1)):d=f.toTree(),o.push(d),a.push(p-l)}return h(r.to+n,!1),new x(r.type,o,a,r.to+n-l,r.tree?r.tree.propValues:void 0)}var F=class t extends bt{constructor(e,r,n,s,i,o,a,l,h){super(),this.nodeSet=e,this.blockParsers=r,this.leafBlockParsers=n,this.blockNames=s,this.endLeafBlock=i,this.skipContextMarkup=o,this.inlineParsers=a,this.inlineNames=l,this.wrappers=h,this.nodeTypes=Object.create(null);for(let f of e.types)this.nodeTypes[f.name]=f.id}createParse(e,r,n){let s=new ae(this,e,r,n);for(let i of this.wrappers)s=i(s,e,r,n);return s}configure(e){let r=le(e);if(!r)return this;let{nodeSet:n,skipContextMarkup:s}=this,i=this.blockParsers.slice(),o=this.leafBlockParsers.slice(),a=this.blockNames.slice(),l=this.inlineParsers.slice(),h=this.inlineNames.slice(),f=this.endLeafBlock.slice(),p=this.wrappers;if(z(r.defineNodes)){s=Object.assign({},s);let d=n.types.slice(),c;for(let L of r.defineNodes){let{name:k,block:E,composite:w,style:S}=typeof L=="string"?{name:L}:L;if(d.some(M=>M.name==k))continue;w&&(s[d.length]=(M,O,Lt)=>w(O,Lt,M.value));let b=d.length,B=w?["Block","BlockContext"]:E?b>=u.ATXHeading1&&b<=u.SetextHeading2?["Block","LeafBlock","Heading"]:["Block","LeafBlock"]:void 0;d.push(J.define({id:b,name:k,props:B&&[[D.group,B]]})),S&&(c||(c={}),Array.isArray(S)||S instanceof St?c[k]=S:Object.assign(c,S))}n=new Oe(d),c&&(n=n.extend(ze(c)))}if(z(r.props)&&(n=n.extend(...r.props)),z(r.remove))for(let d of r.remove){let c=this.blockNames.indexOf(d),L=this.inlineNames.indexOf(d);c>-1&&(i[c]=o[c]=void 0),L>-1&&(l[L]=void 0)}if(z(r.parseBlock))for(let d of r.parseBlock){let c=a.indexOf(d.name);if(c>-1)i[c]=d.parse,o[c]=d.leaf;else{let L=d.before?q(a,d.before):d.after?q(a,d.after)+1:a.length-1;i.splice(L,0,d.parse),o.splice(L,0,d.leaf),a.splice(L,0,d.name)}d.endLeaf&&f.push(d.endLeaf)}if(z(r.parseInline))for(let d of r.parseInline){let c=h.indexOf(d.name);if(c>-1)l[c]=d.parse;else{let L=d.before?q(h,d.before):d.after?q(h,d.after)+1:h.length-1;l.splice(L,0,d.parse),h.splice(L,0,d.name)}}return r.wrap&&(p=p.concat(r.wrap)),new t(n,i,o,a,f,s,l,h,p)}getNodeType(e){let r=this.nodeTypes[e];if(r==null)throw new RangeError(`Unknown node type '${e}'`);return r}parseInline(e,r){let n=new P(this,e,r);e:for(let s=r;s<n.end;){let i=n.char(s);for(let o of this.inlineParsers)if(o){let a=o(n,i,s);if(a>=0){s=a;continue e}}s++}return n.resolveMarkers(0)}};function z(t){return t!=null&&t.length>0}function le(t){if(!Array.isArray(t))return t;if(t.length==0)return null;let e=le(t[0]);if(t.length==1)return e;let r=le(t.slice(1));if(!r||!e)return e||r;let n=(o,a)=>(o||y).concat(a||y),s=e.wrap,i=r.wrap;return{props:n(e.props,r.props),defineNodes:n(e.defineNodes,r.defineNodes),parseBlock:n(e.parseBlock,r.parseBlock),parseInline:n(e.parseInline,r.parseInline),remove:n(e.remove,r.remove),wrap:s?i?(o,a,l,h)=>s(i(o,a,l,h),a,l,h):s:i}}function q(t,e){let r=t.indexOf(e);if(r<0)throw new RangeError(`Position specified relative to unknown parser ${e}`);return r}var Ze=[J.none];for(let t=1,e;e=u[t];t++)Ze[t]=J.define({id:t,name:e,props:t>=u.Escape?[]:[[D.group,t in Re?["Block","BlockContext"]:["Block","LeafBlock"]]],top:e=="Document"});var y=[],Z=class{constructor(e){this.nodeSet=e,this.content=[],this.nodes=[]}write(e,r,n,s=0){return this.content.push(e,r,n Line truncated
`;)i--;this.fragmentEnd=i?i-1:0}let n=this.cursor;n||(n=this.cursor=this.fragment.tree.cursor(Ce.IncludeAnonymous|Ce.IgnoreMounts),n.firstChild());let s=e+this.fragment.offset;for(;n.to<=s;)if(!n.parent())return!1;for(;;){if(n.from>=s)return this.fragment.from<=r;if(!n.childAfter(s))return!1}}matches(e){let r=this.cursor.tree;for(;r&&r.type.isAnonymous&&r.children[0]instanceof x;)r=r.children[0];return r&&r.prop(D.contextHash)==e}takeNodes(e){let r=this.cursor,n=this.fragment.offset,s=this.fragmentEnd-(this.fragment.openEnd?1:0),i=e.absoluteLineStart,o=i,a=e.block.children.length,l=e.lineStart-e.block.from>>2,h=o,f=a;e:for(;;){if(r.to-n>Math.min(s,e.to)||r.type.isAnonymous&&r.tree&&(r.to-r.from>l||!It(r.tree))){if(r.type.isAnonymous&&r.firstChild())continue;break}let p=Ye(r.from-n,e.ranges);if(r.to-n<=e.ranges[e.rangeI].to)e.addNode(r.tree,p);else{let d=new x(e.parser.nodeSet.types[u.Paragraph],[],[],0,e.block.hashProp);e.reusePlaceholders.set(d,r.tree),e.addNode(d,p)}for((r.type.isAnonymous||r.type.is("Block"))&&(r.type.isAnonymous||Je.indexOf(r.type.id)<0?(o=r.to-n,a=e.block.children.length):(o=h,a=f),h=r.to-n,f=e.block.children.length);;){if(r.nextSibling())continue e;if(!r.parent()||!r.type.isAnonymous)break e}}for(;e.block.children.length>a;)e.block.children.pop(),e.block.positions.pop();return o-i}};function It(t){for(;t.type.isAnonymous;){let e=t.children[t.children.length-1];if(e instanceof x)t=e;else return!1}return t.type.is("Block")&&Je.indexOf(t.type.id)<0}function Ye(t,e){let r=t;for(let n=1;n<e.length;n++){let s=e[n-1].to,i=e[n].from;s<t&&(r-=i-s)}return r}var Et=ze({"Blockquote/...":m.quote,HorizontalRule:m.contentSeparator,"ATXHeading1/... SetextHeading1/...":m.heading1,"ATXHeading2/... SetextHeading2/...":m.heading2,"ATXHeading3/...":m.heading3,"ATXHeading4/...":m.heading4,"ATXHeading5/...":m.heading5,"ATXHeading6/...":m.heading6,"Comment CommentBlock":m.comment,Escape:m.escape,Entity:m.character,"Emphasis/...":m.emphasis,"StrongEmphasis/...":m.strong,"Link/... Image/...":m.link,"OrderedList/... BulletList/...":m.list,"BlockQuote/...":m.quote,"InlineCode CodeText":m.monospace,"URL Autolink":m.url,"HeaderMark HardBreak QuoteMark ListMark LinkMark EmphasisMark CodeMark":m.processingInstruction,"CodeInfo LinkLabel":m.labelName,LinkTitle:m.string,Paragraph:m.content}),We=new F(new Oe(Ze).extend(Et),Object.keys(_).map(t=>_[t]),Object.keys(_).map(t=>qe[t]),Object.keys(_),Ct,Re,Object.keys(ee).map(t=>ee[t]),Object.keys(ee),[]);function Bt(t,e,r){let n=[];for(let s=t.firstChild,i=e;;s=s.nextSibling){let o=s?s.from:r;if(o>i&&n.push({from:i,to:o}),!s)break;i=s.to}return n}function et(t){let{codeParser:e,htmlParser:r}=t;return{wrap:wt((s,i)=>{let o=s.type.id;if(e&&(o==u.CodeBlock||o==u.FencedCode)){let a="";if(o==u.FencedCode){let h=s.node.getChild(u.CodeInfo);h&&(a=i.read(h.from,h.to))}let l=e(a);if(l)return{parser:l,overlay:h=>h.type.id==u.CodeText,bracketed:o==u.FencedCode}}else if(r&&(o==u.HTMLBlock||o==u.HTMLTag||o==u.CommentBlock))return{parser:r,overlay:Bt(s.node,s.from,s.to)};return null})}}var Mt={resolve:"Strikethrough",mark:"StrikethroughMark"},Ht={defineNodes:[{name:"Strikethrough",style:{"Strikethrough/...":m.strikethrough}},{name:"StrikethroughMark",style:m.processingInstruction}],parseInline:[{name:"Strikethrough",parse(t,e,r){if(e!=126||t.char(r+1)!=126||t.char(r+2)==126)return-1;let n=t.slice(r-1,r),s=t.slice(r+2,r+3),i=/\s|^$/.test(n),o=/\s|^$/.test(s),a=X.test(n),l=X.test(s);return t.addDelimiter(Mt,r,r+2,!o&&(!l||i||a),!i&&(!a||o||l))},after:"Emphasis"}]};function T(t,e,r=0,n,s=0){let i=0,o=!0,a=-1,l=-1,h=!1,f=()=>{n.push(t.elt("TableCell",s+a,s+l,t.parser.parseInline(e.slice(a,l),s+a)))};for(let p=r;p<e.length;p++){let d=e.charCodeAt(p);d==124&&!h?((!o||a>-1)&&i++,o=!1,n&&(a>-1&&f(),n.push(t.elt("TableDelimiter",p+s,p+s+1))),a=l=-1):(h||d!=32&&d!=9)&&(a<0&&(a=p),l=p+1),h=!h&&d==92}return a>-1&&(i++,n&&f()),i}function Be(t,e){for(let r=e;r<t.length;r++){let n=t.charCodeAt(r);if(n==124)return!0;n==92&&r++}return!1}var tt=/^[>\s]*\|?(\s*:?-+:?\s*\|)+(\s*:?-+:?\s*)?$/,K=class{constructor(){this.rows=null}nextLine(e,r,n){if(this.rows==null){this.rows=!1;let s;if((r.next==45||r.next==58||r.next==124)&&tt.test(s=r.text.slice(r.pos))){let i=[];T(e,n.content,0,i,n.start)==T(e,s,0)&&(this.rows=[e.elt("TableHeader",n.start,n.start+n.content.length,i),e.elt("TableDelimiter",e.lineStart+r.pos,e.lineStart+r.text.length)])}}else if(this.rows){let s=[];T(e,r.text,r.pos,s,e.lineStart),this.rows.push(e.elt("TableRow",e.lineStart+r.pos,e.lineStart+r.text.length,s))}return!1}finish(e,r){return this.rows?(e.addLeafElement(r,e.elt("Table",r.start,r.start+r.content.length,this.rows)),!0):!1}},vt={defineNodes:[{name:"Table",block:!0},{name:"TableHeader",style:{"TableHeader/...":m.heading}},"TableRow",{name:"TableCell",style:m.content},{name:"TableDelimiter",style:m.processingInstruction}],parseBlock:[{name:"Table",leaf(t,e){return Be(e.content,0)?new K:null},endLeaf(t,e,r){if(r.parsers.some(s=>s instanc Line truncated
//# sourceMappingURL=lang-markdown.bundle.mjs.map
+5
View File
@@ -0,0 +1,5 @@
/* esm.sh - @codemirror/lang-python@6.2.1 */
import{ExternalTokenizer as $,ContextTracker as L,LRParser as H}from"./lezer-lr.mjs";import{styleTags as B,tags as n}from"./lezer-highlight.mjs";var K=1,G=194,w=195,M=196,x=197,OO=198,eO=199,iO=200,aO=2,Z=3,u=201,nO=24,rO=25,QO=49,tO=50,oO=55,dO=56,sO=57,lO=59,TO=60,SO=61,pO=62,qO=63,gO=65,mO=238,PO=71,$O=241,hO=242,cO=243,fO=244,XO=245,yO=246,zO=247,WO=248,j=72,vO=249,RO=250,kO=251,_O=252,xO=253,uO=254,UO=255,bO=256,VO=73,GO=77,wO=263,ZO=112,jO=130,EO=151,YO=152,FO=155,p=10,q=13,z=32,h=9,W=35,CO=40,JO=46,y=123,U=125,E=39,Y=34,b=92,AO=111,NO=120,IO=78,DO=117,LO=85,HO=new Set([rO,QO,tO,wO,gO,jO,dO,sO,mO,pO,qO,j,VO,GO,TO,SO,EO,YO,FO,ZO]);function f(O){return O==p||O==q}function X(O){return O>=48&&O<=57||O>=65&&O<=70||O>=97&&O<=102}var BO=new $((O,e)=>{let i;if(O.next<0)O.acceptToken(eO);else if(e.context.flags&m)f(O.next)&&O.acceptToken(OO,1);else if(((i=O.peek(-1))<0||f(i))&&e.canShift(x)){let a=0;for(;O.next==z||O.next==h;)O.advance(),a++;(O.next==p||O.next==q||O.next==W)&&O.acceptToken(x,-a)}else f(O.next)&&O.acceptToken(M,1)},{contextual:!0}),KO=new $((O,e)=>{let i=e.context;if(i.flags)return;let a=O.peek(-1);if(a==p||a==q){let r=0,Q=0;for(;;){if(O.next==z)r++;else if(O.next==h)r+=8-r%8;else break;O.advance(),Q++}r!=i.indent&&O.next!=p&&O.next!=q&&O.next!=W&&(r<i.indent?O.acceptToken(w,-Q):O.acceptToken(G))}}),m=1,F=2,d=4,s=8,l=16,T=32;function P(O,e,i){this.parent=O,this.indent=e,this.flags=i,this.hash=(O?O.hash+O.hash<<8:0)+e+(e<<4)+i+(i<<6)}var MO=new P(null,0,0);function Oe(O){let e=0;for(let i=0;i<O.length;i++)e+=O.charCodeAt(i)==h?8-e%8:1;return e}var V=new Map([[$O,0],[hO,d],[cO,s],[fO,s|d],[XO,l],[yO,l|d],[zO,l|s],[WO,l|s|d],[vO,T],[RO,T|d],[kO,T|s],[_O,T|s|d],[xO,T|l],[uO,T|l|d],[UO,T|l|s],[bO,T|l|s|d]].map(([O,e])=>[O,e|F])),ee=new L({start:MO,reduce(O,e,i,a){return O.flags&m&&HO.has(e)||(e==PO||e==j)&&O.flags&F?O.parent:O},shift(O,e,i,a){return e==G?new P(O,Oe(a.read(a.pos,i.pos)),0):e==w?O.parent:e==nO||e==oO||e==lO||e==Z?new P(O,0,m):V.has(e)?new P(O,0,V.get(e)|O.flags&m):O},hash(O){return O.hash}}),ie=new $(O=>{for(let e=0;e<5;e++){if(O.next!="print".charCodeAt(e))return;O.advance()}if(!/\w/.test(String.fromCharCode(O.next)))for(let e=0;;e++){let i=O.peek(e);if(!(i==z||i==h)){i!=CO&&i!=JO&&i!=p&&i!=q&&i!=W&&O.acceptToken(K);return}}}),ae=new $((O,e)=>{let{flags:i}=e.context,a=i&d?Y:E,r=(i&s)>0,Q=!(i&l),t=(i&T)>0,o=O.pos;for(;!(O.next<0);)if(t&&O.next==y)if(O.peek(1)==y)O.advance(2);else{if(O.pos==o){O.acceptToken(Z,1);return}break}else if(Q&&O.next==b){if(O.pos==o){O.advance();let g=O.next;g>=0&&(O.advance(),ne(O,g)),O.acceptToken(aO);return}break}else if(O.next==b&&!Q&&O.peek(1)>-1)O.advance(2);else if(O.next==a&&(!r||O.peek(1)==a&&O.peek(2)==a)){if(O.pos==o){O.acceptToken(u,r?3:1);return}break}else if(O.next==p){if(r)O.advance();else if(O.pos==o){O.acceptToken(u);return}break}else O.advance();O.pos>o&&O.acceptToken(iO)});function ne(O,e){if(e==AO)for(let i=0;i<2&&O.next>=48&&O.next<=55;i++)O.advance();else if(e==NO)for(let i=0;i<2&&X(O.next);i++)O.advance();else if(e==DO)for(let i=0;i<4&&X(O.next);i++)O.advance();else if(e==LO)for(let i=0;i<8&&X(O.next);i++)O.advance();else if(e==IO&&O.next==y){for(O.advance();O.next>=0&&O.next!=U&&O.next!=E&&O.next!=Y&&O.next!=p;)O.advance();O.next==U&&O.advance()}}var re=B({'async "*" "**" FormatConversion FormatSpec':n.modifier,"for while if elif else try except finally return raise break continue with pass assert await yield match case":n.controlKeyword,"in not and or is del":n.operatorKeyword,"from def class global nonlocal lambda":n.definitionKeyword,import:n.moduleKeyword,"with as print":n.keyword,Boolean:n.bool,None:n.null,VariableName:n.variableName,"CallExpression/VariableName":n.function(n.variableName),"FunctionDefinition/VariableName":n.function(n.definition(n.variableName)),"ClassDefinition/VariableName":n.definition(n.className),PropertyName:n.propertyName,"CallExpression/MemberExpression/PropertyName":n.function(n.propertyName),Comment:n.lineComment,Number:n.number,String:n.string,FormatString:n.special(n.string),Escape:n.escape,UpdateOp:n.updateOperator,"ArithOp!":n.arithmeticOperator,BitOp:n.bitwiseOperator,CompareOp:n.compareOperator,AssignOp:n.definitionOperator,Ellipsis:n.punctuation,At:n.meta,"( )":n.paren,"[ ]":n.squareBracket,"{ }":n.brace,".":n.derefOperator,", ;":n.separator}),Qe={__proto__:null,await:44,or:54,and:56,in:60,not:62,is:64,if:70,else:72,lambda:76,yield:94,from:96,async:102,for:104,None:162,True:164,False:164,del:178,pass:182,break:186,continue:190,return:194,raise:202,import:206,as:208,global:212,nonlocal:214,assert:218,type:223,elif:236,while:240,try:246,except:248,finally:250,with:254,def:258,class:268,match:279,case:285},C=H.deserialize({version:14,states:"##jQ`QeOOP$}OSOOO&WQtO'#HUOOQS'#Co'#CoOOQS'#Cp'#CpO'vQdO'#CnO*UQtO'#HTOOQS'#HU'#HUOOQS'#DU'#DUOOQS'#HT'#HTO*rQdO'#D_O+VQdO'#DfO+gQdO'#DjO+zOWO'#DuO,VOWO'#DvO.[QtO'#GuOOQS'#Gu'#GuO'vQdO'#GtO0ZQtO'#GtOOQS'#Eb'#EbO0rQdO'#EcOOQS'#Gs'#GsO0|QdO'#GrOOQV'#Gr'#GrO1XQdO'# Line truncated
`,{label:"if",detail:"block",type:"keyword"}),S("if ${}:\n ${}\nelse:\n ${}",{label:"if",detail:"/ else block",type:"keyword"}),S("class ${name}:\n def __init__(self, ${params}):\n ${}",{label:"class",detail:"definition",type:"keyword"}),S("import ${module}",{label:"import",detail:"statement",type:"keyword"}),S("from ${module} import ${names}",{label:"from",detail:"import",type:"keyword"})],ce=qe(D,ge($e.concat(he)));function R(O){let{node:e,pos:i}=O,a=O.lineIndent(i,-1),r=null;for(;;){let Q=e.childBefore(i);if(Q)if(Q.name=="Comment")i=Q.from;else if(Q.name=="Body"||Q.name=="MatchBody")O.baseIndentFor(Q)+O.unit<=a&&(r=Q),e=Q;else if(Q.name=="MatchClause")e=Q;else if(Q.type.is("Statement"))e=Q;else break;else break}return r}function k(O,e){let i=O.baseIndentFor(e),a=O.lineAt(O.pos,-1),r=a.from+a.text.length;return/^\s*($|#)/.test(a.text)&&O.node.to<r+100&&!/\S/.test(O.state.sliceDoc(r,O.node.to))&&O.lineIndent(O.pos,-1)<=i||/^\s*(else:|elif |except |finally:|case\s+[^=:]+:)/.test(O.textAfter)&&O.lineIndent(O.pos,-1)>i?null:i+O.unit}var _=oe.define({name:"python",parser:C.configure({props:[de.add({Body:O=>{var e;let i=/^\s*(#|$)/.test(O.textAfter)&&R(O)||O.node;return(e=k(O,i))!==null&&e!==void 0?e:O.continue()},MatchBody:O=>{var e;let i=R(O);return(e=k(O,i||O.node))!==null&&e!==void 0?e:O.continue()},IfStatement:O=>/^\s*(else:|elif )/.test(O.textAfter)?O.baseIndent:O.continue(),"ForStatement WhileStatement":O=>/^\s*else:/.test(O.textAfter)?O.baseIndent:O.continue(),TryStatement:O=>/^\s*(except[ :]|finally:|else:)/.test(O.textAfter)?O.baseIndent:O.continue(),MatchStatement:O=>/^\s*case /.test(O.textAfter)?O.baseIndent+O.unit:O.continue(),"TupleExpression ComprehensionExpression ParamList ArgList ParenthesizedExpression":v({closing:")"}),"DictionaryExpression DictionaryComprehensionExpression SetExpression SetComprehensionExpression":v({closing:"}"}),"ArrayExpression ArrayComprehensionExpression":v({closing:"]"}),MemberExpression:O=>O.baseIndent+O.unit,"String FormatString":()=>null,Script:O=>{var e;let i=R(O);return(e=i&&k(O,i))!==null&&e!==void 0?e:O.continue()}}),se.add({"ArrayExpression DictionaryExpression SetExpression TupleExpression":le,Body:(O,e)=>({from:O.from+1,to:O.to-(O.to==e.doc.length?0:1)}),"String FormatString":(O,e)=>({from:e.doc.lineAt(O.from).to,to:O.to})})]}),languageData:{closeBrackets:{brackets:["(","[","{","'",'"',"'''",'"""'],stringPrefixes:["f","fr","rf","r","u","b","br","rb","F","FR","RF","R","U","B","BR","RB"]},commentTokens:{line:"#"},indentOnInput:/^\s*([\}\]\)]|else:|elif |except |finally:|case\s+[^:]*:?)$/}});function ke(){return new Te(_,[_.data.of({autocomplete:Pe}),_.data.of({autocomplete:ce})])}export{ce as globalCompletion,Pe as localCompletionSource,ke as python,_ as pythonLanguage};
//# sourceMappingURL=lang-python.bundle.mjs.map
+7
View File
@@ -0,0 +1,7 @@
/* esm.sh - @codemirror/language@6.13.1 */
import{NodeProp as v,Tree as b,TreeFragment as $,Parser as Fe,IterMode as Te,NodeType as Z,NodeSet as ut}from"./lezer-common.mjs";import{StateEffect as Q,StateField as Ue,Facet as T,EditorState as M,countColumn as ct,combineConfig as We,RangeSetBuilder as de,RangeSet as je,Prec as Ve}from"./cm-state.mjs";import{ViewPlugin as j,logException as dt,EditorView as S,Decoration as g,WidgetType as He,gutter as pt,GutterMarker as mt,Direction as _}from"./cm-view.mjs";import{tags as u,highlightTree as gt,tagHighlighter as kt,styleTags as bt}from"./lezer-highlight.mjs";var ye=typeof Symbol>"u"?"__\u037C":Symbol.for("\u037C"),Y=typeof Symbol>"u"?"__styleSet"+Math.floor(Math.random()*1e8):Symbol("styleSet"),we=typeof globalThis<"u"?globalThis:typeof window<"u"?window:{},O=class{constructor(e,t){this.rules=[];let{finish:r}=t||{};function i(o){return/^@/.test(o)?[o]:o.split(/,\s*/)}function s(o,a,l,h){let f=[],c=/^@(\w+)\b/.exec(o[0]),m=c&&c[1]=="keyframes";if(c&&a==null)return l.push(o[0]+";");for(let d in a){let k=a[d];if(/&/.test(d))s(d.split(/,\s*/).map(w=>o.map(I=>w.replace(/&/,I))).reduce((w,I)=>w.concat(I)),k,l);else if(k&&typeof k=="object"){if(!c)throw new RangeError("The value of a property ("+d+") should be a primitive value.");s(i(d),k,f,m)}else k!=null&&f.push(d.replace(/_.*/,"").replace(/[A-Z]/g,w=>"-"+w.toLowerCase())+": "+k+";")}(f.length||m)&&l.push((r&&!c&&!h?o.map(r):o).join(", ")+" {"+f.join(" ")+"}")}for(let o in e)s(i(o),e[o],this.rules)}getRules(){return this.rules.join(`
`)}static newName(){let e=we[ye]||1;return we[ye]=e+1,"\u037C"+e.toString(36)}static mount(e,t,r){let i=e[Y],s=r&&r.nonce;i?s&&i.setNonce(s):i=new ee(e,s),i.mount(Array.isArray(t)?t:[t],e)}},Se=new Map,ee=class{constructor(e,t){let r=e.ownerDocument||e,i=r.defaultView;if(!e.head&&e.adoptedStyleSheets&&i.CSSStyleSheet){let s=Se.get(r);if(s)return e[Y]=s;this.sheet=new i.CSSStyleSheet,Se.set(r,this)}else this.styleTag=r.createElement("style"),t&&this.styleTag.setAttribute("nonce",t);this.modules=[],e[Y]=this}mount(e,t){let r=this.sheet,i=0,s=0,o=!1;for(let a=0;a<e.length;a++){let l=e[a],h=this.modules.indexOf(l);if(h<s&&h>-1&&(this.modules.splice(h,1),o=!0,s--,h=-1),h==-1){if(this.modules.splice(s++,0,l),o=!0,r)for(let f=0;f<l.rules.length;f++)r.insertRule(l.rules[f],i++)}else{for(;s<h;)i+=this.modules[s++].rules.length;i+=l.rules.length,s++}}if(r)t.adoptedStyleSheets.indexOf(this.sheet)<0&&(t.adoptedStyleSheets=[this.sheet,...t.adoptedStyleSheets]);else{if(o){let l="";for(let h=0;h<this.modules.length;h++)l+=this.modules[h].getRules()+`
`;this.styleTag.textContent=l}let a=t.head||t;this.styleTag.parentNode!=a&&a.insertBefore(this.styleTag,a.firstChild)}}setNonce(e){this.styleTag&&this.styleTag.getAttribute("nonce")!=e&&this.styleTag.setAttribute("nonce",e)}};function ve(n,e,t,r=0,i=0){e==null&&(e=n.search(/[^\s\u00a0]/),e==-1&&(e=n.length));let s=i;for(let o=r;o<e;o++)n.charCodeAt(o)==9?s+=t-s%t:s++;return s}var P=class{constructor(e,t,r,i){this.string=e,this.tabSize=t,this.indentUnit=r,this.overrideIndent=i,this.pos=0,this.start=0,this.lastColumnPos=0,this.lastColumnValue=0}eol(){return this.pos>=this.string.length}sol(){return this.pos==0}peek(){return this.string.charAt(this.pos)||void 0}next(){if(this.pos<this.string.length)return this.string.charAt(this.pos++)}eat(e){let t=this.string.charAt(this.pos),r;if(typeof e=="string"?r=t==e:r=t&&(e instanceof RegExp?e.test(t):e(t)),r)return++this.pos,t}eatWhile(e){let t=this.pos;for(;this.eat(e););return this.pos>t}eatSpace(){let e=this.pos;for(;/[\s\u00a0]/.test(this.string.charAt(this.pos));)++this.pos;return this.pos>e}skipToEnd(){this.pos=this.string.length}skipTo(e){let t=this.string.indexOf(e,this.pos);if(t>-1)return this.pos=t,!0}backUp(e){this.pos-=e}column(){return this.lastColumnPos<this.start&&(this.lastColumnValue=ve(this.string,this.start,this.tabSize,this.lastColumnPos,this.lastColumnValue),this.lastColumnPos=this.start),this.lastColumnValue}indentation(){var e;return(e=this.overrideIndent)!==null&&e!==void 0?e:ve(this.string,null,this.tabSize)}match(e,t,r){if(typeof e=="string"){let i=o=>r?o.toLowerCase():o,s=this.string.substr(this.pos,e.length);return i(s)==i(e)?(t!==!1&&(this.pos+=e.length),!0):null}else{let i=this.string.slice(this.pos).match(e);return i&&i.index>0?null:(i&&t!==!1&&(this.pos+=i[0].length),i)}}current(){return this.string.slice(this.start,this.pos)}};var te,A=new v;function $e(n){return T.define({combine:n?e=>e.concat(n):void 0})}var yt=new v,p=class{constructor(e,t,r=[],i=""){this.data=e,this.name=i,M.prototype.hasOwnProperty("tree")||Object.defineProperty(M.prototype,"tree",{get(){return y(this)}}),this.parser=t,this.extension=[x.of(this),M.languageData.of((s,o,a)=>{let l=xe(s,o,a),h=l.type.prop(A);if(!h)return[];let f=s.facet(h),c=l.type.prop(yt);if(c){let m=l.resolve(o-l.from,a);for(let d of c)if(d.test(m,s)){let k=s.facet(d.facet);return d.type=="replace"?k:k.concat(f)}}return f})].concat(r)}isActiveAt(e,t,r=-1){return xe(e,t,r).type.prop(A)==this.data}findRegions(e){let t=e.facet(x);if(t?.data==this.data)return[{from:0,to:e.doc.length}];if(!t||!t.allowsNesting)return[];let r=[],i=(s,o)=>{if(s.prop(A)==this.data){r.push({from:o,to:o+s.length});return}let a=s.prop(v.mounted);if(a){if(a.tree.prop(A)==this.data){if(a.overlay)for(let l of a.overlay)r.push({from:l.from+o,to:l.to+o});else r.push({from:o,to:o+s.length});return}else if(a.overlay){let l=r.length;if(i(a.tree,a.overlay[0].from+o),r.length>l)return}}for(let l=0;l<s.children.length;l++){let h=s.children[l];h instanceof b&&i(h,s.positions[l]+o)}};return i(y(e),0),r}get allowsNesting(){return!0}};p.setState=Q.define();function xe(n,e,t){let r=n.facet(x),i=y(n).topNode;if(!r||r.allowsNesting)for(let s=i;s;s=s.enter(e,t,Te.ExcludeBuffers|Te.EnterBracketed))s.type.isTop&&(i=s);return i}var Ce=class n extends p{constructor(e,t,r){super(e,t,[],r),this.parser=t}static define(e){let t=$e(e.languageData);return new n(t,e.parser.configure({props:[A.add(r=>r.isTop?t:void 0)]}),e.name)}configure(e,t){return new n(this.data,this.parser.configure(e),t||this.name)}get allowsNesting(){return this.parser.hasWrappers()}};function y(n){let e=n.field(p.state,!1);return e?e.tree:b.empty}function wt(n,e,t=50){var r;let i=(r=n.field(p.state,!1))===null||r===void 0?void 0:r.context;if(!i)return null;let s=i.viewport;i.updateViewport({from:0,to:e});let o=i.isDone(e)||i.work(t,e)?i.tree:null;return i.updateViewport(s),o}function kn(n,e=n.doc.length){var t;return((t=n.field(p.state,!1))===null||t===void 0?void 0:t.context.isDone(e))||!1}function bn(n,e=n.viewport.to,t=100){let r=wt(n.state,e,t);return r!=y(n.state)&&n.dispatch({}),!!r}function yn(n){var e;return((e=n.plugin(_e))===null||e===void 0?void 0:e.isWorking())||!1}var ie=class{constructor(e){this.doc=e,this.cursorPos=0,this.string="",this.cursor=e.iter()}get length(){return this.doc.length}syncTo(e){return this.string=this.cursor.next(e-this.cursorPos).value,this.cursorPos=e+this.string.length,this.cursorPos-this.string.length}chunk(e){return this.syncTo(e),this.string}get lineChunks(){return!0}read(e,t){let r=this.cursorPos-this.string.length;return e<r||t>=this.cursorPos?this.doc.sliceString(e,t):this.string.slice(e-r,t-r)}},N=null,R=class n{constructor(e,t,r=[],i,s,o,a,l){this.parser=e,this.state=t,this.fragments=r,this.tree=i,this.treeLen=s,this.viewport=o,this.skipped=a,this.scheduleOn=l,this.parse=null,this.tempSkipped=[]}static create(e,t,r){return new n(e,t,[],b.empty,0,r,[],null)}startParse(){return this.parser.startParse(new ie(this.state.doc),this.fragments Line truncated
`&&(t="");else{let r=t.indexOf(`
`);r>-1&&(t=t.slice(0,r))}return e+t.length<=this.to?t:t.slice(0,this.to-e)}nextLine(){let e=this.parsedPos,t=this.lineAfter(e),r=e+t.length;for(let i=this.rangeIndex;;){let s=this.ranges[i].to;if(s>=r||(t=t.slice(0,s-(r-t.length)),i++,i==this.ranges.length))break;let o=this.ranges[i].from,a=this.lineAfter(o);t+=a,r=o+a.length}return{line:t,end:r}}skipGapsTo(e,t,r){for(;;){let i=this.ranges[this.rangeIndex].to,s=e+t;if(r>0?i>s:i>=s)break;let o=this.ranges[++this.rangeIndex].from;t+=o-i}return t}moveRangeIndex(){for(;this.ranges[this.rangeIndex].to<this.parsedPos;)this.rangeIndex++}emitToken(e,t,r,i){let s=4;if(this.ranges.length>1){i=this.skipGapsTo(t,i,1),t+=i;let a=this.chunk.length;i=this.skipGapsTo(r,i,-1),r+=i,s+=this.chunk.length-a}let o=this.chunk.length-4;return this.lang.streamParser.mergeTokens&&s==4&&o>=0&&this.chunk[o]==e&&this.chunk[o+2]==t?this.chunk[o+2]=r:this.chunk.push(e,t,r,s),i}parseLine(e){let{line:t,end:r}=this.nextLine(),i=0,{streamParser:s}=this.lang,o=new P(t,e?e.state.tabSize:4,e?q(e.state):2);if(o.eol())s.blankLine(this.state,o.indentUnit);else for(;!o.eol();){let a=ot(s.token,o,this.state);if(a&&(i=this.emitToken(this.lang.tokenTable.resolve(a),this.parsedPos+o.start,this.parsedPos+o.pos,i)),o.start>1e4)break}this.parsedPos=r,this.moveRangeIndex(),this.parsedPos<this.to&&this.parsedPos++}finishChunk(){let e=b.build({buffer:this.chunk,start:this.chunkStart,length:this.parsedPos-this.chunkStart,nodeSet:tn,topID:0,maxBufferLength:512,reused:this.chunkReused});e=new b(e.type,e.children,e.positions,e.length,[[this.lang.stateAfter,this.lang.streamParser.copyState(this.state)]]),this.chunks.push(e),this.chunkPos.push(this.chunkStart-this.ranges[0].from),this.chunk=[],this.chunkReused=void 0,this.chunkStart=this.parsedPos}finish(){return new b(this.lang.topNode,this.chunks,this.chunkPos,this.parsedPos-this.ranges[0].from).balance()}};function ot(n,e,t){e.start=e.pos;for(let r=0;r<10;r++){let i=n(e,t);if(e.pos>e.start)return i}throw new Error("Stream parser failed to advance stream.")}var be=Object.create(null),W=[Z.none],tn=new ut(W),Re=[],Le=Object.create(null),lt=Object.create(null);for(let[n,e]of[["variable","variableName"],["variable-2","variableName.special"],["string-2","string.special"],["def","variableName.definition"],["tag","tagName"],["attribute","attributeName"],["type","typeName"],["builtin","variableName.standard"],["qualifier","modifier"],["error","invalid"],["header","heading"],["property","propertyName"]])lt[n]=at(be,e);var K=class{constructor(e){this.extra=e,this.table=Object.assign(Object.create(null),lt)}resolve(e){return e?this.table[e]||(this.table[e]=at(this.extra,e)):0}},nn=new K(be);function re(n,e){Re.indexOf(n)>-1||(Re.push(n),console.warn(e))}function at(n,e){let t=[];for(let a of e.split(" ")){let l=[];for(let h of a.split(".")){let f=n[h]||u[h];f?typeof f=="function"?l.length?l=l.map(f):re(h,`Modifier ${h} used at start of tag`):l.length?re(h,`Tag ${h} used as modifier`):l=Array.isArray(f)?f:[f]:re(h,`Unknown highlighting tag ${h}`)}for(let h of l)t.push(h)}if(!t.length)return 0;let r=e.replace(/ /g,"_"),i=r+" "+t.map(a=>a.id),s=Le[i];if(s)return s.id;let o=Le[i]=Z.define({id:W.length,name:r,props:[bt({[r]:t})]});return W.push(o),o.id}function rn(n,e){let t=Z.define({id:W.length,name:"Document",props:[A.add(()=>n),Ke.add(()=>r=>e.getIndent(r))],top:!0});return W.push(t),t}function ht(n){return n.length<=4096&&/[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac\ufb50-\ufdff]/.test(n)}function ft(n){for(let e=n.iter();!e.next().done;)if(ht(e.value))return!0;return!1}function sn(n){let e=!1;return n.iterChanges((t,r,i,s,o)=>{!e&&ft(o)&&(e=!0)}),e}var ce=T.define({combine:n=>n.some(e=>e)});function Ln(n={}){let e=[on];return n.alwaysIsolate&&e.push(ce.of(!0)),e}var on=j.fromClass(class{constructor(n){this.always=n.state.facet(ce)||n.textDirection!=_.LTR||n.state.facet(S.perLineTextDirection),this.hasRTL=!this.always&&ft(n.state.doc),this.tree=y(n.state),this.decorations=this.always||this.hasRTL?Ee(n,this.tree,this.always):g.none}update(n){let e=n.state.facet(ce)||n.view.textDirection!=_.LTR||n.state.facet(S.perLineTextDirection);if(!e&&!this.hasRTL&&sn(n.changes)&&(this.hasRTL=!0),!e&&!this.hasRTL)return;let t=y(n.state);(e!=this.always||t!=this.tree||n.docChanged||n.viewportChanged)&&(this.tree=t,this.always=e,this.decorations=Ee(n.view,t,e))}},{provide:n=>{function e(t){var r,i;return(i=(r=t.plugin(n))===null||r===void 0?void 0:r.decorations)!==null&&i!==void 0?i:g.none}return[S.outerDecorations.of(e),Ve.lowest(S.bidiIsolatedRanges.of(e))]}});function Ee(n,e,t){let r=new de,i=n.visibleRanges;t||(i=ln(i,n.state.doc));for(let{from:s,to:o}of i)e.iterate({enter:a=>{let l=a.type.prop(v.isolate);l&&r.add(a.from,a.to,an[l])},from:s,to:o});return r.finish()}function ln(n,e){let t=e.iter(),r=0,i=[],s=null;for(let{from:o,to:a}of n)if(!(s&&s.to>o&&(o=s.to,o>=a)))for(r+t.value.length<o&&(t.next(o-(r+t.value.length)),r=o);;){let l=r,h=r+t.value.length;if(!t.lineBreak&&ht(t.value)&& Line truncated
//# sourceMappingURL=language.bundle.mjs.map
+3
View File
@@ -0,0 +1,3 @@
/* esm.sh - @codemirror/lint@6.9.7 */
import{Decoration as T,showPanel as re,EditorView as E,ViewPlugin as ae,gutter as ce,showTooltip as fe,hoverTooltip as de,getPanel as ue,activateHover as U,logException as me,WidgetType as he,GutterMarker as ge}from"./cm-view.mjs";import{StateEffect as R,StateField as G,Facet as Z,combineConfig as X,RangeSet as j,RangeSetBuilder as pe}from"./cm-state.mjs";function k(){var t=arguments[0];typeof t=="string"&&(t=document.createElement(t));var e=1,n=arguments[1];if(n&&typeof n=="object"&&n.nodeType==null&&!Array.isArray(n)){for(var i in n)if(Object.prototype.hasOwnProperty.call(n,i)){var o=n[i];typeof o=="string"?t.setAttribute(i,o):o!=null&&(t[i]=o)}e++}for(;e<arguments.length;e++)_(t,arguments[e]);return t}function _(t,e){if(typeof e=="string")t.appendChild(document.createTextNode(e));else if(e!=null)if(e.nodeType!=null)t.appendChild(e);else if(Array.isArray(e))for(var n=0;n<e.length;n++)_(t,e[n]);else throw new RangeError("Unsupported child node: "+e)}var L=class{constructor(e,n,i){this.from=e,this.to=n,this.diagnostic=i}},C=class t{constructor(e,n,i){this.diagnostics=e,this.panel=n,this.selected=i}static init(e,n,i){let o=i.facet(y).markerFilter;o&&(e=o(e,i));let s=e.slice().sort((h,b)=>h.from-b.from||h.to-b.to),r=new pe,l=[],c=0,a=i.doc.iter(),f=0,u=i.doc.length;for(let h=0;;){let b=h==s.length?null:s[h];if(!b&&!l.length)break;let w,g;if(l.length)w=c,g=l.reduce((d,x)=>Math.min(d,x.to),b&&b.from>w?b.from:1e8);else{if(w=b.from,w>u)break;g=b.to,l.push(b),h++}for(;h<s.length;){let d=s[h];if(d.from==w&&(d.to>d.from||d.to==w))l.push(d),h++,g=Math.min(d.to,g);else{g=Math.min(d.from,g);break}}g=Math.min(g,u);let P=!1;if(l.some(d=>d.from==w&&(d.to==g||g==u))&&(P=w==g,!P&&g-w<10)){let d=w-(f+a.value.length);d>0&&(a.next(d),f=w);for(let x=w;;){if(x>=g){P=!0;break}if(!a.lineBreak&&f+a.value.length>x)break;x=f+a.value.length,f+=a.value.length,a.next()}}let N=oe(l);if(P)r.add(w,w,T.widget({widget:new B(N),diagnostics:l.slice()}));else{let d=l.reduce((x,V)=>V.markClass?x+" "+V.markClass:x,"");r.add(w,g,T.mark({class:"cm-lintRange cm-lintRange-"+N+d,diagnostics:l.slice(),inclusiveEnd:l.some(x=>x.to>g)}))}if(c=g,c==u)break;for(let d=0;d<l.length;d++)l[d].to<=c&&l.splice(d--,1)}let m=r.finish();return new t(m,n,v(m))}};function v(t,e=null,n=0){let i=null;return t.between(n,1e9,(o,s,{spec:r})=>{if(!(e&&r.diagnostics.indexOf(e)<0))if(!i)i=new L(o,s,e||r.diagnostics[0]);else{if(r.diagnostics.indexOf(i.diagnostic)<0)return!1;i=new L(i.from,s,i.diagnostic)}}),i}function W(t,e){let n=e.pos,i=e.end||n,o=t.state.facet(y).hideOn(t,n,i);if(o!=null)return o;let s=t.startState.doc.lineAt(e.pos);return!!(t.effects.some(r=>r.is(I))||t.changes.touchesRange(s.from,Math.max(s.to,i)))}function J(t,e){return t.field(p,!1)?e:e.concat(R.appendConfig.of(le))}function we(t,e){return{effects:J(t,[I.of(e)])}}var I=R.define(),z=R.define(),Q=R.define(),p=G.define({create(){return new C(T.none,null,null)},update(t,e){if(e.docChanged&&t.diagnostics.size){let n=t.diagnostics.map(e.changes),i=null,o=t.panel;if(t.selected){let s=e.changes.mapPos(t.selected.from,1);i=v(n,t.selected.diagnostic,s)||v(n,null,s)}!n.size&&o&&e.state.facet(y).autoPanel&&(o=null),t=new C(n,o,i)}for(let n of e.effects)if(n.is(I)){let i=e.state.facet(y).autoPanel?n.value.length?S.open:null:t.panel;t=C.init(n.value,i,e.state)}else n.is(z)?t=new C(t.diagnostics,n.value?S.open:null,t.selected):n.is(Q)&&(t=new C(t.diagnostics,t.panel,n.value));return t},provide:t=>[re.from(t,e=>e.panel),E.decorations.from(t,e=>e.diagnostics)]});function Ie(t){let e=t.field(p,!1);return e?e.diagnostics.size:0}var be=T.mark({class:"cm-lintRange cm-lintRange-active"});function ke(t,e,n){let{diagnostics:i}=t.state.field(p),o,s=-1,r=-1;i.between(e-(n<0?1:0),e+(n>0?1:0),(c,a,{spec:f})=>{if(e>=c&&e<=a&&(c==a||(e>c||n>0)&&(e<a||n<0)))return o=f.diagnostics,s=c,r=a,!1});let l=t.state.facet(y).tooltipFilter;return o&&l&&(o=l(o,t.state)),o?{pos:s,end:r,above:!0,create(){return{dom:ee(t,o)}}}:null}function ee(t,e){return k("ul",{class:"cm-tooltip-lint"},e.map(n=>ne(t,n,!1)))}var xe=t=>{let e=t.state.field(p,!1);(!e||!e.panel)&&t.dispatch({effects:J(t.state,[z.of(!0)])});let n=ue(t,S.open);return n&&n.dom.querySelector(".cm-panel-lint ul").focus(),!0},Y=t=>{let e=t.state.field(p,!1);return!e||!e.panel?!1:(t.dispatch({effects:z.of(!1)}),!0)},ye=t=>{let e=t.state.field(p,!1);if(!e)return!1;let n=t.state.selection.main,i=v(e.diagnostics,null,n.to+1);return!i&&(i=v(e.diagnostics,null,0),!i||i.from==n.from&&i.to==n.to)?!1:(t.dispatch({selection:{anchor:i.from,head:i.to},scrollIntoView:!0}),U(t,i.from,1,{tooltip:K,until:o=>o.docChanged||o.newSelection.main.head<i.from||o.newSelection.main.head>i.to}),!0)},Oe=t=>{var e;let{state:n}=t,i=n.field(p,!1);if(!i)return!1;let o=n.selection.main,s,r,l,c;if(i.diagnostics.between(0,n.doc.length,(u,m)=>{m<o.to&&(s==null||s<u)&&(s=u,r=m),(l==null||u>l)&&(l=u,c=m)}),l==null||s==null&&l==o.from)return!1;let a=s??l,f=(e=r??c)!==null&&e!==void 0?e:a;return t.dispatch({selection:{anchor:aLine truncated
//# sourceMappingURL=lint.bundle.mjs.map
+4
View File
@@ -0,0 +1,4 @@
/* esm.sh - @codemirror/search@6.7.2 */
import{getDialog as ge,showDialog as xe,EditorView as M,Decoration as m,ViewPlugin as ie,showPanel as ye,runScopeHandlers as Me,getPanel as se}from"./cm-view.mjs";import{codePointAt as ve,fromCodePoint as Se,codePointSize as be,EditorSelection as g,Facet as le,combineConfig as oe,CharCategory as p,StateEffect as G,StateField as Ce,RangeSetBuilder as ke,Prec as Le,EditorState as We,findClusterBreak as ce}from"./cm-state.mjs";function u(){var t=arguments[0];typeof t=="string"&&(t=document.createElement(t));var e=1,r=arguments[1];if(r&&typeof r=="object"&&r.nodeType==null&&!Array.isArray(r)){for(var n in r)if(Object.prototype.hasOwnProperty.call(r,n)){var i=r[n];typeof i=="string"?t.setAttribute(n,i):i!=null&&(t[n]=i)}e++}for(;e<arguments.length;e++)ee(t,arguments[e]);return t}function ee(t,e){if(typeof e=="string")t.appendChild(document.createTextNode(e));else if(e!=null)if(e.nodeType!=null)t.appendChild(e);else if(Array.isArray(e))for(var r=0;r<e.length;r++)ee(t,e[r]);else throw new RangeError("Unsupported child node: "+e)}var te=typeof String.prototype.normalize=="function"?t=>t.normalize("NFKD"):t=>t,y=class{constructor(e,r,n=0,i=e.length,s,l){this.test=l,this.value={from:0,to:0,precise:!1},this.done=!1,this.matches=[],this.buffer="",this.bufferPos=0,this.iter=e.iterRange(n,i),this.bufferStart=n,this.normalize=s?o=>s(te(o)):te,this.query=this.normalize(r)}peek(){if(this.bufferPos==this.buffer.length){if(this.bufferStart+=this.buffer.length,this.iter.next(),this.iter.done)return-1;this.bufferPos=0,this.buffer=this.iter.value}return ve(this.buffer,this.bufferPos)}next(){for(;this.matches.length;)this.matches.pop();return this.nextOverlapping()}nextOverlapping(){for(;;){let e=this.peek();if(e<0)return this.done=!0,this;let r=Se(e),n=this.bufferStart+this.bufferPos;this.bufferPos+=be(e);let i=this.normalize(r);if(i.length)for(let s=0,l=n,o=!0;;s++){let c=i.charCodeAt(s),a=this.match(c,l,o,this.bufferPos+this.bufferStart,s==i.length-1);if(a)return this.value=a,this;if(s==i.length-1)break;o&&s<r.length&&r.charCodeAt(s)==c?l++:o=!1}}}match(e,r,n,i,s){let l=null;for(let o=0;o<this.matches.length;){let c=this.matches[o],a=!1;this.query.charCodeAt(c.index)==e&&(c.index==this.query.length-1?l={from:c.from,to:i,precise:s&&c.precise}:(c.index++,a=!0)),a?o++:this.matches.splice(o,1)}return this.query.charCodeAt(0)==e&&(this.query.length==1?l={from:r,to:i,precise:n&&s}:this.matches.push({from:r,index:1,precise:n})),l&&this.test&&!this.test(l.from,l.to,this.buffer,this.bufferStart)&&(l=null),l}};typeof Symbol<"u"&&(y.prototype[Symbol.iterator]=function(){return this});var ae={from:-1,to:-1,match:/.*/.exec(""),precise:!0},Q="gm"+(/x/.unicode==null?"":"u"),q=class{constructor(e,r,n,i=0,s=e.length){if(this.text=e,this.to=s,this.curLine="",this.done=!1,this.value=ae,/\\[sWDnr]|\n|\r|\[\^/.test(r))return new E(e,r,n,i,s);this.re=new RegExp(r,Q+(n?.ignoreCase?"i":"")),this.test=n?.test,this.iter=e.iter();let l=e.lineAt(i);this.curLineStart=l.from,this.matchPos=R(e,i),this.getLine(this.curLineStart)}getLine(e){this.iter.next(e),this.iter.lineBreak?this.curLine="":(this.curLine=this.iter.value,this.curLineStart+this.curLine.length>this.to&&(this.curLine=this.curLine.slice(0,this.to-this.curLineStart)),this.iter.next())}nextLine(){this.curLineStart=this.curLineStart+this.curLine.length+1,this.curLineStart>this.to?this.curLine="":this.getLine(0)}next(){for(let e=this.matchPos-this.curLineStart;;){this.re.lastIndex=e;let r=this.matchPos<=this.to&&this.re.exec(this.curLine);if(r){let n=this.curLineStart+r.index,i=n+r[0].length;if(this.matchPos=R(this.text,i+(n==i?1:0)),n==this.curLineStart+this.curLine.length&&this.nextLine(),(n<i||n>this.value.to)&&(!this.test||this.test(n,i,r)))return this.value={from:n,to:i,precise:!0,match:r},this;e=this.matchPos-this.curLineStart}else if(this.curLineStart+this.curLine.length<this.to)this.nextLine(),e=0;else return this.done=!0,this}}},B=new WeakMap,w=class t{constructor(e,r){this.from=e,this.text=r}get to(){return this.from+this.text.length}static get(e,r,n){let i=B.get(e);if(!i||i.from>=n||i.to<=r){let o=new t(r,e.sliceString(r,n));return B.set(e,o),o}if(i.from==r&&i.to==n)return i;let{text:s,from:l}=i;return l>r&&(s=e.sliceString(r,l)+s,l=r),i.to<n&&(s+=e.sliceString(i.to,n)),B.set(e,new t(l,s)),new t(r,s.slice(r-l,n-l))}},E=class{constructor(e,r,n,i,s){this.text=e,this.to=s,this.done=!1,this.value=ae,this.matchPos=R(e,i),this.re=new RegExp(r,Q+(n?.ignoreCase?"i":"")),this.test=n?.test,this.flat=w.get(e,i,this.chunkEnd(i+5e3))}chunkEnd(e){return e>=this.to?this.to:this.text.lineAt(e).to}next(){for(;;){let e=this.re.lastIndex=this.matchPos-this.flat.from,r=this.re.exec(this.flat.text);if(r&&!r[0]&&r.index==e&&(this.re.lastIndex=e+1,r=this.re.exec(this.flat.text)),r){let n=this.flat.from+r.index,i=n+r[0].length;if((this.flat.to>=this.to||r.index+r[0].length<=this.flat.text.length-10)&&(!this.test||this.test(n,i,r)))return this.value={from:n,to:i,precise:!0,match:r},this.matchPos=R(this.text,i+(Line truncated
`:n=="r"?"\r":n=="t"?" ":"\\")}eq(e){return this.search==e.search&&this.replace==e.replace&&this.caseSensitive==e.caseSensitive&&this.regexp==e.regexp&&this.wholeWord==e.wholeWord&&this.test==e.test}create(){return this.regexp?new H(this):new V(this)}getCursor(e,r=0,n){let i=e.doc?e:We.create({doc:e});return n==null&&(n=i.doc.length),this.regexp?b(this,i,r,n):S(this,i,r,n)}},I=class{constructor(e){this.spec=e}};function $e(t,e,r){return(n,i,s,l)=>{if(r&&!r(n,i,s,l))return!1;let o=n>=l&&i<=l+s.length?s.slice(n-l,i-l):e.doc.sliceString(n,i);return t(o,e,n,i)}}function S(t,e,r,n){let i;return t.wholeWord&&(i=_e(e.doc,e.charCategorizer(e.selection.main.head))),t.test&&(i=$e(t.test,e,i)),new y(e.doc,t.unquoted,r,n,t.caseSensitive?void 0:s=>s.toLowerCase(),i)}function _e(t,e){return(r,n,i,s)=>((s>r||s+i.length<n)&&(s=Math.max(0,r-2),i=t.sliceString(s,Math.min(t.length,n+2))),(e(T(i,r-s))!=p.Word||e(O(i,r-s))!=p.Word)&&(e(O(i,n-s))!=p.Word||e(T(i,n-s))!=p.Word))}var V=class extends I{constructor(e){super(e)}nextMatch(e,r,n){let i=S(this.spec,e,n,e.doc.length).nextOverlapping();if(i.done){let s=Math.min(e.doc.length,r+this.spec.unquoted.length);i=S(this.spec,e,0,s).nextOverlapping()}return i.done||i.value.from==r&&i.value.to==n?null:i.value}prevMatchInRange(e,r,n){for(let i=n;;){let s=Math.max(r,i-1e4-this.spec.unquoted.length),l=S(this.spec,e,s,i),o=null;for(;!l.nextOverlapping().done;)o=l.value;if(o)return o;if(s==r)return null;i-=1e4}}prevMatch(e,r,n){let i=this.prevMatchInRange(e,0,r);return i||(i=this.prevMatchInRange(e,Math.max(0,n-this.spec.unquoted.length),e.doc.length)),i&&(i.from!=r||i.to!=n)?i:null}getReplacement(e){return this.spec.unquote(this.spec.replace)}matchAll(e,r){let n=S(this.spec,e,0,e.doc.length),i=[];for(;!n.next().done;){if(i.length>=r)return null;i.push(n.value)}return i}highlight(e,r,n,i){let s=S(this.spec,e,Math.max(0,r-this.spec.unquoted.length),Math.min(n+this.spec.unquoted.length,e.doc.length));for(;!s.next().done;)i(s.value.from,s.value.to)}};function Ne(t,e,r){return(n,i,s)=>(!r||r(n,i,s))&&t(s[0],e,n,i)}function b(t,e,r,n){let i;return t.wholeWord&&(i=ze(e.charCategorizer(e.selection.main.head))),t.test&&(i=Ne(t.test,e,i)),new q(e.doc,t.search,{ignoreCase:!t.caseSensitive,test:i},r,n)}function T(t,e){return t.slice(ce(t,e,!1),e)}function O(t,e){return t.slice(e,ce(t,e))}function ze(t){return(e,r,n)=>!n[0].length||(t(T(n.input,n.index))!=p.Word||t(O(n.input,n.index))!=p.Word)&&(t(O(n.input,n.index+n[0].length))!=p.Word||t(T(n.input,n.index+n[0].length))!=p.Word)}var H=class extends I{nextMatch(e,r,n){let i=b(this.spec,e,n,e.doc.length).next();return i.done&&(i=b(this.spec,e,0,r).next()),i.done?null:i.value}prevMatchInRange(e,r,n){for(let i=1;;i++){let s=Math.max(r,n-i*1e4),l=b(this.spec,e,s,n),o=null;for(;!l.next().done;)o=l.value;if(o&&(s==r||o.from>s+10))return o;if(s==r)return null}}prevMatch(e,r,n){return this.prevMatchInRange(e,0,r)||this.prevMatchInRange(e,n,e.doc.length)}getReplacement(e){return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g,(r,n)=>{if(n=="&")return e.match[0];if(n=="$")return"$";for(let i=n.length;i>0;i--){let s=+n.slice(0,i);if(s>0&&s<e.match.length)return e.match[s]+n.slice(i)}return r})}matchAll(e,r){let n=b(this.spec,e,0,e.doc.length),i=[];for(;!n.next().done;){if(i.length>=r)return null;i.push(n.value)}return i}highlight(e,r,n,i){let s=b(this.spec,e,Math.max(0,r-250),Math.min(n+250,e.doc.length));for(;!s.next().done;)i(s.value.from,s.value.to)}},L=G.define(),U=G.define(),x=Ce.define({create(t){return new k(N(t).create(),null)},update(t,e){for(let r of e.effects)r.is(L)?t=new k(r.value.create(),t.panel):r.is(U)&&(t=new k(t.query,r.value?J:null));return t},provide:t=>ye.from(t,e=>e.panel)});function tt(t){let e=t.field(x,!1);return e?e.query.spec:N(t)}function rt(t){var e;return((e=t.field(x,!1))===null||e===void 0?void 0:e.panel)!=null}var k=class{constructor(e,r){this.query=e,this.panel=r}},Be=m.mark({class:"cm-searchMatch"}),Ve=m.mark({class:"cm-searchMatch cm-searchMatch-selected"}),He=ie.fromClass(class{constructor(t){this.view=t,this.decorations=this.highlight(t.state.field(x))}update(t){let e=t.state.field(x);(e!=t.startState.field(x)||t.docChanged||t.selectionSet||t.viewportChanged)&&(this.decorations=this.highlight(e))}highlight({query:t,panel:e}){if(!e||!t.spec.valid)return m.none;let{view:r}=this,n=new ke;for(let i=0,s=r.visibleRanges,l=s.length;i<l;i++){let{from:o,to:c}=s[i];for(;i<l-1&&c>s[i+1].from-500;)c=s[++i].to;t.highlight(r.state,o,c,(a,h)=>{let f=r.state.selection.ranges.some(A=>A.from==a&&A.to==h);n.add(a,h,f?Ve:Be)})}return n.finish()}},{decorations:t=>t.decorations});function W(t){return e=>{let r=e.state.field(x,!1);return r&&r.query.spec.valid?t(e,r):de(e)}}var $=W((t,{query:e})=>{let{to:r}=t.state.selection.main,n=e.nextMatch(t.state,r,r);if(!n)return!1;let i=g.single(n.from,n.to),s=t.state.facet(v);return t.dispatch({selection:i,effects:[X(t,n),s.scrollToMatch(i.main,t)],userEvent:"select.search"}),fe(t),!0}),_=W( Line truncated
//# sourceMappingURL=search.bundle.mjs.map
+7
View File
@@ -0,0 +1,7 @@
/* esm.sh - @codemirror/state@6.7.6 */
var fe=[],Te=[];(()=>{let s="lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,1n,9,16,o,,x,1i,3,,i,,7,a,2,t,3,1k,,,7,2,2,2,3,9,,a,2,q,,2,3,1k,,,5,4,2,2,3,3,,u,2,3,,b,3,1k,,,8,,3,,3,k,2,m,6,,3,1k,,,7,2,2,2,3,7,3,a,2,u,,1n,5,3,3,,4,9,,14,5,1j,,,7,,3,,4,7,2,b,2,t,3,1k,,,7,,3,,4,7,2,b,2,f,,c,4,1j,2,,7,,3,,4,9,,a,2,t,3,1y,,4,6,,,,8,i,2,1p,,,8,c,8,2q,,,a,b,7,21,2,r,,,,,,4,2,1d,k,,2,5,b,,10,9,,2u,b,,6,n,4,4,3,g,4,d,,,3,6,,f,,jj,3,qa,4,s,3,t,2,u,2,1s,w,9,,19,3,,,39,2,y,,3a,c,4,c,63,5,1l,a,,,,,2,o,2,,1c,1a,2,c,k,5,1b,h,12,9,c,3,u,d,1k,e,1c,k,48,3,,l,4,,6,,2,3,5i,1s,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,n,5,4,,2b,2,1e,i,q,i,d,,12,8,p,d,18,4,1b,e,10,,1v,e,c,,8,2,1a,,1f,,,3,2,2,5,2,,,15,5,5,2,6k,8,,2,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,1t,5,8t,2,25,6,1y,b,1d,4,3e,3,1h,f,15,,2,2,a,4,19,b,7,,1p,3,10,e,g,2,18,,c,3,1c,e,8,4,,2,2k,c,6,,2,,4d,c,l,4,1j,2,,7,2,2,2,3,9,,a,2,2,7,3,5,1v,9,,,2,,,4,,5,,,e,2,2a,i,n,,29,k,6j,7,2,9,r,2,2a,h,2y,d,2t,3,2,a,74,f,6t,6,,2,2,4,,,,2,3x,7,2,7,3,,s,a,14,7,,4,8,,9,b,1a,g,5i,8,5j,8,,8,2a,m,,e,3e,6,3,,,2,,7,,,1u,5,,2,,5,9n,4,9,2,,,1c,7,3,5,n,,44l,,6,f,8ug,i,1xc,5,1n,7,t4,,,1j,7,4,29,,b,2,f57,2,3mp,1a,2,n,f2,5,3,6,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,2s,,4g,7,af,,1p,4,e4,4,72,2,6r,,2,,7,2,5,,d6,7,31,7,240,5".split(",").map(e=>e?parseInt(e,36):1);for(let e=0,t=0;e<s.length;e++)(e%2?Te:fe).push(t=t+s[e])})();function Xe(s){if(s<768)return!1;for(let e=0,t=fe.length;;){let n=e+t>>1;if(s<fe[n])t=n;else if(s>=Te[n])e=n+1;else return!0;if(e==t)return!1}}function Ce(s){return s>=127462&&s<=127487}var Oe=8205;function Me(s,e,t=!0,n=!0){return(t?Be:Ye)(s,e,n)}function Be(s,e,t){if(e==s.length)return e;e&&Fe(s.charCodeAt(e))&&Le(s.charCodeAt(e-1))&&e--;let n=ae(s,e);for(e+=Re(n);e<s.length;){let i=ae(s,e);if(n==Oe||i==Oe||t&&Xe(i))e+=Re(i),n=i;else if(Ce(i)){let r=0,l=e-2;for(;l>=0&&Ce(ae(s,l));)r++,l-=2;if(r%2==0)break;e+=2}else break}return e}function Ye(s,e,t){for(;e>1;){let n=Be(s,e-2,t);if(n<e)return n;e--}return 0}function ae(s,e){let t=s.charCodeAt(e);if(!Le(t)||e+1==s.length)return t;let n=s.charCodeAt(e+1);return Fe(n)?(t-55296<<10)+(n-56320)+65536:t}function Fe(s){return s>=56320&&s<57344}function Le(s){return s>=55296&&s<56320}function Re(s){return s<65536?1:2}var w=class s{lineAt(e){if(e<0||e>this.length)throw new RangeError(`Invalid position ${e} in document of length ${this.length}`);return this.lineInner(e,!1,1,0)}line(e){if(e<1||e>this.lines)throw new RangeError(`Invalid line number ${e} in ${this.lines}-line document`);return this.lineInner(e,!0,1,0)}replace(e,t,n){[e,t]=q(this,e,t);let i=[];return this.decompose(0,e,i,2),n.length&&n.decompose(0,n.length,i,3),this.decompose(t,this.length,i,1),V.from(i,this.length-(t-e)+n.length)}append(e){return this.replace(this.length,this.length,e)}slice(e,t=this.length){[e,t]=q(this,e,t);let n=[];return this.decompose(e,t,n,0),V.from(n,t-e)}eq(e){if(e==this)return!0;if(e.length!=this.length||e.lines!=this.lines)return!1;let t=this.scanIdentical(e,1),n=this.length-this.scanIdentical(e,-1),i=new F(this),r=new F(e);for(let l=t,h=t;;){if(i.next(l),r.next(l),l=0,i.lineBreak!=r.lineBreak||i.done!=r.done||i.value!=r.value)return!1;if(h+=i.value.length,i.done||h>=n)return!0}}iter(e=1){return new F(this,e)}iterRange(e,t=this.length){return new te(this,e,t)}iterLines(e,t){let n;if(e==null)n=this.iter();else{t==null&&(t=this.lines+1);let i=this.line(e).from;n=this.iterRange(i,Math.max(i,t==this.lines+1?this.length:t<=1?0:this.line(t-1).to))}return new ne(n)}toString(){return this.sliceString(0)}toJSON(){let e=[];return this.flatten(e),e}constructor(){}static of(e){if(e.length==0)throw new RangeError("A document must have at least one line");return e.length==1&&!e[0]?s.empty:e.length<=32?new S(e):V.from(S.split(e,[]))}},S=class s extends w{constructor(e,t=_e(e)){super(),this.text=e,this.length=t}get lines(){return this.text.length}get children(){return null}lineInner(e,t,n,i){for(let r=0;;r++){let l=this.text[r],h=i+l.length;if((t?n:h)>=e)return new ce(i,h,n,l);i=h+1,n++}}decompose(e,t,n,i){let r=e<=0&&t>=this.length?this:new s(De(this.text,e,t),Math.min(t,this.length)-Math.max(0,e));if(i&1){let l=n.pop(),h=ee(r.text,l.text.slice(),0,r.length);if(h.length<=32)n.push(new s(h,l.length+r.length));else{let o=h.length>>1;n.push(new s(h.slice(0,o)),new s(h.slice(o)))}}else n.push(r)}replace(e,t,n){if(!(n instanceof s))return super.replace(e,t,n);[e,t]=q(this,e,t);let i=ee(this.text,ee(n.text,De(this.text,0,e)),t),r=this.length+n.length-(t-e);return i.length<=32?new s(i,r):V.from(s.split(i,[]),r)}sliceString(e,t=this.length,n=`
`){[e,t]=q(this,e,t);let i="";for(let r=0,l=0;r<=t&&l<this.text.length;l++){let h=this.text[l],o=r+h.length;r>e&&l&&(i+=n),e<o&&t>r&&(i+=h.slice(Math.max(0,e-r),t-r)),r=o+1}return i}flatten(e){for(let t of this.text)e.push(t)}scanIdentical(){return 0}static split(e,t){let n=[],i=-1;for(let r of e)n.push(r),i+=r.length+1,n.length==32&&(t.push(new s(n,i)),n=[],i=-1);return i>-1&&t.push(new s(n,i)),t}},V=class s extends w{constructor(e,t){super(),this.children=e,this.length=t,this.lines=0;for(let n of e)this.lines+=n.lines}lineInner(e,t,n,i){for(let r=0;;r++){let l=this.children[r],h=i+l.length,o=n+l.lines-1;if((t?o:h)>=e)return l.lineInner(e,t,n,i);i=h+1,n=o+1}}decompose(e,t,n,i){for(let r=0,l=0;l<=t&&r<this.children.length;r++){let h=this.children[r],o=l+h.length;if(e<=o&&t>=l){let a=i&((l<=e?1:0)|(o>=t?2:0));l>=e&&o<=t&&!a?n.push(h):h.decompose(e-l,t-l,n,a)}l=o+1}}replace(e,t,n){if([e,t]=q(this,e,t),n.lines<this.lines)for(let i=0,r=0;i<this.children.length;i++){let l=this.children[i],h=r+l.length;if(e>=r&&t<=h){let o=l.replace(e-r,t-r,n),a=this.lines-l.lines+o.lines;if(o.lines<a>>4&&o.lines>a>>6){let f=this.children.slice();return f[i]=o,new s(f,this.length-(t-e)+n.length)}return super.replace(r,h,o)}r=h+1}return super.replace(e,t,n)}sliceString(e,t=this.length,n=`
`){[e,t]=q(this,e,t);let i="";for(let r=0,l=0;r<this.children.length&&l<=t;r++){let h=this.children[r],o=l+h.length;l>e&&r&&(i+=n),e<o&&t>l&&(i+=h.sliceString(e-l,t-l,n)),l=o+1}return i}flatten(e){for(let t of this.children)t.flatten(e)}scanIdentical(e,t){if(!(e instanceof s))return 0;let n=0,[i,r,l,h]=t>0?[0,0,this.children.length,e.children.length]:[this.children.length-1,e.children.length-1,-1,-1];for(;;i+=t,r+=t){if(i==l||r==h)return n;let o=this.children[i],a=e.children[r];if(o!=a)return n+o.scanIdentical(a,t);n+=o.length+1}}static from(e,t=e.reduce((n,i)=>n+i.length+1,-1)){let n=0;for(let d of e)n+=d.lines;if(n<32){let d=[];for(let g of e)g.flatten(d);return new S(d,t)}let i=Math.max(32,n>>5),r=i<<1,l=i>>1,h=[],o=0,a=-1,f=[];function u(d){let g;if(d.lines>r&&d instanceof s)for(let m of d.children)u(m);else d.lines>l&&(o>l||!o)?(c(),h.push(d)):d instanceof S&&o&&(g=f[f.length-1])instanceof S&&d.lines+g.lines<=32?(o+=d.lines,a+=d.length+1,f[f.length-1]=new S(g.text.concat(d.text),g.length+1+d.length)):(o+d.lines>i&&c(),o+=d.lines,a+=d.length+1,f.push(d))}function c(){o!=0&&(h.push(f.length==1?f[0]:s.from(f,a)),a=-1,o=f.length=0)}for(let d of e)u(d);return c(),h.length==1?h[0]:new s(h,t)}};w.empty=new S([""],0);function _e(s){let e=-1;for(let t of s)e+=t.length+1;return e}function ee(s,e,t=0,n=1e9){for(let i=0,r=0,l=!0;r<s.length&&i<=n;r++){let h=s[r],o=i+h.length;o>=t&&(o>n&&(h=h.slice(0,n-i)),i<t&&(h=h.slice(t-i)),l?(e[e.length-1]+=h,l=!1):e.push(h)),i=o+1}return e}function De(s,e,t){return ee(s,[""],e,t)}var F=class{constructor(e,t=1){this.dir=t,this.done=!1,this.lineBreak=!1,this.value="",this.nodes=[e],this.offsets=[t>0?1:(e instanceof S?e.text.length:e.children.length)<<1]}nextInner(e,t){for(this.done=this.lineBreak=!1;;){let n=this.nodes.length-1,i=this.nodes[n],r=this.offsets[n],l=r>>1,h=i instanceof S?i.text.length:i.children.length;if(l==(t>0?h:0)){if(n==0)return this.done=!0,this.value="",this;t>0&&this.offsets[n-1]++,this.nodes.pop(),this.offsets.pop()}else if((r&1)==(t>0?0:1)){if(this.offsets[n]+=t,e==0)return this.lineBreak=!0,this.value=`
`,this;e--}else if(i instanceof S){let o=i.text[l+(t<0?-1:0)];if(this.offsets[n]+=t,o.length>Math.max(0,e))return this.value=e==0?o:t>0?o.slice(e):o.slice(0,o.length-e),this;e-=o.length}else{let o=i.children[l+(t<0?-1:0)];e>o.length?(e-=o.length,this.offsets[n]+=t):(t<0&&this.offsets[n]--,this.nodes.push(o),this.offsets.push(t>0?1:(o instanceof S?o.text.length:o.children.length)<<1))}}}next(e=0){return e<0&&(this.nextInner(-e,-this.dir),e=this.value.length),this.nextInner(e,this.dir)}},te=class{constructor(e,t,n){this.value="",this.done=!1,this.cursor=new F(e,t>n?-1:1),this.pos=t>n?e.length:0,this.from=Math.min(t,n),this.to=Math.max(t,n)}nextInner(e,t){if(t<0?this.pos<=this.from:this.pos>=this.to)return this.value="",this.done=!0,this;e+=Math.max(0,t<0?this.pos-this.to:this.from-this.pos);let n=t<0?this.pos-this.from:this.to-this.pos;e>n&&(e=n),n-=e;let{value:i}=this.cursor.next(e);return this.pos+=(i.length+e)*t,this.value=i.length<=n?i:t<0?i.slice(i.length-n):i.slice(0,n),this.done=!this.value,this}next(e=0){return e<0?e=Math.max(e,this.from-this.pos):e>0&&(e=Math.min(e,this.to-this.pos)),this.nextInner(e,this.cursor.dir)}get lineBreak(){return this.cursor.lineBreak&&this.value!=""}},ne=class{constructor(e){this.inner=e,this.afterBreak=!0,this.value="",this.done=!1}next(e=0){let{done:t,lineBreak:n,value:i}=this.inner.next(e);return t&&this.afterBreak?(this.value="",this.afterBreak=!1):t?(this.done=!0,this.value=""):n?this.afterBreak?this.value="":(this.afterBreak=!0,this.next()):(this.value=i,this.afterBreak=!1),this}get lineBreak(){return!1}};typeof Symbol<"u"&&(w.prototype[Symbol.iterator]=function(){return this.iter()},F.prototype[Symbol.iterator]=te.prototype[Symbol.iterator]=ne.prototype[Symbol.iterator]=function(){return this});var ce=class{constructor(e,t,n,i){this.from=e,this.to=t,this.number=n,this.text=i}get length(){return this.to-this.from}};function q(s,e,t){return e=Math.max(0,Math.min(s.length,e)),[e,Math.max(e,Math.min(s.length,t))]}function ie(s,e,t=!0,n=!0){return Me(s,e,t,n)}function et(s){return s>=56320&&s<57344}function tt(s){return s>=55296&&s<56320}function gt(s,e){let t=s.charCodeAt(e);if(!tt(t)||e+1==s.length)return t;let n=s.charCodeAt(e+1);return et(n)?(t-55296<<10)+(n-56320)+65536:t}function pt(s){return s<=65535?String.fromCharCode(s):(s-=65536,String.fromCharCode((s>>10)+55296,(s&1023)+56320))}function mt(s){return s<65536?1:2}var de=/\r\n?|\n/,b=(function(s){return s[s.Simple=0]="Simple",s[s.TrackDel=1]="TrackDel",s[s.TrackBefore=2]="TrackBefore",s[s.TrackAfter=3]="TrackAfter",s})(b||(b={})),L=class s{constructor(e){this.sections=e}get length(){let e=0;for(let t=0;t<this.sections.length;t+=2)e+=this.sections[t];return e}get newLength(){let e=0;for(let t=0;t<this.sections.length;t+=2){let n=this.sections[t+1];e+=n<0?this.sections[t]:n}return e}get empty(){return this.sections.length==0||this.sections.length==2&&this.sections[1]<0}iterGaps(e){for(let t=0,n=0,i=0;t<this.sections.length;){let r=this.sections[t++],l=this.sections[t++];l<0?(e(n,i,r),i+=r):i+=l,n+=r}}iterChangedRanges(e,t=!1){ge(this,e,t)}get invertedDesc(){let e=[];for(let t=0;t<this.sections.length;){let n=this.sections[t++],i=this.sections[t++];i<0?e.push(n,i):e.push(i,n)}return new s(e)}composeDesc(e){return this.empty?e:e.empty?this:ze(this,e)}mapDesc(e,t=!1){return e.empty?this:pe(this,e,t)}mapPos(e,t=-1,n=b.Simple){let i=0,r=0;for(let l=0;l<this.sections.length;){let h=this.sections[l++],o=this.sections[l++],a=i+h;if(o<0){if(a>e)return r+(e-i);r+=h}else{if(n!=b.Simple&&a>=e&&(n==b.TrackDel&&i<e&&a>e||n==b.TrackBefore&&i<e||n==b.TrackAfter&&a>e))return null;if(a>e||a==e&&t<0&&!h)return e==i||t<0?r:r+o;r+=o}i=a}if(e>i)throw new RangeError(`Position ${e} is out of range for changeset of length ${i}`);return r}touchesRange(e,t=e){for(let n=0,i=0;n<this.sections.length&&i<=t;){let r=this.sections[n++],l=this.sections[n++],h=i+r;if(l>=0&&i<=t&&h>=e)return i<e&&h>t?"cover":!0;i=h}return!1}toString(){let e="";for(let t=0;t<this.sections.length;){let n=this.sections[t++],i=this.sections[t++];e+=(e?" ":"")+n+(i>=0?":"+i:"")}return e}toJSON(){return this.sections}static fromJSON(e){if(!Array.isArray(e)||e.length%2||e.some(t=>typeof t!="number"))throw new RangeError("Invalid JSON representation of ChangeDesc");return new s(e)}static create(e){return new s(e)}},E=class s extends L{constructor(e,t){super(e),this.inserted=t}apply(e){if(this.length!=e.length)throw new RangeError("Applying change set to a document with the wrong length");return ge(this,(t,n,i,r,l)=>e=e.replace(i,i+(n-t),l),!1),e}mapDesc(e,t=!1){return pe(this,e,t,!0)}invert(e){let t=this.sections.slice(),n=[];for(let i=0,r=0;i<t.length;i+=2){let l=t[i],h=t[i+1];if(h>=0){t[i]=h,t[i+1]=l;let o=i>>1;for(;n.length<o;)n.push(w.empty);n.push(l?e.slice(r,r+l):w.empty)}r+=l}return new s(t,n)}compose(e){return this.empty?e:e.empty?this:ze(this,e,!0)}map(e,t=!1){return e.empty?this:pe(this,e,t,!0)}iterChanges(e,t=!1){ge(this,e,t)}get desc(){return L.create(this.sect Line truncated
`}get readOnly(){return this.facet(Ze)}phrase(e,...t){for(let n of this.facet(s.phrases))if(Object.prototype.hasOwnProperty.call(n,e)){e=n[e];break}return t.length&&(e=e.replace(/\$(\$|\d*)/g,(n,i)=>{if(i=="$")return"$";let r=+(i||1);return!r||r>t.length?n:t[r-1]})),e}languageDataAt(e,t,n=-1){let i=[];for(let r of this.facet(We))for(let l of r(this,t,n))Object.prototype.hasOwnProperty.call(l,e)&&i.push(l[e]);return i}charCategorizer(e){let t=this.languageDataAt("wordChars",e);return ft(t.length?t[0]:"")}wordAt(e){let{text:t,from:n,length:i}=this.doc.lineAt(e),r=this.charCategorizer(e),l=e-n,h=e-n;for(;l>0;){let o=ie(t,l,!1);if(r(t.slice(o,l))!=O.Word)break;l=o}for(;h<i;){let o=ie(t,h);if(r(t.slice(h,o))!=O.Word)break;h=o}return l==h?null:y.range(l+n,h+n)}};P.allowMultipleSelections=we;P.tabSize=I.define({combine:s=>s.length?s[0]:4});P.lineSeparator=je;P.readOnly=Ze;P.phrases=I.define({compare(s,e){let t=Object.keys(s),n=Object.keys(e);return t.length==n.length&&t.every(i=>s[i]==e[i])}});P.languageData=We;P.changeFilter=Ue;P.transactionFilter=Ge;P.transactionExtender=He;re.reconfigure=k.define();function vt(s,e,t={}){let n={};for(let i of s)for(let r of Object.keys(i)){let l=i[r],h=n[r];if(h===void 0)n[r]=l;else if(!(h===l||l===void 0))if(Object.hasOwnProperty.call(t,r))n[r]=t[r](h,l);else throw new Error("Config merge conflict for field "+r)}for(let i in e)n[i]===void 0&&(n[i]=e[i]);return n}var j=class{eq(e){return this==e}range(e,t=e){return K.create(e,t,this)}};j.prototype.startSide=j.prototype.endSide=0;j.prototype.point=!1;j.prototype.mapMode=b.TrackDel;function be(s,e){return s==e||s.constructor==e.constructor&&s.eq(e)}var K=class s{constructor(e,t,n){this.from=e,this.to=t,this.value=n}static create(e,t,n){return new s(e,t,n)}};function Se(s,e){return s.from-e.from||s.value.startSide-e.value.startSide}var Ie=class s{constructor(e,t,n,i){this.from=e,this.to=t,this.value=n,this.maxPoint=i}get length(){return N(this.to)}findIndex(e,t,n,i=0){let r=n?this.to:this.from;for(let l=i,h=r.length;;){if(l==h)return l;let o=l+h>>1,a=r[o]-e||(n?this.value[o].endSide:this.value[o].startSide)-t;if(o==l)return a>=0?l:h;a>=0?h=o:l=o+1}}between(e,t,n,i){for(let r=this.findIndex(t,-1e9,!0),l=this.findIndex(n,1e9,!1,r);r<l;r++)if(i(this.from[r]+e,this.to[r]+e,this.value[r])===!1)return!1}map(e,t,n,i,r){let l=[],h=[],o=[],a=-1,f=-1;e:for(let u=0;u<this.value.length;u++){let c=this.value[u],d=this.from[u]+e,g=this.to[u]+e,m,p;if(d==g){let v=t.mapPos(d,c.startSide,c.mapMode);if(v==null||(m=p=v,c.startSide!=c.endSide&&(p=t.mapPos(d,c.endSide),p<m)))continue}else if(m=t.mapPos(d,c.startSide),p=t.mapPos(g,c.endSide),m>p||m==p&&c.startSide>0&&c.endSide<=0)continue;if(!((p-m||c.endSide-c.startSide)<0))if(a<0&&(a=m),c.point&&(f=Math.max(f,p-m)),(m-n||c.startSide-i)>=0)l.push(c),h.push(m-a),o.push(p-a),n=p,i=c.endSide;else{if(m==p)for(let v=l.length;v>0;v--){if((m-(o[v-1]+a)||c.startSide-l[v-1].endSide)>=0){l.splice(v,0,c),h.splice(v,0,m-a),o.splice(v,0,p-a);continue e}if((m-(h[v-1]+a)||c.endSide-l[v-1].startSide)>0)break}r(m,p,c)}}return{mapped:l.length?new s(h,o,l,f):null,pos:a}}},R=class s{constructor(e,t,n,i){this.chunkPos=e,this.chunk=t,this.nextLayer=n,this.maxPoint=i}static create(e,t,n,i){return new s(e,t,n,i)}get length(){let e=this.chunk.length-1;return e<0?0:Math.max(this.chunkEnd(e),this.nextLayer.length)}get size(){if(this.isEmpty)return 0;let e=this.nextLayer.size;for(let t of this.chunk)e+=t.value.length;return e}chunkEnd(e){return this.chunkPos[e]+this.chunk[e].length}update(e){let{add:t=[],sort:n=!1,filterFrom:i=0,filterTo:r=this.length}=e,l=e.filter;if(t.length==0&&!l)return this;if(n&&(t=t.slice().sort(Se)),this.isEmpty)return t.length?s.of(t):this;let h=new oe(this,null,-1).goto(0),o=0,a=[],f=new H;for(;h.value||o<t.length;)if(o<t.length&&(h.from-t[o].from||h.startSide-t[o].value.startSide)>=0){let u=t[o++];f.addInner(u.from,u.to,u.value,!1)||a.push(u)}else h.rangeIndex==1&&h.chunkIndex<this.chunk.length&&(o==t.length||this.chunkEnd(h.chunkIndex)<t[o].from)&&(!l||i>this.chunkEnd(h.chunkIndex)||r<this.chunkPos[h.chunkIndex])&&f.addChunk(this.chunkPos[h.chunkIndex],this.chunk[h.chunkIndex])?h.nextChunk():((!l||i>h.to||r<h.from||l(h.from,h.to,h.value))&&(f.addInner(h.from,h.to,h.value,!1)||a.push(K.create(h.from,h.to,h.value))),h.next());return f.finishInner(this.nextLayer.isEmpty&&!a.length?s.empty:this.nextLayer.update({add:a,filter:l,filterFrom:i,filterTo:r}))}map(e){if(e.empty||this.isEmpty)return this;let t=[],n=[],i=-1,r,l=(o,a,f)=>{r||(r=new H),r.addRange(o,a,f,!1)};for(let o=0;o<this.chunk.length;o++){let a=this.chunkPos[o],f=this.chunk[o],u=e.touchesRange(a,a+f.length);if(u===!1)i=Math.max(i,f.maxPoint),t.push(f),n.push(e.mapPos(a));else if(u===!0){let[c,d]=t.length?[N(n)+N(t).length,N(N(t).value).endSide]:[-1,-1],{mapped:g,pos:m}=f.map(a,e,c,d,l);g&&(i=Math.max(i,g.maxPoint),t.push(g),n.push(m))}}let h=this.nextLayer.map(e);return r&&(h=r.finishInner(h)),t.length==0?h:new s(n,t,h||s.empty,i)}between(e,t, Line truncated
//# sourceMappingURL=state.bundle.mjs.map
+9
View File
@@ -0,0 +1,9 @@
/* esm.sh - @codemirror/view@6.43.15 */
import{RangeSet as H,MapMode as Et,RangeValue as ls,findClusterBreak as wt,EditorSelection as C,Facet as k,StateEffect as at,ChangeSet as hn,Text as j,findColumn as li,CharCategory as Cr,EditorState as Yt,Annotation as kr,Transaction as Mr,Prec as as,codePointAt as cn,codePointSize as Ar,combineConfig as hs,StateField as cs,RangeSetBuilder as Tr,countColumn as fn}from"./cm-state.mjs";var vs=typeof Symbol>"u"?"__\u037C":Symbol.for("\u037C"),Je=typeof Symbol>"u"?"__styleSet"+Math.floor(Math.random()*1e8):Symbol("styleSet"),Ss=typeof globalThis<"u"?globalThis:typeof window<"u"?window:{},it=class{constructor(t,e){this.rules=[];let{finish:i}=e||{};function n(o){return/^@/.test(o)?[o]:o.split(/,\s*/)}function r(o,l,a,h){let c=[],f=/^@(\w+)\b/.exec(o[0]),d=f&&f[1]=="keyframes";if(f&&l==null)return a.push(o[0]+";");for(let p in l){let u=l[p];if(/&/.test(p))r(p.split(/,\s*/).map(g=>o.map(m=>g.replace(/&/,m))).reduce((g,m)=>g.concat(m)),u,a);else if(u&&typeof u=="object"){if(!f)throw new RangeError("The value of a property ("+p+") should be a primitive value.");r(n(p),u,c,d)}else u!=null&&c.push(p.replace(/_.*/,"").replace(/[A-Z]/g,g=>"-"+g.toLowerCase())+": "+u+";")}(c.length||d)&&a.push((i&&!f&&!h?o.map(i):o).join(", ")+" {"+c.join(" ")+"}")}for(let o in t)r(n(o),t[o],this.rules)}getRules(){return this.rules.join(`
`)}static newName(){let t=Ss[vs]||1;return Ss[vs]=t+1,"\u037C"+t.toString(36)}static mount(t,e,i){let n=t[Je],r=i&&i.nonce;n?r&&n.setNonce(r):n=new Ze(t,r),n.mount(Array.isArray(e)?e:[e],t)}},Cs=new Map,Ze=class{constructor(t,e){let i=t.ownerDocument||t,n=i.defaultView;if(!t.head&&t.adoptedStyleSheets&&n.CSSStyleSheet){let r=Cs.get(i);if(r)return t[Je]=r;this.sheet=new n.CSSStyleSheet,Cs.set(i,this)}else this.styleTag=i.createElement("style"),e&&this.styleTag.setAttribute("nonce",e);this.modules=[],t[Je]=this}mount(t,e){let i=this.sheet,n=0,r=0,o=!1;for(let l=0;l<t.length;l++){let a=t[l],h=this.modules.indexOf(a);if(h<r&&h>-1&&(this.modules.splice(h,1),o=!0,r--,h=-1),h==-1){if(this.modules.splice(r++,0,a),o=!0,i)for(let c=0;c<a.rules.length;c++)i.insertRule(a.rules[c],n++)}else{for(;r<h;)n+=this.modules[r++].rules.length;n+=a.rules.length,r++}}if(i)e.adoptedStyleSheets.indexOf(this.sheet)<0&&(e.adoptedStyleSheets=[this.sheet,...e.adoptedStyleSheets]);else{if(o){let a="";for(let h=0;h<this.modules.length;h++)a+=this.modules[h].getRules()+`
`;this.styleTag.textContent=a}let l=e.head||e;this.styleTag.parentNode!=l&&l.insertBefore(this.styleTag,l.firstChild)}}setNonce(t){this.styleTag&&this.styleTag.getAttribute("nonce")!=t&&this.styleTag.setAttribute("nonce",t)}};var st={8:"Backspace",9:"Tab",10:"Enter",12:"NumLock",13:"Enter",16:"Shift",17:"Control",18:"Alt",20:"CapsLock",27:"Escape",32:" ",33:"PageUp",34:"PageDown",35:"End",36:"Home",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",44:"PrintScreen",45:"Insert",46:"Delete",59:";",61:"=",91:"Meta",92:"Meta",106:"*",107:"+",108:",",109:"-",110:".",111:"/",144:"NumLock",145:"ScrollLock",160:"Shift",161:"Shift",162:"Control",163:"Control",164:"Alt",165:"Alt",173:"-",186:";",187:"=",188:",",189:"-",190:".",191:"/",192:"`",219:"[",220:"\\",221:"]",222:"'"},Tt={48:")",49:"!",50:"@",51:"#",52:"$",53:"%",54:"^",55:"&",56:"*",57:"(",59:":",61:"+",173:"_",186:":",187:"+",188:"<",189:"_",190:">",191:"?",192:"~",219:"{",220:"|",221:"}",222:'"'},vr=typeof navigator<"u"&&/Mac/.test(navigator.platform),Sr=typeof navigator<"u"&&/MSIE \d|Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(navigator.userAgent);for(E=0;E<10;E++)st[48+E]=st[96+E]=String(E);var E;for(E=1;E<=24;E++)st[E+111]="F"+E;var E;for(E=65;E<=90;E++)st[E]=String.fromCharCode(E+32),Tt[E]=String.fromCharCode(E);var E;for(pe in st)Tt.hasOwnProperty(pe)||(Tt[pe]=st[pe]);var pe;function ks(s){var t=vr&&s.metaKey&&s.shiftKey&&!s.ctrlKey&&!s.altKey||Sr&&s.shiftKey&&s.key&&s.key.length==1||s.key=="Unidentified",e=!t&&s.key||(s.shiftKey?Tt:st)[s.keyCode]||s.key||"Unidentified";return e=="Esc"&&(e="Escape"),e=="Del"&&(e="Delete"),e=="Left"&&(e="ArrowLeft"),e=="Up"&&(e="ArrowUp"),e=="Right"&&(e="ArrowRight"),e=="Down"&&(e="ArrowDown"),e}function ft(){var s=arguments[0];typeof s=="string"&&(s=document.createElement(s));var t=1,e=arguments[1];if(e&&typeof e=="object"&&e.nodeType==null&&!Array.isArray(e)){for(var i in e)if(Object.prototype.hasOwnProperty.call(e,i)){var n=e[i];typeof n=="string"?s.setAttribute(i,n):n!=null&&(s[i]=n)}t++}for(;t<arguments.length;t++)Ms(s,arguments[t]);return s}function Ms(s,t){if(typeof t=="string")s.appendChild(document.createTextNode(t));else if(t!=null)if(t.nodeType!=null)s.appendChild(t);else if(Array.isArray(t))for(var e=0;e<t.length;e++)Ms(s,t[e]);else throw new RangeError("Unsupported child node: "+t)}var F=typeof navigator<"u"?navigator:{userAgent:"",vendor:"",platform:""},ai=typeof document<"u"?document:{documentElement:{style:{}}},hi=/Edge\/(\d+)/.exec(F.userAgent),dn=/MSIE \d/.test(F.userAgent),ci=/Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(F.userAgent),Xe=!!(dn||ci||hi),As=!Xe&&/gecko\/(\d+)/i.test(F.userAgent),ti=!Xe&&/Chrome\/(\d+)/.exec(F.userAgent),Ts="webkitFontSmoothing"in ai.documentElement.style,fi=!Xe&&/Apple Computer/.test(F.vendor),Ds=fi&&(/Mobile\/\w+/.test(F.userAgent)||F.maxTouchPoints>2),y={mac:Ds||/Mac/.test(F.platform),windows:/Win/.test(F.platform),linux:/Linux|X11/.test(F.platform),ie:Xe,ie_version:dn?ai.documentMode||6:ci?+ci[1]:hi?+hi[1]:0,gecko:As,gecko_version:As?+(/Firefox\/(\d+)/.exec(F.userAgent)||[0,0])[1]:0,chrome:!!ti,chrome_version:ti?+ti[1]:0,ios:Ds,android:/Android\b/.test(F.userAgent),webkit:Ts,webkit_version:Ts?+(/\bAppleWebKit\/(\d+)/.exec(F.userAgent)||[0,0])[1]:0,safari:fi,safari_version:fi?+(/\bVersion\/(\d+(\.\d+)?)/.exec(F.userAgent)||[0,0])[1]:0,tabSize:ai.documentElement.style.tabSize!=null?"tab-size":"-moz-tab-size"};function fs(s,t){for(let e in s)e=="class"&&t.class?t.class+=" "+s.class:e=="style"&&t.style?t.style+=";"+s.style:t[e]=s[e];return t}var Re=Object.create(null);function ds(s,t,e){if(s==t)return!0;s||(s=Re),t||(t=Re);let i=Object.keys(s),n=Object.keys(t);if(i.length-0!=n.length-0)return!1;for(let r of i)if(r!=e&&(n.indexOf(r)==-1||s[r]!==t[r]))return!1;return!0}function Dr(s,t){for(let e=s.attributes.length-1;e>=0;e--){let i=s.attributes[e].name;t[i]==null&&s.removeAttribute(i)}for(let e in t){let i=t[e];e=="style"?s.style.cssText=i:s.getAttribute(e)!=i&&s.setAttribute(e,i)}}function Os(s,t,e){let i=!1;if(t)for(let n in t)e&&n in e||(i=!0,n=="style"?s.style.cssText="":s.removeAttribute(n));if(e)for(let n in e)t&&t[n]==e[n]||(i=!0,n=="style"?s.style.cssText=e[n]:s.setAttribute(n,e[n]));return i}function Or(s){let t=Object.create(null);for(let e=0;e<s.attributes.length;e++){let i=s.attributes[e];t[i.name]=i.value}return t}var ot=class{eq(t){return!1}updateDOM(t,e,i){return!1}compare(t){return this==t||this.constructor==t.constructor&&this.eq(t)}get estimatedHeight(){return-1}get lineBreaks(){return 0}ignoreEvent(t){return!0}coordsAt(t,e,i){return null}get isHidden(){return!1}get editable(){return!1}destroy(t){}},W=(function(s){return s[s.Text=0]="Text",s[s.WidgetBefore=1]="WidgetBefore",s[s.WidgetAfter=2]="WidgetAfter",s[s.WidgetRange=3]="WidgetRange",s})(W||(W={})),T=class extends ls{constructor(t,e,i,n){super(),this.startSide=t,this.endSide=e,this.widget=i,this.spec=n}get heightRelevant(){return!1}static mark(t){return new se(t)}static widget(t){let e=Math.Line truncated
`,n=s.state.doc.line(n.number+(e?1:-1)),r=s.bidiSpans(n),h=e?C.cursor(n.from,-1):C.cursor(n.to,1)}if(a){if(!a(c))return l}else{if(!i)return h;a=i(c)}l=h}}function oo(s,t,e){let i=s.state.charCategorizer(t),n=i(e);return r=>{let o=i(r);return n==Cr.Space&&(n=o),n==o}}function lo(s,t,e,i){let n=t.head,r=e?1:-1;if(n==(e?s.state.doc.length:0))return C.cursor(n,t.assoc);let o=t.goalColumn,l,a=s.contentDOM.getBoundingClientRect(),h=s.coordsAtPos(n,t.assoc||((t.empty?e:t.head==t.from)?1:-1)),c=s.documentTop;if(h)o==null&&(o=h.left-a.left),l=r<0?h.top:h.bottom;else{let u=s.viewState.lineBlockAt(n);o==null&&(o=Math.min(a.right-a.left,s.defaultCharacterWidth*(n-u.from))),l=(r<0?u.top:u.bottom)+c}let f=a.left+o,d=s.viewState.heightOracle.textHeight>>1,p=i??d;for(let u=0;;u+=d){let g=l+(p+u)*r,m=Ti(s,{x:f,y:g},!1,r);if(e?g>a.bottom:g<a.top)return C.cursor(m.pos,m.assoc);let b=s.coordsAtPos(m.pos,m.assoc),S=b?(b.top+b.bottom)/2:0;if(!b||(e?S>l:S<l))return C.cursor(m.pos,m.assoc,void 0,o)}}function Zt(s,t,e){for(;;){let i=0;for(let n of s)n.between(t-1,t+1,(r,o,l)=>{if(t>r&&t<o){let a=i||e||(t-r<o-t?-1:1);t=a<0?r:o,i=a}});if(!i)return t}}function Yn(s,t){let e=null;for(let i=0;i<t.ranges.length;i++){let n=t.ranges[i],r=null;if(n.empty){let o=Zt(s,n.from,0);o!=n.from&&(r=C.cursor(o,-1))}else{let o=Zt(s,n.from,-1),l=Zt(s,n.to,1);(o!=n.from||l!=n.to)&&(n.undirectional?r=C.undirectionalRange(n.from,n.to):r=C.range(n.from==n.anchor?o:l,n.from==n.head?o:l))}r&&(e||(e=t.ranges.slice()),e[i]=r)}return e?C.create(e,t.mainIndex):t}function si(s,t,e){let i=Zt(s.state.facet(ce).map(n=>n(s)),e.from,t.head>e.from?-1:1);return i==e.from?e:C.cursor(i,i<e.from?1:-1)}var _=class{constructor(t,e){this.pos=t,this.assoc=e}};function Ti(s,t,e,i){let n=s.contentDOM.getBoundingClientRect(),r=n.top+s.viewState.paddingTop,{x:o,y:l}=t,a=l-r,h;for(;;){if(a<0)return new _(0,1);if(a>s.viewState.docHeight)return new _(s.state.doc.length,-1);if(h=s.elementAtHeight(a),i==null)break;if(h.type==W.Text){if(i<0?h.to<s.viewport.from:h.from>s.viewport.to)break;let d=s.docView.coordsAt(i<0?h.from:h.to,i>0?-1:1);if(d&&(i<0?d.top<=a+r:d.bottom>=a+r))break}let f=s.viewState.heightOracle.textHeight/2;a=i>0?h.bottom+f:h.top-f}if(s.viewport.from>=h.to||s.viewport.to<=h.from){if(e)return null;if(h.type==W.Text){let f=no(s,n,h,o,l);return new _(f,f==h.from?1:-1)}}if(h.type!=W.Text)return a<(h.top+h.bottom)/2?new _(h.from,1):new _(h.to,-1);let c=s.docView.lineAt(h.from,2);return(!c||c.length!=h.length)&&(c=s.docView.lineAt(h.from,-2)),new Di(s,o,l,s.textDirectionAt(h.from)).scanTile(c,h.from)}var Di=class{constructor(t,e,i,n){this.view=t,this.x=e,this.y=i,this.baseDir=n,this.line=null,this.spans=null}bidiSpansAt(t){return(!this.line||this.line.from>t||this.line.to<t)&&(this.line=this.view.state.doc.lineAt(t),this.spans=this.view.bidiSpans(this.line)),this}baseDirAt(t,e){let{line:i,spans:n}=this.bidiSpansAt(t);return n[Y.find(n,t-i.from,-1,e)].level==this.baseDir}dirAt(t,e){let{line:i,spans:n}=this.bidiSpansAt(t);return n[Y.find(n,t-i.from,-1,e)].dir}bidiIn(t,e){let{spans:i,line:n}=this.bidiSpansAt(t);return i.length>1||i.length&&(i[0].level!=this.baseDir||i[0].to+n.from<e)}scan(t,e,i=!1){let n=0,r=t.length-1,o=new Set,l=this.bidiIn(t[0],t[r]),a,h,c=-1,f=1e9,d;t:for(;n<r;){let u=r-n,g=n+r>>1;e:if(o.has(g)){for(let S=1;S<u;S++){let w=g+S;if(w>=r&&(w-=u),!o.has(w)){g=w;break e}}break t}o.add(g);let m=e(g),b=0;if(m)for(let S=0;S<m.length;S++){let w=m[S];if(!(w.width==0&&m.length>1))if(w.bottom<this.y)(!a||a.bottom<w.bottom)&&(a=w),b=1;else if(w.top>this.y)(!h||h.top>w.top)&&(h=w),b=-1;else{let M=w.left>this.x?this.x-w.left:w.right<this.x?this.x-w.right:0,x=Math.abs(M);x<f&&(c=g,f=x,d=w),M&&(b=M<0==(this.baseDir==L.LTR)?-1:1)}}b==-1&&(!l||this.baseDirAt(t[g],1))?r=g:b==1&&(!l||this.baseDirAt(t[g+1],-1))&&(n=g+1)}if(!d){if(!h&&!a)return{i:0,after:!1};let u=a&&(!h||this.y-a.bottom<h.top-this.y)?a:h;return this.y=(u.top+u.bottom)/2,this.scan(t,e,!0)}if(f&&!i){let{top:u,bottom:g}=d;if(a&&a.bottom>(u+u+g)/3)return this.y=a.bottom-1,this.scan(t,e,!0);if(h&&h.top<(u+g+g)/3)return this.y=h.top+1,this.scan(t,e,!0)}let p=(l?this.dirAt(t[c],1):this.baseDir)==L.LTR;return{i:c,after:this.x>(d.left+d.right)/2==p}}scanText(t,e){let i=[];for(let r=0;r<t.length;r=wt(t.text,r))i.push(e+r);i.push(e+t.length);let n=this.scan(i,r=>{let o=i[r]-e,l=i[r+1]-e;return le(t.dom,o,l).getClientRects()});return n.after?new _(i[n.i+1],-1):new _(i[n.i],1)}scanTile(t,e){if(!t.length)return new _(e,1);if(t.children.length==1){let l=t.children[0];if(l.isText())return this.scanText(l,e);if(l.isComposite())return this.scanTile(l,e)}let i=[e];for(let l=0,a=e;l<t.children.length;l++)i.push(a+=t.children[l].length);let n=this.scan(i,l=>{let a=t.children[l];return a.flags&48?null:(N(a.dom)?a.dom:le(a.dom,0,a.length)).getClientRects()}),r=t.children[n.i],o=i[n.i];return r.isText()?this.scanText(r,o):r.isComposite()?this.scanTile(r,o):n.after?new _(i[n.i+1],-1):new _(o,1)}},Dt="\uFFFF",Oi=class{constructor(t,e){this Line truncated
`&&s.lineWrapping&&(i&&(i=C.single(i.main.anchor-1,i.main.head-1)),e={from:r.from,to:r.to,insert:j.of([" "])}),e)return ys(s,e,i,o);if(i&&!Li(i,r)){let l=!1,a="select";return s.inputState.lastSelectionTime>Date.now()-50&&(s.inputState.lastSelectionOrigin=="select"&&(l=!0),a=s.inputState.lastSelectionOrigin,a=="select.pointer"&&(i=Yn(n.facet(ce).map(h=>h(s)),i))),s.dispatch({selection:i,scrollIntoView:l,userEvent:a}),!0}else return!1}function ys(s,t,e,i=-1){if(y.ios&&s.inputState.flushIOSKey(t))return!0;let n=s.state.selection.main;if(y.android&&(t.to==n.to&&(t.from==n.from||t.from==n.from-1&&s.state.sliceDoc(t.from,n.from)==" ")&&t.insert.length==1&&t.insert.lines==2&&Pt(s.contentDOM,"Enter",13)||(t.from==n.from-1&&t.to==n.to&&t.insert.length==0||i==8&&t.insert.length<t.to-t.from&&t.to>n.head)&&Pt(s.contentDOM,"Backspace",8)||t.from==n.from&&t.to==n.to+1&&t.insert.length==0&&Pt(s.contentDOM,"Delete",46)))return!0;let r=t.insert.toString();s.inputState.composing>=0&&s.inputState.composing++;let o,l=()=>o||(o=co(s,t,e));return s.state.facet(En).some(a=>a(s,t.from,t.to,r,l))||s.dispatch(l()),!0}function co(s,t,e){let i,n=s.state,r=n.selection.main,o=-1;if(t.from==t.to&&t.from<r.from||t.from>r.to){let a=t.from<r.from?-1:1,h=a<0?r.from:r.to,c=Zt(n.facet(ce).map(f=>f(s)),h,a);t.from==c&&(o=c)}if(o>-1)i={changes:t,selection:C.cursor(t.from+t.insert.length,-1)};else if(t.from>=r.from&&t.to<=r.to&&t.to-t.from>=(r.to-r.from)/3&&(!e||e.main.empty&&e.main.from==t.from+t.insert.length)&&s.inputState.composing<0){let a=r.from<t.from?n.sliceDoc(r.from,t.from):"",h=r.to>t.to?n.sliceDoc(t.to,r.to):"";i=n.replaceSelection(s.state.toText(a+t.insert.sliceString(0,void 0,s.state.lineBreak)+h))}else{let a=n.changes(t),h=e&&e.main.to<=a.newLength?e.main:void 0;if(n.selection.ranges.length>1&&(s.inputState.composing>=0||s.inputState.compositionPendingChange)&&t.to<=r.to+10&&t.to>=r.to-10){let c=s.state.sliceDoc(t.from,t.to),f,d=e&&jn(s,e.main.head);if(d){let u=t.insert.length-(t.to-t.from);f={from:d.from,to:d.to-u}}else f=s.state.doc.lineAt(r.head);let p=r.to-t.to;i=n.changeByRange(u=>{if(u.from==r.from&&u.to==r.to)return{changes:a,range:h||u.map(a)};let g=u.to-p,m=g-c.length;if(s.state.sliceDoc(m,g)!=c||g>=f.from&&m<=f.to)return{range:u};let b=n.changes({from:m,to:g,insert:t.insert}),S=u.to-r.to;return{changes:b,range:h?C.range(Math.max(0,h.anchor+S),Math.max(0,h.head+S)):u.map(b)}})}else i={changes:a,selection:h&&n.selection.replaceRange(h)}}let l="input.type";return(s.composing||s.inputState.compositionPendingChange&&s.inputState.compositionEndedAt>Date.now()-50)&&(s.inputState.compositionPendingChange=!1,l+=".compose",s.inputState.compositionFirstChange&&(l+=".start",s.inputState.compositionFirstChange=!1)),n.update(i,{userEvent:l,scrollIntoView:!0})}function Gn(s,t,e,i){let n=Math.min(s.length,t.length),r=0;for(;r<n&&s.charCodeAt(r)==t.charCodeAt(r);)r++;if(r==n&&s.length==t.length)return null;let o=s.length,l=t.length;for(;o>0&&l>0&&s.charCodeAt(o-1)==t.charCodeAt(l-1);)o--,l--;if(i=="end"){let a=Math.max(0,r-Math.min(o,l));e-=o+a-r}if(o<r&&s.length<t.length){let a=e<=r&&e>=o?r-e:0;r-=a,l=r+(l-o),o=r}else if(l<r){let a=e<=r&&e>=l?r-e:0;r-=a,o=r+(o-l),l=r}return{from:r,toA:o,toB:l}}function fo(s){let t=[];if(s.root.activeElement!=s.contentDOM)return t;let{anchorNode:e,anchorOffset:i,focusNode:n,focusOffset:r}=s.observer.selectionRange;return e&&(t.push(new Ne(e,i)),(n!=e||r!=i)&&t.push(new Ne(n,r))),t}function uo(s,t){if(s.length==0)return null;let e=s[0].pos,i=s.length==2?s[1].pos:e;return e<0||i<0?null:e==i?C.create([C.cursor(i+t,-1)]):C.single(e+t,i+t)}function Li(s,t){return t.head==s.main.head&&t.anchor==s.main.anchor}var Bi=class{setSelectionOrigin(t){this.lastSelectionOrigin=t,this.lastSelectionTime=Date.now()}constructor(t){this.view=t,this.lastKeyCode=0,this.lastKeyTime=0,this.touchActive=!1,this.lastTouchTime=0,this.lastTouchX=0,this.lastTouchY=0,this.lastFocusTime=0,this.lastScrollTop=0,this.lastScrollLeft=0,this.lastWheelEvent=0,this.pendingIOSKey=void 0,this.lastIOSMomentumScroll=0,this.tabFocusMode=-1,this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastContextMenu=0,this.scrollHandlers=[],this.handlers=Object.create(null),this.composing=-1,this.compositionFirstChange=null,this.compositionEndedAt=0,this.compositionPendingKey=!1,this.compositionPendingChange=!1,this.insertingText="",this.insertingTextAt=0,this.mouseSelection=null,this.draggedContent=null,this.handleEvent=this.handleEvent.bind(this),this.notifiedFocused=t.hasFocus,y.safari&&t.contentDOM.addEventListener("input",()=>null),y.gecko&&Do(t.contentDOM.ownerDocument)}handleEvent(t){!vo(this.view,t)||this.ignoreDuringComposition(t)||t.type=="keydown"&&this.keydown(t)||(this.view.updateState!=0?Promise.resolve().then(()=>this.runHandlers(t.type,t)):this.runHandlers(t.type,t))}runHandlers(t,e){let i=this.handlers[t];if(i){for(let n of i.observers)n(this.view,e);for(let n of i.handlers){if(e.defaultPrevented)break;if(n(this.view,e)){e.preventDef Line truncated
`))};if((y.mac||y.android)&&f.from==o-1&&/^\. ?$/.test(i.text)&&t.contentDOM.getAttribute("autocorrect")=="off"&&(f={from:l,to:a,insert:j.of([i.text.replace("."," ")])}),this.pendingContextChange=f,!t.state.readOnly){let d=this.to-this.from+(f.to-f.from+f.insert.length);ys(t,f,C.single(this.toEditorPos(i.selectionStart,d),this.toEditorPos(i.selectionEnd,d)))}this.pendingContextChange&&(this.revertPending(t.state),this.setSelection(t.state)),f.from<f.to&&!f.insert.length&&t.inputState.composing>=0&&!/[\\p{Alphabetic}\\p{Number}_]/.test(e.text.slice(Math.max(0,i.updateRangeStart-1),Math.min(e.text.length,i.updateRangeStart+1)))&&this.handlers.compositionend(i)},this.handlers.characterboundsupdate=i=>{let n=[],r=null;for(let o=this.toEditorPos(i.rangeStart),l=this.toEditorPos(i.rangeEnd);o<l;o++){let a=t.coordsForChar(o);r=a&&new DOMRect(a.left,a.top,a.right-a.left,a.bottom-a.top)||r||new DOMRect,n.push(r)}e.updateCharacterBounds(i.rangeStart,n)},this.handlers.textformatupdate=i=>{let n=[];for(let r of i.getTextFormats()){let o=r.underlineStyle,l=r.underlineThickness;if(!/none/i.test(o)&&!/none/i.test(l)){let a=this.toEditorPos(r.rangeStart),h=this.toEditorPos(r.rangeEnd);if(a<h){let c=`text-decoration: underline ${/^[a-z]/.test(o)?o+" ":o=="Dashed"?"dashed ":o=="Squiggle"?"wavy ":""}${/thin/i.test(l)?1:2}px`;n.push(T.mark({attributes:{style:c}}).range(a,h))}}}t.dispatch({effects:Vn.of(T.set(n))})},this.handlers.compositionstart=()=>{t.inputState.composing<0&&(t.inputState.composing=0,t.inputState.compositionFirstChange=!0)},this.handlers.compositionend=()=>{if(t.inputState.composing=-1,t.inputState.compositionFirstChange=null,this.composing){let{drifted:i}=this.composing;this.composing=null,i&&this.reset(t.state)}};for(let i in this.handlers)e.addEventListener(i,this.handlers[i]);this.measureReq={read:i=>{let n=re(i.root);n&&n.rangeCount&&this.editContext.updateSelectionBounds(n.getRangeAt(0).getBoundingClientRect())}}}applyEdits(t){let e=0,i=!1,n=this.pendingContextChange;return t.changes.iterChanges((r,o,l,a,h)=>{if(i)return;let c=h.length-(o-r);if(n&&o>=n.to)if(n.from==r&&n.to==o&&n.insert.eq(h)){n=this.pendingContextChange=null,e+=c,this.to+=c;return}else n=null,this.revertPending(t.state);if(r+=e,o+=e,o<=this.from)this.from+=c,this.to+=c;else if(r<this.to){if(r<this.from||o>this.to||this.to-this.from+h.length>3e4){i=!0;return}this.editContext.updateText(this.toContextPos(r),this.toContextPos(o),h.toString()),this.to+=c}e+=c}),n&&!i&&this.revertPending(t.state),!i}update(t){let e=this.pendingContextChange,i=t.startState.selection.main;this.composing&&(this.composing.drifted||!t.changes.touchesRange(i.from,i.to)&&t.transactions.some(n=>!n.isUserEvent("input.type")&&n.changes.touchesRange(this.from,this.to)))?(this.composing.drifted=!0,this.composing.editorBase=t.changes.mapPos(this.composing.editorBase)):!this.applyEdits(t)||!this.rangeIsValid(t.state)?(this.pendingContextChange=null,this.reset(t.state)):(t.docChanged||t.selectionSet||e)&&this.setSelection(t.state),(t.geometryChanged||t.docChanged||t.selectionSet)&&t.view.requestMeasure(this.measureReq)}resetRange(t){let{head:e}=t.selection.main;this.from=Math.max(0,e-1e4),this.to=Math.min(t.doc.length,e+1e4)}reset(t){this.resetRange(t),this.editContext.updateText(0,this.editContext.text.length,t.doc.sliceString(this.from,this.to)),this.setSelection(t)}revertPending(t){let e=this.pendingContextChange;this.pendingContextChange=null,this.editContext.updateText(this.toContextPos(e.from),this.toContextPos(e.from+e.insert.length),t.doc.sliceString(e.from,e.to))}setSelection(t){let{main:e}=t.selection,i=this.toContextPos(Math.max(this.from,Math.min(this.to,e.anchor))),n=this.toContextPos(e.head);(this.editContext.selectionStart!=i||this.editContext.selectionEnd!=n)&&this.editContext.updateSelection(i,n)}rangeIsValid(t){let{head:e}=t.selection.main;return!(this.from>0&&e-this.from<500||this.to<t.doc.length&&this.to-e<500||this.to-this.from>1e4*3)}toEditorPos(t,e=this.to-this.from){t=Math.min(t,e);let i=this.composing;return i&&i.drifted?i.editorBase+(t-i.contextBase):t+this.from}toContextPos(t){let e=this.composing;return e&&e.drifted?e.contextBase+(t-e.editorBase):t-this.from}destroy(){for(let t in this.handlers)this.editContext.removeEventListener(t,this.handlers[t])}},A=class s{get state(){return this.viewState.state}get viewport(){return this.viewState.viewport}get visibleRanges(){return this.viewState.visibleRanges}get inView(){return this.viewState.inView}get composing(){return!!this.inputState&&this.inputState.composing>0}get compositionStarted(){return!!this.inputState&&this.inputState.composing>=0}get root(){return this._root}get win(){return this.dom.ownerDocument.defaultView||window}constructor(t={}){var e;this.plugins=[],this.pluginMap=new Map,this.editorAttrs={},this.contentAttrs={},this.bidiCache=[],this.destroyed=!1,this.updateState=2,this.measureScheduled=-1,this.measureRequests=[],this.clearAnnouncement=-1,this.contentDOM=document.createElement( Line truncated
-\x7F-\x9F\xAD\u061C\u200B\u200E\u200F\u2028\u2029\u202D\u202E\u2066\u2067\u2069\uFEFF\uFFF9-\uFFFC]`,Ui),il={0:"null",7:"bell",8:"backspace",10:"newline",11:"vertical tab",13:"carriage return",27:"escape",8203:"zero width space",8204:"zero width non-joiner",8205:"zero width joiner",8206:"left-to-right mark",8207:"right-to-left mark",8232:"line separator",8237:"left-to-right override",8238:"right-to-left override",8294:"left-to-right isolate",8295:"right-to-left isolate",8297:"pop directional isolate",8233:"paragraph separator",65279:"zero width no-break space",65532:"object replacement"},ri=null;function sl(){var s;if(ri==null&&typeof document<"u"&&document.body){let t=document.body.style;ri=((s=t.tabSize)!==null&&s!==void 0?s:t.MozTabSize)!=null}return ri||!1}var Ae=k.define({combine(s){let t=hs(s,{render:null,specialChars:el,addSpecialChars:null});return(t.replaceTabs=!sl())&&(t.specialChars=new RegExp(" |"+t.specialChars.source,Ui)),t.addSpecialChars&&(t.specialChars=new RegExp(t.specialChars.source+"|"+t.addSpecialChars.source,Ui)),t}});function Xl(s={}){return[Ae.of(s),nl()]}var tn=null;function nl(){return tn||(tn=V.fromClass(class{constructor(s){this.view=s,this.decorations=T.none,this.decorationCache=Object.create(null),this.decorator=this.makeDecorator(s.state.facet(Ae)),this.decorations=this.decorator.createDeco(s)}makeDecorator(s){return new ae({regexp:s.specialChars,decoration:(t,e,i)=>{let{doc:n}=e.state,r=cn(t[0],0);if(r==9){let o=n.lineAt(i),l=e.state.tabSize,a=fn(o.text,l,i-o.from);return T.replace({widget:new Ji((l-a%l)*this.view.defaultCharacterWidth/this.view.scaleX)})}return this.decorationCache[r]||(this.decorationCache[r]=T.replace({widget:new Qi(s,r)}))},boundary:s.replaceTabs?void 0:/[^]/})}update(s){let t=s.state.facet(Ae);s.startState.facet(Ae)!=t?(this.decorator=this.makeDecorator(t),this.decorations=this.decorator.createDeco(s.view)):this.decorations=this.decorator.updateDeco(s,this.decorations)}},{decorations:s=>s.decorations}))}var rl="\u2022";function ol(s){return s>=32?rl:s==10?"\u2424":String.fromCharCode(9216+s)}var Qi=class extends ot{constructor(t,e){super(),this.options=t,this.code=e}eq(t){return t.code==this.code}toDOM(t){let e=ol(this.code),i=t.state.phrase("Control character")+" "+(il[this.code]||"0x"+this.code.toString(16)),n=this.options.render&&this.options.render(this.code,i,e);if(n)return n;let r=document.createElement("span");return r.textContent=e,r.title=i,r.setAttribute("aria-label",i),r.className="cm-specialChar",r}ignoreEvent(){return!1}},Ji=class extends ot{constructor(t){super(),this.width=t}eq(t){return t.width==this.width}toDOM(){let t=document.createElement("span");return t.textContent=" ",t.className="cm-tab",t.style.width=this.width+"px",t}ignoreEvent(){return!1}},en=V.fromClass(class{constructor(){this.height=1e3,this.attrs={style:"padding-bottom: 1000px"}}update(s){let{view:t}=s,e=t.viewState.editorHeight-t.defaultLineHeight-t.documentPadding.top-.5;e>=0&&e!=this.height&&(this.height=e,this.attrs={style:`padding-bottom: ${e}px`})}});function $l(){return[en,$e.of(s=>{var t;return((t=s.plugin(en))===null||t===void 0?void 0:t.attrs)||null})]}function Gl(){return al}var ll=T.line({class:"cm-activeLine"}),al=V.fromClass(class{constructor(s){this.decorations=this.getDeco(s)}update(s){(s.docChanged||s.selectionSet)&&(this.decorations=this.getDeco(s.view))}getDeco(s){let t=-1,e=[];for(let i of s.state.selection.ranges){let n=s.lineBlockAt(i.head);n.from>t&&(e.push(ll.range(n.from)),t=n.from)}return T.set(e)}},{decorations:s=>s.decorations}),Zi=class extends ot{constructor(t){super(),this.content=t}toDOM(t){let e=document.createElement("span");return e.className="cm-placeholder",e.style.pointerEvents="none",e.appendChild(typeof this.content=="string"?document.createTextNode(this.content):typeof this.content=="function"?this.content(t):this.content.cloneNode(!0)),e.setAttribute("aria-hidden","true"),e}coordsAt(t){let e=t.firstChild?$t(t.firstChild):[];if(!e.length)return null;let i=window.getComputedStyle(t.parentNode),n=oe(e[0],i.direction!="rtl"),r=parseInt(i.lineHeight);return n.bottom-n.top>r*1.5?{left:n.left,right:n.right,top:n.top,bottom:n.top+r}:n}ignoreEvent(){return!1}};function Ul(s){let t=V.fromClass(class{constructor(e){this.view=e,this.placeholder=s?T.set([T.widget({widget:new Zi(s),side:1}).range(0)]):T.none}get decorations(){return this.view.state.doc.length?T.none:this.placeholder}},{decorations:e=>e.decorations});return typeof s=="string"?[t,A.contentAttributes.of({"aria-placeholder":s})]:t}var ts=2e3;function hl(s,t,e){let i=Math.min(t.line,e.line),n=Math.max(t.line,e.line),r=[];if(t.off>ts||e.off>ts||t.col<0||e.col<0){let o=Math.min(t.off,e.off),l=Math.max(t.off,e.off);for(let a=i;a<=n;a++){let h=s.doc.line(a);h.length<=l&&r.push(C.range(h.from+o,h.to+l))}}else{let o=Math.min(t.col,e.col),l=Math.max(t.col,e.col);for(let a=i;a<=n;a++){let h=s.doc.line(a),c=li(h.text,o,s.tabSize,!0);if(c<0)r.push(C.cursor(h.to));else{let f=li(h.text,l,s.t Line truncated
//# sourceMappingURL=view.bundle.mjs.map
+3
View File
@@ -0,0 +1,3 @@
/* esm.sh - @lezer/common@1.5.4 */
var De=1024,Pe=0,I=class{constructor(e,t){this.from=e,this.to=t}},k=class{constructor(e={}){this.id=Pe++,this.perNode=!!e.perNode,this.deserialize=e.deserialize||(()=>{throw new Error("This node type doesn't define a deserialize function")}),this.combine=e.combine||null}add(e){if(this.perNode)throw new RangeError("Can't add per-node props to node types");return typeof e!="function"&&(e=M.match(e)),t=>{let r=e(t);return r===void 0?null:[this,r]}}};k.closedBy=new k({deserialize:s=>s.split(" ")});k.openedBy=new k({deserialize:s=>s.split(" ")});k.group=new k({deserialize:s=>s.split(" ")});k.isolate=new k({deserialize:s=>{if(s&&s!="rtl"&&s!="ltr"&&s!="auto")throw new RangeError("Invalid value for isolate: "+s);return s||"auto"}});k.contextHash=new k({perNode:!0});k.lookAhead=new k({perNode:!0});k.mounted=new k({perNode:!0});var W=class{constructor(e,t,r,n=!1){this.tree=e,this.overlay=t,this.parser=r,this.bracketed=n}static get(e){return e&&e.props&&e.props[k.mounted.id]}},Ee=Object.create(null),M=class s{constructor(e,t,r,n=0){this.name=e,this.props=t,this.id=r,this.flags=n}static define(e){let t=e.props&&e.props.length?Object.create(null):Ee,r=(e.top?1:0)|(e.skipped?2:0)|(e.error?4:0)|(e.name==null?8:0),n=new s(e.name||"",t,e.id,r);if(e.props){for(let i of e.props)if(Array.isArray(i)||(i=i(n)),i){if(i[0].perNode)throw new RangeError("Can't store a per-node prop on a node type");t[i[0].id]=i[1]}}return n}prop(e){return this.props[e.id]}get isTop(){return(this.flags&1)>0}get isSkipped(){return(this.flags&2)>0}get isError(){return(this.flags&4)>0}get isAnonymous(){return(this.flags&8)>0}is(e){if(typeof e=="string"){if(this.name==e)return!0;let t=this.prop(k.group);return t?t.indexOf(e)>-1:!1}return this.id==e}static match(e){let t=Object.create(null);for(let r in e)for(let n of r.split(" "))t[n]=e[r];return r=>{for(let n=r.prop(k.group),i=-1;i<(n?n.length:0);i++){let l=t[i<0?r.name:n[i]];if(l)return l}}}};M.none=new M("",Object.create(null),0,8);var xe=class s{constructor(e){this.types=e;for(let t=0;t<e.length;t++)if(e[t].id!=t)throw new RangeError("Node type ids should correspond to array positions when creating a node set")}extend(...e){let t=[];for(let r of this.types){let n=null;for(let i of e){let l=i(r);if(l){n||(n=Object.assign({},r.props));let f=l[1],h=l[0];h.combine&&h.id in n&&(f=h.combine(n[h.id],f)),n[h.id]=f}}t.push(n?new M(r.name,n,r.id,r.flags):r)}return new s(t)}},Z=new WeakMap,we=new WeakMap,x;(function(s){s[s.ExcludeBuffers=1]="ExcludeBuffers",s[s.IncludeAnonymous=2]="IncludeAnonymous",s[s.IgnoreMounts=4]="IgnoreMounts",s[s.IgnoreOverlays=8]="IgnoreOverlays",s[s.EnterBracketed=16]="EnterBracketed"})(x||(x={}));var E=class s{constructor(e,t,r,n,i){if(this.type=e,this.children=t,this.positions=r,this.length=n,this.props=null,i&&i.length){this.props=Object.create(null);for(let[l,f]of i)this.props[typeof l=="number"?l:l.id]=f}}toString(){let e=W.get(this);if(e&&!e.overlay)return e.tree.toString();let t="";for(let r of this.children){let n=r.toString();n&&(t&&(t+=","),t+=n)}return this.type.name?(/\W/.test(this.type.name)&&!this.type.isError?JSON.stringify(this.type.name):this.type.name)+(t.length?"("+t+")":""):t}cursor(e=0){return new q(this.topNode,e)}cursorAt(e,t=0,r=0){let n=Z.get(this)||this.topNode,i=new q(n);return i.moveTo(e,t),Z.set(this,i._tree),i}get topNode(){return new P(this,0,0,null)}resolve(e,t=0){let r=Q(Z.get(this)||this.topNode,e,t,!1);return Z.set(this,r),r}resolveInner(e,t=0){let r=Q(we.get(this)||this.topNode,e,t,!0);return we.set(this,r),r}resolveStack(e,t=0){return Oe(this,e,t)}iterate(e){let{enter:t,leave:r,from:n=0,to:i=this.length}=e,l=e.mode||0,f=(l&x.IncludeAnonymous)>0;for(let h=this.cursor(l|x.IncludeAnonymous);;){let u=!1;if(h.from<=i&&h.to>=n&&(!f&&h.type.isAnonymous||t(h)!==!1)){if(h.firstChild())continue;u=!0}for(;u&&r&&(f||!h.type.isAnonymous)&&r(h),!h.nextSibling();){if(!h.parent())return;u=!0}}}prop(e){return e.perNode?this.props?this.props[e.id]:void 0:this.type.prop(e)}get propValues(){let e=[];if(this.props)for(let t in this.props)e.push([+t,this.props[t]]);return e}balance(e={}){return this.children.length<=8?this:me(M.none,this.children,this.positions,0,this.children.length,0,this.length,(t,r,n)=>new s(this.type,t,r,n,this.propValues),e.makeTree||((t,r,n)=>new s(M.none,t,r,n)))}static build(e){return Te(e)}};E.empty=new E(M.none,[],[],0);var le=class s{constructor(e,t){this.buffer=e,this.index=t}get id(){return this.buffer[this.index-4]}get start(){return this.buffer[this.index-3]}get end(){return this.buffer[this.index-2]}get size(){return this.buffer[this.index-1]}get pos(){return this.index}next(){this.index-=4}fork(){return new s(this.buffer,this.index)}},H=class s{constructor(e,t,r){this.buffer=e,this.length=t,this.set=r}get type(){return M.none}toString(){let e=[];for(let t=0;t<this.buffer.length;)e.push(this.childString(t)),t=this.buffer[t+3];return e.join(",")}childString(e){let t=this.buffer[e],r=this.buffer[e+3],n=this.set.types[t],i=n.name;if(/\W/Line truncated
//# sourceMappingURL=common.bundle.mjs.map
+4
View File
@@ -0,0 +1,4 @@
/* esm.sh - @lezer/highlight@1.2.5 */
import{NodeProp as V}from"./lezer-common.mjs";var G=0,p=class s{constructor(t,a,n,i){this.name=t,this.set=a,this.base=n,this.modified=i,this.id=G++}toString(){let{name:t}=this;for(let a of this.modified)a.name&&(t=`${a.name}(${t})`);return t}static define(t,a){let n=typeof t=="string"?t:"?";if(t instanceof s&&(a=t),a?.base)throw new Error("Can not derive from a modified tag");let i=new s(n,[],null,[]);if(i.set.push(i),a)for(let o of a.set)i.set.push(o);return i}static defineModifier(t){let a=new E(t);return n=>n.modified.indexOf(a)>-1?n:E.get(n.base||n,n.modified.concat(a).sort((i,o)=>i.id-o.id))}},L=0,E=class s{constructor(t){this.name=t,this.instances=[],this.id=L++}static get(t,a){if(!a.length)return t;let n=a[0].instances.find(r=>r.base==t&&P(a,r.modified));if(n)return n;let i=[],o=new p(t.name,i,t,a);for(let r of a)r.instances.push(o);let h=Q(a);for(let r of t.set)if(!r.modified.length)for(let d of h)i.push(s.get(r,d));return o}};function P(s,t){return s.length==t.length&&s.every((a,n)=>a==t[n])}function Q(s){let t=[[]];for(let a=0;a<s.length;a++)for(let n=0,i=t.length;n<i;n++)t.push(t[n].concat(s[a]));return t.sort((a,n)=>n.length-a.length)}function _(s){let t=Object.create(null);for(let a in s){let n=s[a];Array.isArray(n)||(n=[n]);for(let i of a.split(" "))if(i){let o=[],h=2,r=i;for(let g=0;;){if(r=="..."&&g>0&&g+3==i.length){h=1;break}let f=/^"(?:[^"\\]|\\.)*?"|[^\/!]+/.exec(r);if(!f)throw new RangeError("Invalid path: "+i);if(o.push(f[0]=="*"?"":f[0][0]=='"'?JSON.parse(f[0]):f[0]),g+=f[0].length,g==i.length)break;let m=i[g++];if(g==i.length&&m=="!"){h=0;break}if(m!="/")throw new RangeError("Invalid path: "+i);r=i.slice(g)}let d=o.length-1,c=o[d];if(!c)throw new RangeError("Invalid path: "+i);let x=new v(n,h,d>0?o.slice(0,d):null);t[c]=x.sort(t[c])}}return z.add(t)}var z=new V({combine(s,t){let a,n,i;for(;s||t;){if(!s||t&&s.depth<=t.depth?(i=t,t=t.next):(i=s,s=s.next),a&&a.mode==i.mode&&!i.context&&!a.context)continue;let o=new v(i.tags,i.mode,i.context);a?a.next=o:n=o,a=o}return n}}),v=class{constructor(t,a,n,i){this.tags=t,this.mode=a,this.context=n,this.next=i}get opaque(){return this.mode==0}get inherit(){return this.mode==1}sort(t){return!t||t.depth<this.depth?(this.next=t,this):(t.next=this.sort(t.next),t)}get depth(){return this.context?this.context.length:0}};v.empty=new v([],2,null);function U(s,t){let a=Object.create(null);for(let o of s)if(!Array.isArray(o.tag))a[o.tag.id]=o.class;else for(let h of o.tag)a[h.id]=o.class;let{scope:n,all:i=null}=t||{};return{style:o=>{let h=i;for(let r of o)for(let d of r.set){let c=a[d.id];if(c){h=h?h+" "+c:c;break}}return h},scope:n}}function W(s,t){let a=null;for(let n of s){let i=n.style(t);i&&(a=a?a+" "+i:i)}return a}function X(s,t,a,n=0,i=s.length){let o=new B(n,Array.isArray(t)?t:[t],a);o.highlightRange(s.cursor(),n,i,"",o.highlighters),o.flush(i)}function tt(s,t,a,n,i,o=0,h=s.length){let r=o;function d(c,x){if(!(c<=r)){for(let g=s.slice(r,c),f=0;;){let m=g.indexOf(`
`,f),R=m<0?g.length:m;if(R>f&&n(g.slice(f,R),x),m<0)break;i(),f=m+1}r=c}}X(t,a,(c,x,g)=>{d(c,""),d(x,g)},o,h),d(h,"")}var B=class{constructor(t,a,n){this.at=t,this.highlighters=a,this.span=n,this.class=""}startSpan(t,a){a!=this.class&&(this.flush(t),t>this.at&&(this.at=t),this.class=a)}flush(t){t>this.at&&this.class&&this.span(this.at,t,this.class)}highlightRange(t,a,n,i,o){let{type:h,from:r,to:d}=t;if(r>=n||d<=a)return;h.isTop&&(o=this.highlighters.filter(m=>!m.scope||m.scope(h)));let c=i,x=Y(t)||v.empty,g=W(o,x.tags);if(g&&(c&&(c+=" "),c+=g,x.mode==1&&(i+=(i?" ":"")+g)),this.startSpan(Math.max(a,r),c),x.opaque)return;let f=t.tree&&t.tree.prop(V.mounted);if(f&&f.overlay){let m=t.node.enter(f.overlay[0].from+r,1),R=this.highlighters.filter(M=>!M.scope||M.scope(f.tree.type)),H=t.firstChild();for(let M=0,S=r;;M++){let O=M<f.overlay.length?f.overlay[M]:null,K=O?O.from+r:d,$=Math.max(a,S),T=Math.min(n,K);if($<T&&H)for(;t.from<T&&(this.highlightRange(t,$,T,i,o),this.startSpan(Math.min(T,t.to),c),!(t.to>=K||!t.nextSibling())););if(!O||K>n)break;S=O.to+r,S>a&&(this.highlightRange(m.cursor(),Math.max(a,O.from+r),Math.min(n,S),"",R),this.startSpan(Math.min(n,S),c))}H&&t.parent()}else if(t.firstChild()){f&&(i="");do if(!(t.to<=a)){if(t.from>=n)break;this.highlightRange(t,a,n,i,o),this.startSpan(Math.min(n,t.to),c)}while(t.nextSibling());t.parent()}}};function Y(s){let t=s.type.prop(z);for(;t&&t.context&&!s.matchContext(t.context);)t=t.next;return t||null}var e=p.define,C=e(),b=e(),F=e(b),J=e(b),N=e(),I=e(N),j=e(N),y=e(),w=e(y),u=e(),k=e(),D=e(),A=e(D),q=e(),l={comment:C,lineComment:e(C),blockComment:e(C),docComment:e(C),name:b,variableName:e(b),typeName:F,tagName:e(F),propertyName:J,attributeName:e(J),className:e(b),labelName:e(b),namespace:e(b),macroName:e(b),literal:N,string:I,docString:e(I),character:e(I),attributeValue:e(I),number:j,integer:e(j),float:e(j),bool:e(N),regexp:e(N),escape:e(N),color:e(N),url:e(N),keyword:u,self:e(u),null:e(u),atom:e(u),unit:e(u),modifier:e(u),operatorKeyword:e(u),controlKeyword:e(u),definitionKeyword:e(u),moduleKeyword:e(u),operator:k,derefOperator:e(k),arithmeticOperator:e(k),logicOperator:e(k),bitwiseOperator:e(k),compareOperator:e(k),updateOperator:e(k),definitionOperator:e(k),typeOperator:e(k),controlOperator:e(k),punctuation:D,separator:e(D),bracket:A,angleBracket:e(A),squareBracket:e(A),paren:e(A),brace:e(A),content:y,heading:w,heading1:e(w),heading2:e(w),heading3:e(w),heading4:e(w),heading5:e(w),heading6:e(w),contentSeparator:e(y),list:e(y),quote:e(y),emphasis:e(y),strong:e(y),link:e(y),monospace:e(y),strikethrough:e(y),inserted:e(),deleted:e(),changed:e(),invalid:e(),meta:q,documentMeta:e(q),annotation:e(q),processingInstruction:e(q),definition:p.defineModifier("definition"),constant:p.defineModifier("constant"),function:p.defineModifier("function"),standard:p.defineModifier("standard"),local:p.defineModifier("local"),special:p.defineModifier("special")};for(let s in l){let t=l[s];t instanceof p&&(t.name=s)}var et=U([{tag:l.link,class:"tok-link"},{tag:l.heading,class:"tok-heading"},{tag:l.emphasis,class:"tok-emphasis"},{tag:l.strong,class:"tok-strong"},{tag:l.keyword,class:"tok-keyword"},{tag:l.atom,class:"tok-atom"},{tag:l.bool,class:"tok-bool"},{tag:l.url,class:"tok-url"},{tag:l.labelName,class:"tok-labelName"},{tag:l.inserted,class:"tok-inserted"},{tag:l.deleted,class:"tok-deleted"},{tag:l.literal,class:"tok-literal"},{tag:l.string,class:"tok-string"},{tag:l.number,class:"tok-number"},{tag:[l.regexp,l.escape,l.special(l.string)],class:"tok-string2"},{tag:l.variableName,class:"tok-variableName"},{tag:l.local(l.variableName),class:"tok-variableName tok-local"},{tag:l.definition(l.variableName),class:"tok-variableName tok-definition"},{tag:l.special(l.variableName),class:"tok-variableName2"},{tag:l.definition(l.propertyName),class:"tok-propertyName tok-definition"},{tag:l.typeName,class:"tok-typeName"},{tag:l.namespace,class:"tok-namespace"},{tag:l.className,class:"tok-className"},{tag:l.macroName,class:"tok-macroName"},{tag:l.propertyName,class:"tok-propertyName"},{tag:l.operator,class:"tok-operator"},{tag:l.comment,class:"tok-comment"},{tag:l.meta,class:"tok-meta"},{tag:l.invalid,class:"tok-invalid"},{tag:l.punctuation,class:"tok-punctuation"}]);export{p as Tag,et as classHighlighter,Y as getStyleTags,tt as highlightCode,X as highlightTree,_ as styleTags,U as tagHighlighter,l as tags};
//# sourceMappingURL=highlight.bundle.mjs.map
+4
View File
@@ -0,0 +1,4 @@
/* esm.sh - @lezer/lr@1.4.11 */
import __Process$ from "./node-process.mjs";
import{Parser as q,NodeProp as O,NodeSet as J,NodeType as K,DefaultBufferLength as Q,Tree as w,IterMode as V}from"./lezer-common.mjs";var R=class a{constructor(t,e,s,i,h,r,n,o,l,u=0,f){this.p=t,this.stack=e,this.state=s,this.reducePos=i,this.pos=h,this.score=r,this.buffer=n,this.bufferBase=o,this.curContext=l,this.lookAhead=u,this.parent=f}toString(){return`[${this.stack.filter((t,e)=>e%3==0).concat(this.state)}]@${this.pos}${this.score?"!"+this.score:""}`}static start(t,e,s=0){let i=t.parser.context;return new a(t,[],e,s,s,0,[],0,i?new S(i,i.start):null,0,null)}get context(){return this.curContext?this.curContext.context:null}pushState(t,e){this.stack.push(this.state,e,this.bufferBase+this.buffer.length),this.state=t}reduce(t){var e;let s=t>>19,i=t&65535,{parser:h}=this.p,r=this.reducePos<this.pos-25&&this.setLookAhead(this.pos),n=h.dynamicPrecedence(i);if(n&&(this.score+=n),s==0){i<h.minRepeatTerm&&this.reducePos<this.pos&&(this.reducePos=this.pos),this.pushState(h.getGoto(this.state,i,!0),this.reducePos),i<h.minRepeatTerm&&this.storeNode(i,this.reducePos,this.reducePos,r?8:4,!0),this.reduceContext(i,this.reducePos);return}let o=this.stack.length-(s-1)*3-(t&262144?6:0),l=o?this.stack[o-2]:this.p.ranges[0].from;i<h.minRepeatTerm&&l==this.reducePos&&this.reducePos<this.pos&&(this.reducePos=this.pos);let u=this.reducePos-l;u>=2e3&&!(!((e=this.p.parser.nodeSet.types[i])===null||e===void 0)&&e.isAnonymous)&&(l==this.p.lastBigReductionStart?(this.p.bigReductionCount++,this.p.lastBigReductionSize=u):this.p.lastBigReductionSize<u&&(this.p.bigReductionCount=1,this.p.lastBigReductionStart=l,this.p.lastBigReductionSize=u));let f=o?this.stack[o-1]:0,d=this.bufferBase+this.buffer.length-f;if(i<h.minRepeatTerm||t&131072){let c=h.stateFlag(this.state,1)?this.pos:this.reducePos;this.storeNode(i,l,c,d+4,!0)}if(t&262144)this.state=this.stack[o];else{let c=this.stack[o-3];this.state=h.getGoto(c,i,!0)}for(;this.stack.length>o;)this.stack.pop();this.reduceContext(i,l)}storeNode(t,e,s,i=4,h=!1){if(t==0&&(!this.stack.length||this.stack[this.stack.length-1]<this.buffer.length+this.bufferBase)){let r=this.buffer.length;if(r>0&&this.buffer[r-4]==0&&this.buffer[r-1]>-1){if(e==s)return;if(this.buffer[r-2]>=e){this.buffer[r-2]=s;return}}}if(!h||this.pos==s)this.buffer.push(t,e,s,i);else{let r=this.buffer.length;if(r>0&&(this.buffer[r-4]!=0||this.buffer[r-1]<0)){let n=!1;for(let o=r;o>0&&this.buffer[o-2]>s;o-=4)if(this.buffer[o-1]>=0){n=!0;break}if(n)for(;r>0&&this.buffer[r-2]>s;)this.buffer[r]=this.buffer[r-4],this.buffer[r+1]=this.buffer[r-3],this.buffer[r+2]=this.buffer[r-2],this.buffer[r+3]=this.buffer[r-1],r-=4,i>4&&(i-=4)}this.buffer[r]=t,this.buffer[r+1]=e,this.buffer[r+2]=s,this.buffer[r+3]=i}}shift(t,e,s,i){if(t&131072)this.pushState(t&65535,this.pos);else if((t&262144)==0){let h=t,{parser:r}=this.p;this.pos=i;let n=r.stateFlag(h,1);!n&&(i>s||e<=r.maxNode)&&(this.reducePos=i),this.pushState(h,n?s:Math.min(s,this.reducePos)),this.shiftContext(e,s),e<=r.maxNode&&this.buffer.push(e,s,i,4)}else this.pos=i,this.shiftContext(e,s),e<=this.p.parser.maxNode&&this.buffer.push(e,s,i,4)}apply(t,e,s,i){t&65536?this.reduce(t):this.shift(t,e,s,i)}useNode(t,e,s){let i=this.p.reused.length-1;(i<0||this.p.reused[i]!=t)&&(this.p.reused.push(t),i++);let h=this.reducePos=this.pos=s+t.length;this.pushState(e,s),this.buffer.push(i,s,h,-1),this.curContext&&this.updateContext(this.curContext.tracker.reuse(this.curContext.context,t,this,this.p.stream.reset(this.pos-t.length)))}split(){let t=this,e=t.buffer.length;for(e&&t.buffer[e-4]==0&&(e-=4);e>0&&t.buffer[e-2]>t.reducePos;)e-=4;let s=t.buffer.slice(e),i=t.bufferBase+e;for(;t&&i==t.bufferBase;)t=t.parent;return new a(this.p,this.stack.slice(),this.state,this.reducePos,this.pos,this.score,s,i,this.curContext,this.lookAhead,t)}recoverByDelete(t,e){let s=t<=this.p.parser.maxNode;s&&this.storeNode(t,this.pos,e,4),this.storeNode(0,this.pos,e,s?8:4),this.pos=this.reducePos=e,this.score-=190}canShift(t){for(let e=new y(this);;){let s=this.p.parser.stateSlot(e.state,4)||this.p.parser.hasAction(e.state,t);if(s==0)return!1;if((s&65536)==0)return!0;e.reduce(s)}}recoverByInsert(t){if(this.stack.length>=300)return[];let e=this.p.parser.nextStates(this.state);if(e.length>8||this.stack.length>=120){let i=[];for(let h=0,r;h<e.length;h+=2)(r=e[h+1])!=this.state&&this.p.parser.hasAction(r,t)&&i.push(e[h],r);if(this.stack.length<120)for(let h=0;i.length<8&&h<e.length;h+=2){let r=e[h+1];i.some((n,o)=>o&1&&n==r)||i.push(e[h],r)}e=i}let s=[];for(let i=0;i<e.length&&s.length<4;i+=2){let h=e[i+1];if(h==this.state)continue;let r=this.split();r.pushState(h,this.pos),r.storeNode(0,r.pos,r.pos,4,!0),r.shiftContext(e[i],this.pos),r.reducePos=this.pos,r.score-=200,s.push(r)}return s}forceReduce(){let{parser:t}=this.p,e=t.stateSlot(this.state,5);if((e&65536)==0)return!1;if(!t.validAction(this.state,e)){let s=e>>19,i=e&65535,h=this.stack.length-s*3;if(h<0||t.getGoto(this.stack[h],i,!1)<0){let r=this.findForcedReduction();if(r==nuLine truncated
//# sourceMappingURL=lr.bundle.mjs.map
+9
View File
@@ -0,0 +1,9 @@
/*
The `process` that esm.sh's builds import as "/node/process.mjs" (its Node
polyfill). @lezer/lr reads `process.env.LOG` to decide whether to log its
parse; nothing else here asks. Without this the absolute path is a 404 on
this server, @lezer/lr fails to load, and with it every editor language
that parses: the editor then opens with no highlighting at all.
Mapped by the import map in files/edit.html.
*/
export default { env: {} };
+24
View File
@@ -0,0 +1,24 @@
{
"tag": "v2.0.0-beta.6",
"commit": "a71e9b4bdb8acb6e9949e978253a72c347cc67f3",
"files": {
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},
"cm-commands.mjs": {"server": "e1868cd8ddb1cbcab5f8acedfaf1ab173d895c0b684b58af573e5991a0ab7dc3", "copy": "20e89b7bc60658df1c5da3b079d4a14f29fc115813062adbac2b16898b283cc8"},
"cm-lang-css.mjs": {"server": "2034378ea0d0ce561805bfae0c6b270e945302eedf2717410a750b59d26098fe", "copy": "f969b1b5d2a70deb60cbc1f6634bb9d403c7ab5b92d40713c19688e5be743163"},
"cm-lang-html.mjs": {"server": "c431ac565b0b38e77b4900f8e0786a48ad4c74fe3afb1a81253de358ba06ac18", "copy": "3c65a8328577b1604a64b22b161792434a7202b514649c7ddef360f9a1761890"},
"cm-lang-javascript.mjs": {"server": "97256d9ab7131a42529bb411fc5271837a215efddd63d4a0a6ddd6bd00d0820e", "copy": "b01ab6b30753bb59ba6ca32308aec293820050be43a9fb4446cfdd572c53ac19"},
"cm-lang-json.mjs": {"server": "b58f106e7c0e3d013e0f4a0bde86c4e39b9644576c4244e769271768c6c0af4f", "copy": "74b7041d6d25a3368e666a56312c5444a14180ea1bb10d1e670747b62fe254c5"},
"cm-lang-markdown.mjs": {"server": "5b77c954b306115cfda133d7d2535e45e90abcae0e8c4a9dec08fd4f83e16a65", "copy": "0254eafb658a3ed7e8d5eafb17996f5963855007d95c4dedf8a0e3e7c048c82d"},
"cm-lang-python.mjs": {"server": "8a9ed958f993dbf2c9cb52200383f7894096ef4b400726fc14ce9737077d1f75", "copy": "d53c16d58fa6c5a47a83ecd012d2cb6c8215996261de5f0cd668ec11273af9a1"},
"cm-language.mjs": {"server": "1400a75c21ad190b1fd2050f9d8cfdc948d8d2e4a9467b70ed3953b70498ef5d", "copy": "dfacb8f324365281a1faed43ce8b643f0b663a0e296e9a2e2057c1602b47d3e3"},
"cm-lint.mjs": {"server": "88f8d9f4ff305d3a32b6ca44ca78e901c24495b3def2fbb8dc1b351d746f962c", "copy": "5a6b78bd165fab1952d042d786f7fddd3ad229d553f8baedb59cfd119047a65a"},
"cm-search.mjs": {"server": "75a5bd0a92b1cda20ca075ae8ce3b42dcaa4847c7f7de3f7ef3e9ec6064157c1", "copy": "ee433bb77e124a717dffce54c9f1203b8838f9740d8ff7023da3ec5ec354fff3"},
"cm-state.mjs": {"server": "9db7c46f6be425dbea814af001692939d694b571b3d8df683b87abb635132d4c", "copy": "9db7c46f6be425dbea814af001692939d694b571b3d8df683b87abb635132d4c"},
"cm-view.mjs": {"server": "ae7c45442292a6cb1bff0c82b0493924489911ff4f239eb2067293180c0f1d5e", "copy": "7ecdf6913bff51de827d31d56641ccb82f7dfb3801dde0e40c32eadc98815226"},
"lezer-common.mjs": {"server": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f", "copy": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f"},
"lezer-highlight.mjs": {"server": "b74148d673103ae08330a045a464acddf4ee8b563b213ea26e50f2be01a6d19d", "copy": "df21a1741ae80f2ad2b6d25860cad9ff3cda246a4fba171535d9dc92df150c1a"},
"lezer-lr.mjs": {"server": "4c2dcc61b5c3aedb091dfd60ce8abaeb94704b58fa9036547b0b8d2c75f701e4", "copy": "f41bc21dc3fd874132e1d9862aae57946ae2604d91330303924c03aecfd3bc21"},
"node-process.mjs": {"server": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981", "copy": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981"}
}
}