17 Commits
Author SHA1 Message Date
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
HomerandClaude Opus 5.5 8f04494ba8 LLeMbas Desktop 0.7.1, re-released: the fix folded in, no 0.7.2
ci / check (push) Failing after 3s
release / linux (push) Failing after 3s
The first 0.7.1 aborted on Linux before its window appeared (it read
WebKitWebView's write-only 'settings' property). The owner asked for the
patch to be released again rather than followed by a 0.7.2: this commit is
0.7.1 as it should have been, and tag v0.7.1 moves here. The release
workflow now replaces the release of a tag that is pushed again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:40:43 +00:00
HomerandClaude Opus 5.5 8062a8ac77 LLeMbas Desktop 0.7.2: starts again on Linux
release / linux (push) Failing after 3s
ci / check (push) Failing after 3s
0.7.1 read WebKitWebView's 'settings' property through GLib; it is write-only,
GLib panicked, and the app aborted before its window appeared. The read is
gone; the microphone permission handler stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:39:32 +00:00
HomerandClaude Opus 5.5 4a982ae089 LLeMbas Desktop 0.7.1, carrying LLeMbas CLI 1.5.1
release / linux (push) Failing after 2s
ci / check (push) Failing after 3s
The microphone: WebKitGTK refuses a permission request its app does not
answer, and has no prompt of its own, so dictation on the server's page was
always blocked. The content view now allows the microphone -- only that, and
only there -- and media capture is switched on explicitly for the older
WebKitGTK the AppImage carries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 21:28:17 +00:00
HomerandClaude Fable 5.1 d9e2c4a01d LLeMbas Desktop 0.7.0, carrying LLeMbas CLI 1.5.0
ci / check (push) Failing after 3s
release / linux (push) Failing after 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 16:19:37 +00:00
HomerandClaude Opus 5.5 e3da95247d LLeMbas Desktop 0.6.0, carrying LLeMbas CLI 1.4.0
ci / check (push) Failing after 3s
release / linux (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:25:01 +00:00
HomerandClaude Fable 5.1 ae62cbf7cd LLeMbas Desktop 0.5.0, carrying LLeMbas CLI 1.3.0
ci / check (push) Failing after 4s
release / linux (push) Failing after 3s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 04:45:43 +02:00
HomerandClaude Fable 5.1 9ca03493b2 The app's own pages in the server's nine languages
scripts/gen-i18n.py writes ui/i18n.js from the server's catalogues where the
same sentence exists and from its own table otherwise; the pages pick the
language from the browser and fall back to English.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 03:38:31 +02:00
HomerandClaude Fable 5.1 881a222f5f The look from the server's tokens, pinned; the frame and bar follow the page's theme
ui/tokens.css is no longer a hand copy that drifts: scripts/fetch-tokens.sh
takes the server's tokens.css at a git ref and tokens.lock.json pins it, as
fetch-cli.sh does for the CLI. build.rs reads the two themes' --bg and --border
out of it, so no colour is written in Rust. The content view reports the page's
theme by a navigation the app refuses (llembas-theme:<name>), and the window,
the title bar and the first-run page are painted in it and remember it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 03:38:31 +02:00
HomerandClaude Opus 5.5 5028dd7ca1 LLeMbas Desktop 0.4.0, carrying LLeMbas CLI 1.2.0
ci / check (push) Failing after 2s
release / linux (push) Failing after 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:26:53 +00:00
HomerandClaude Opus 5.5 1e2fa39193 Project folders for the agent, Local, the menu under its button
ci / check (push) Failing after 3s
- Settings… lists the folders the agent may work in, with the system's
  folder dialog, remove, max mode and terminal, all through
  'lembas remote … --json' (CLI 1.2.0). Turning the agent on with no
  folder opens it; the title bar says 'agent: no folders' instead of green.
- The content webview's User-Agent carries LLeMbasDesktop/<v> (host=…),
  so the server can show this computer's device as Local.
- The title bar menu pops up at the button (popup_menu_at): Wayland gave
  the pointer-less popup the middle of the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:52:25 +00:00
HomerandClaude Opus 5.5 bb91ac62b0 LLeMbas Desktop 0.3.0
ci / check (push) Failing after 3s
release / linux (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:22:12 +00:00
HomerandClaude Opus 5.5 5eff067e75 Fill the window on Linux; updates
ci / check (push) Failing after 3s
- Linux: Tauri packs a window's webviews into one GTK box with equal
  shares and ignores their bounds, so the title bar took half the window.
  The title bar webview now has a fixed height and no share; the box's
  border is the resize frame (none when maximized).
- tauri-plugin-updater: checks llembas.eu/desktop/latest.json at start
  and every 6 h, an update button in the title bar and menu entries,
  .deb via pkexec, AppImage in place, then restart. Packages signed with
  the updater's minisign key; requireSignedVersion on.
  scripts/publish-update.sh signs a GitHub release and writes the manifest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:19:36 +00:00
HomerandClaude Opus 5.5 971e1ddc9f LLeMbas Desktop 0.2.0
ci / check (push) Failing after 3s
release / linux (push) Failing after 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:56:46 +00:00
HomerandClaude Opus 5.5 daf2cf31ce LLeMbas's own window, the leaf icon, one name for the app
ci / check (push) Failing after 3s
- The window has no system decoration: a local title bar webview (agent
  state, a native menu with the tray's items, window buttons) above the
  content webview, which stays remote with no IPC. Laid out on resize,
  with a frame to resize by unless maximized.
- Icons from the transparent pixel leaf, not the server's tiled icon.
- productName llembas-desktop: package, .desktop file and the window's
  app id now match (KWin showed a generic icon); the visible name stays
  LLeMbas through a desktop template. Replaces the 0.1.0 package l-le-mbas.
- The bundled CLI is a resource in the app's own directory, not
  /usr/bin/lembas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:54:55 +00:00
HomerandClaude Opus 5.5 5045ba025a release: SHA256SUMS no longer lists itself
ci / check (push) Failing after 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:36:11 +00:00
36 changed files with 3683 additions and 153 deletions

No files matched your search

+5 -2
View File
@@ -34,5 +34,8 @@ jobs:
- name: Tests
working-directory: src-tauri
run: cargo test --locked
- name: The first-run page's JavaScript
run: node --check ui/app.js
- name: The pages' JavaScript, and the script the content view runs
run: |
for f in ui/*.js src-tauri/src/*.js; do node --check "$f"; done
- name: The design tokens are the server's, as pinned
run: scripts/fetch-tokens.sh
+6 -1
View File
@@ -53,12 +53,17 @@ jobs:
mkdir dist
cp src-tauri/target/release/bundle/deb/*.deb src-tauri/target/release/bundle/appimage/*.AppImage dist/
cd dist
{ echo "# llembas-desktop $VERSION"; sha256sum -- *; } > SHA256SUMS
# Written beside the folder first: inside it, the glob would list SHA256SUMS itself.
{ echo "# llembas-desktop $VERSION"; sha256sum -- *; } > ../SHA256SUMS
mv ../SHA256SUMS .
cat SHA256SUMS
- name: Publish the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
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:
# the old one, and the files built from the old commit, go first.
gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --yes 2>/dev/null || true
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
--title "LLeMbas Desktop $VERSION" --notes-file notes.md dist/*
+1
View File
@@ -2,4 +2,5 @@
/src-tauri/target/
/src-tauri/gen/
# The bundled LLeMbas CLI: fetched and verified by scripts/fetch-cli.sh, never committed
/src-tauri/binaries/lembas
/src-tauri/binaries/lembas-*
+133
View File
@@ -6,6 +6,139 @@ All notable changes to LLeMbas Desktop. The format follows
## [Unreleased]
## [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
The microphone works, and LLeMbas CLI 1.5.1 inside.
### Fixed
- **Dictation said the microphone was blocked, with no way to allow it.** The server's page asks
for the microphone, and a webview grants it only if the app answers: WebKitGTK refuses whatever
it is not answered on, and shows no prompt of its own. Desktop now allows the microphone -- the
microphone only, and only for the server's page in the content view; a camera or a screen still
gets the platform's refusal.
### Changed
- Carries LLeMbas CLI 1.5.1: an image counts by its size, so a screenshot no longer makes the
agent compact.
## [0.7.0] — 2026-10-20
Carries LLeMbas CLI 1.5.0: harness spec 2.0.0 — background work as jobs the person can see,
enter and stop, and subagents that run in the background and talk back.
### Changed
- Carries LLeMbas CLI 1.5.0. The agent on this computer runs subagents in the background, keeps
every background command and subagent as a job of its session, and reports them to a LLeMbas
server 1.6.0, which shows them in the chat with Stop and a message box.
## [0.6.0] — 2026-10-20
Carries LLeMbas CLI 1.4.0: harness spec 1.2.0, so the agent's memory, notes and skills are the
same tools as the server's.
### Changed
- Carries LLeMbas CLI 1.4.0. The agent on this computer and a LLeMbas server 1.5.0 now send a
model the same library tools — `memory`, `note_view`, `note_manage`, `skills_list`,
`skill_view`, `skill_manage` — whether the agent uses its own library or the server's.
## [0.5.0] — 2026-10-18
Carries LLeMbas CLI 1.3.0: a plan in every agent chat, the instance's instructions and
knowledge scope on the device, Edit and Regenerate on agent chats. The app's look comes from
the server's tokens and follows the page's theme; its own pages speak nine languages.
### Changed
- The app's own pages take their look from the server's `tokens.css`, copied by
`scripts/fetch-tokens.sh` from a server tag and pinned in `tokens.lock.json` (the script with
no argument verifies the copy). The hand copy had drifted: `--danger`, `--control-h` and
`--text-xl` were not the server's. The pages name no colour, size or space of their own, and
no font is bundled: the font rules are left out of the copy, so the title bar and the
first-run page are set in the system's monospace face.
- The window's frame is painted in the theme's border colour read out of `tokens.css` at build
time (`build.rs`), not a value written in Rust, and it changes with the theme.
- The first-run page and the title bar speak the system's language when it is one of the
server's nine (Czech, German, Spanish, French, Hungarian, Italian, Polish, Slovak; English
otherwise). `ui/i18n.js` is generated by `scripts/gen-i18n.py`: the sentences the server also
has are translated as the server translates them, the app's own are in the script.
### Fixed
- The title bar followed the desktop's light or dark setting, not the account's theme, so it
could be dark over the parchment (Shire) page. It now follows the server page: a script in the
content view reports the page's theme to the app, which paints the frame, the title bar and
the first-run page in it and remembers it for the next start. The server's pages still get no
IPC; the report is a navigation attempt the app reads and refuses.
- The close button's hover colour was a literal white; it is the theme's inverse ink.
## [0.4.0] — 2026-10-14
Carries LLeMbas CLI 1.2.0, whose agent stays linked before any folder is allowed and follows
changes to its folders without a restart.
### Added
- **Project folders**: Settings… (in the title bar's and the tray's menu) lists the folders the
agent may work in, with *Add folder…* (the system's folder dialog), *Remove*, the most an agent
chat may do there, and whether the server may open a terminal. Turning the agent on with no
folder chosen opens this page. Changes go through the bundled CLI (`lembas remote`).
- The server is told it is shown in LLeMbas Desktop and on which computer, so it lists this
computer's device as **Local**, first.
### Fixed
- The title bar's menu opens under the button, not in the middle of the window.
- The agent no longer shows a green light while it may open no folder: the title bar says
*agent: no folders*.
## [0.3.0] — 2026-10-14
### Added
- Updates: the app looks for a new release at start and every six hours, shows an **update**
button in the title bar when there is one, and installs it (the .deb with the system's password
prompt, an AppImage in place) after checking its signature. The menu can also check by hand.
### Fixed
- On Linux the server's page took only the lower half of the window, under an empty title bar
area. The title bar is now its fixed height and the page takes the rest; the frame you resize
the window by is drawn around both.
## [0.2.0] — 2026-10-14
### Added
- The window is LLeMbas's own: a title bar in the LLeMbas look with the server's address, the
agent's state, a menu (the tray's items, plus Reload) and the window buttons. Closing still keeps
the app in the tray. The server's pages stay in a view of their own, with no access to the app.
### Fixed
- The app icon is the leaf alone, on a transparent background, instead of a dark square tile.
- The window shows the app's icon instead of a generic one (the window, the menu entry and the
package now share one name, `llembas-desktop`).
- The package is called `llembas-desktop` (it was `l-le-mbas`) and replaces the old one.
- The bundled LLeMbas CLI is installed in the app's own directory instead of as `/usr/bin/lembas`,
where it could hide, or be hidden by, a `lembas` installed by hand.
- `SHA256SUMS` on the GitHub release no longer lists itself.
## [0.1.0] — 2026-10-14
The first alpha, for Linux. It carries LLeMbas CLI 1.1.0.
+28 -8
View File
@@ -7,8 +7,9 @@ Your [LLeMbas](https://llembas.eu) server in a window of its own, with the agent
- **The agent, built in.** LLeMbas Desktop carries the [LLeMbas CLI](https://github.com/LLeMbas/LLeMbas-CLI)
and runs it in the background as this computer's device, so agent chats started on the server
can work in your projects here, without a terminal.
- **In the tray**: close the window and the agent keeps running; the tray menu starts and stops it,
links the computer, and changes the server.
- **Its own window**: a LLeMbas title bar with the agent's state and a menu (start and stop the
agent, link the computer, reload, change server, quit). Close the window and the agent keeps
running in the tray, which has the same menu.
> **Alpha.** Linux first (`.deb` and AppImage, x86-64). Windows and macOS later.
@@ -19,21 +20,32 @@ Download the `.deb` or the AppImage from the
`SHA256SUMS` in the same release.
```sh
sudo apt install ./LLeMbas_*_amd64.deb # Debian, Ubuntu
chmod +x LLeMbas_*.AppImage && ./LLeMbas_*.AppImage
sudo apt install ./llembas-desktop_*_amd64.deb # Debian, Ubuntu (replaces 0.1.0's l-le-mbas)
chmod +x llembas-desktop_*.AppImage && ./llembas-desktop_*.AppImage
```
On GNOME the tray needs the *AppIndicator* extension; without it, closing the window hides the
app, and starting it again brings the window back.
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.
## Updates
LLeMbas Desktop looks for a new release when it starts and every six hours. When there is one, an
**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
itself), then the app starts again. Every package is checked against the release signing key
built into the app before anything is installed.
## Use
1. Start LLeMbas and enter your server's address. Sign in there as you would in a browser.
2. From the tray, **Link this computer**. The approval page opens in the window; approve it.
3. The agent starts. In the server's new-chat screen, choose this computer for an agent chat.
3. The agent starts. Choose the folders it may work in (Settings…, *Add folder…*); a new
computer allows none until you do.
4. In the server's Agents tab, start an agent chat on **Local** and pick one of those folders.
The agent is the LLeMbas CLI's `lembas service`, using the CLI's own configuration
(`~/.config/lembas`). If you also installed the CLI and run `lembas service` yourself, the app sees
(`~/.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
it and does not start a second one: they are the same device.
## Build
@@ -43,6 +55,7 @@ Needs Rust (stable) and, on Linux, WebKitGTK 4.1 and an AppIndicator library:
```sh
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-tokens.sh # verifies ui/tokens.css against tokens.lock.json
cargo install tauri-cli --version "^2" --locked
cargo tauri dev # or: cargo tauri build
```
@@ -51,6 +64,13 @@ There is no Node and no bundler: the app's one local page is plain HTML, CSS and
`ui/`. Everything else on screen is the server's own web UI. The version of the CLI it carries is
in `lembas-cli.version`.
Two files in `ui/` are generated and committed. `ui/tokens.css` is the server's design tokens,
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
new tag when the server's look changes. `ui/i18n.js` is the pages' words in the server's
languages, written by `scripts/gen-i18n.py` from the server's catalogues and the script's own
table; run it when a string on the page changes.
## Licence
MIT; see [LICENSE](LICENSE). The bundled LLeMbas CLI is MIT too, with its own third-party notices.
+1 -1
View File
@@ -1 +1 @@
1.1.0
1.5.2
+6 -5
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Put the LLeMbas CLI that LLeMbas Desktop carries where Tauri's bundler wants it:
# src-tauri/binaries/lembas-<target triple>
# Put the LLeMbas CLI that LLeMbas Desktop carries where the bundle takes it from:
# src-tauri/binaries/lembas (a resource: installed in the app's own directory, never on PATH)
#
# scripts/fetch-cli.sh [target-triple] (default: this machine's, from rustc)
#
@@ -39,6 +39,7 @@ ssh-keygen -Y verify -f "$work/allowed" -I lembas-release -n lembas-release \
|| { echo "fetch-cli: $ASSET.gz does not match SHA256SUMS" >&2; exit 1; }
mkdir -p src-tauri/binaries
gunzip -c "$work/$ASSET.gz" > "src-tauri/binaries/lembas-$TRIPLE"
chmod 755 "src-tauri/binaries/lembas-$TRIPLE"
echo "fetch-cli: LLeMbas CLI $VERSION ($ASSET) -> src-tauri/binaries/lembas-$TRIPLE"
gunzip -c "$work/$ASSET.gz" > src-tauri/binaries/lembas
chmod 755 src-tauri/binaries/lembas
rm -f src-tauri/binaries/lembas-* # where an earlier version of this script put it
echo "fetch-cli: LLeMbas CLI $VERSION ($ASSET) -> src-tauri/binaries/lembas"
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Put the LLeMbas server's design tokens where the app's own pages read them from:
# ui/tokens.css (a copy of the server's static/css/tokens.css, never edited by hand)
#
# scripts/fetch-tokens.sh <server-ref> copy the tokens at that ref (a tag such as v1.3.0,
# or HEAD) and write tokens.lock.json
# scripts/fetch-tokens.sh verify: ui/tokens.css matches tokens.lock.json
#
# The server is a git checkout, LLeMbas beside this repository by default (LLEMBAS_SERVER names
# another). The one change made to the copy: the @font-face rules are left out, with the comment
# above them. They name woff2 files the server vendors and the app does not carry; the app's pages
# fall through the same `--font-ui` stack to the system's monospace face, and without the rules
# the webview does not ask for two files that are not there.
#
# tokens.lock.json records the ref, the commit it resolved to, and the SHA-256 of ui/tokens.css
# as written, which is what verify checks. Like fetch-cli.sh and lembas-cli.version: the file in
# the tree says which server version the look comes from, and CI refuses a copy that drifted.
set -euo pipefail
cd "$(dirname "$0")/.."
SERVER="${LEMBAS_SERVER:-../LLeMbas}"
SOURCE="src/lembas/web/static/css/tokens.css"
OUT="ui/tokens.css"
LOCK="tokens.lock.json"
sha() { sha256sum "$1" | cut -d' ' -f1; }
if [ $# -eq 0 ]; then
[ -f "$LOCK" ] || { echo "fetch-tokens: no $LOCK; run with a server ref first" >&2; exit 1; }
[ -f "$OUT" ] || { echo "fetch-tokens: no $OUT; run with a server ref first" >&2; exit 1; }
want="$(sed -n -E 's/^ *"sha256": *"([0-9a-f]{64})".*/\1/p' "$LOCK" | head -n1)"
ref="$(sed -n -E 's/^ *"tag": *"([^"]+)".*/\1/p' "$LOCK" | head -n1)"
have="$(sha "$OUT")"
if [ "$want" != "$have" ]; then
echo "fetch-tokens: $OUT does not match $LOCK (server $ref): $have, expected $want" >&2
exit 1
fi
echo "fetch-tokens: $OUT matches $LOCK (server $ref)"
exit 0
fi
REF="$1"
[ -d "$SERVER/.git" ] || [ -f "$SERVER/.git" ] \
|| { echo "fetch-tokens: $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-tokens: $REF is not a commit in $SERVER" >&2; exit 1; }
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
git -C "$SERVER" show "$commit:$SOURCE" > "$work/tokens.css" \
|| { echo "fetch-tokens: $SOURCE is not in $SERVER at $REF" >&2; exit 1; }
# A comment immediately before an @font-face goes with it; then every @font-face block.
perl -0pe 's#/\*(?:(?!\*/).)*?\*/\s*(?=\@font-face)##gs; s#\@font-face\s*\{[^}]*\}\s*##g' \
"$work/tokens.css" > "$work/body.css"
grep -q -- '--border:' "$work/body.css" && grep -q -- '--bg:' "$work/body.css" \
|| { echo "fetch-tokens: $SOURCE at $REF has no --border or --bg: not the tokens file" >&2; exit 1; }
! grep -q '@font-face' "$work/body.css" \
|| { echo "fetch-tokens: an @font-face survived the strip; fix the script" >&2; exit 1; }
{
printf '/* LLeMbas %s (%s) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.\n' "$REF" "${commit:0:12}"
printf ' The font-face rules are left out; the app bundles no font. */\n'
cat "$work/body.css"
} > "$work/out.css"
cp "$work/out.css" "$OUT"
printf '{\n "tag": "%s",\n "commit": "%s",\n "sha256": "%s"\n}\n' "$REF" "$commit" "$(sha "$OUT")" > "$LOCK"
echo "fetch-tokens: LLeMbas $REF (${commit:0:12}) $SOURCE -> $OUT; $LOCK written"
+621
View File
@@ -0,0 +1,621 @@
#!/usr/bin/env python3
"""Write ui/i18n.js: the app's own pages' words in the server's nine languages.
scripts/gen-i18n.py [path/to/LLeMbas] (default: ../LLeMbas, or $LEMBAS_SERVER)
The server's catalogues (src/lembas/web/i18n/<lang>.py, keyed by the English sentence) are
the first source: a sentence the server also has is translated as the server translates it,
so the title bar says "Zavrieť" where the server's own Close button does. The sentences only
this app has -- the first-run page, the agent's states -- are in OWN below, and nowhere else.
Keep them few: a new string on the page is a new row here, in all eight languages.
The output is deterministic (languages and keys in a fixed order), so running this twice
writes the same file. ui/i18n.js is committed; this script runs when a string or a server
catalogue changes, not in CI, which has no server checkout.
"""
from __future__ import annotations
import importlib.util
import json
import os
import re
import sys
from pathlib import Path
# The server checkout is read, not written: no __pycache__ beside its catalogues.
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "ui" / "i18n.js"
LANGS = ("cs", "de", "es", "fr", "hu", "it", "pl", "sk") # and "en", the keys themselves
# Every sentence the pages use (ui/index.html, ui/app.js, ui/titlebar.html, ui/titlebar.js),
# as `t("…")` and `data-i18n` name it. `%(name)s` fields are filled in by `t`.
KEYS = (
# index.html
"Server",
"Agent",
"Project folders",
"Address of your LLeMbas server",
"Connect",
"Sign in there as you would in a browser.",
"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.",
"Link this computer",
"Start the agent",
"Open the server",
"Agent chats from your server can work in these folders and anything inside them — "
"nowhere else on this computer. Start one in the server's Agents tab, on “Local”.",
"Add folder…",
"The most an agent chat may do here",
"Manual — asks first",
"Edit — asks before commands",
"Auto — asks nothing",
"Plan — changes nothing",
"A chat asking for more is lowered to this.",
"Terminal",
"Let the server open a terminal here",
"A shell as you, in one of these folders.",
# app.js
"The agent is off.",
"This computer is not linked to the server yet.",
"The agent is starting…",
"The agent is running: agent chats can use this computer.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.",
"The agent stopped: %(why)s. Trying again shortly.",
"The agent is connected, but may open no folder yet: add one below.",
"Link again",
"Stop the agent",
"No folder yet: the agent can open nothing. Add the folder of a project.",
"Remove",
"Remove %(dir)s",
"Looking for a LLeMbas server there…",
# titlebar.html, titlebar.js
"agent off",
"not linked",
"agent starting…",
"agent running",
"agent running (service)",
"agent stopped",
"agent: no folders",
"Agent stopped: %(why)s",
"update",
"update %(version)s",
"LLeMbas %(version)s is available (this is %(current)s)",
"Menu",
"Minimise",
"Maximise",
"Restore",
"Close",
"Close (LLeMbas stays in the tray)",
)
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
# server's catalogues keep them ("Režim Edit", "Modus Edit"); "Local" is the server's word for
# the device, as the server's catalogue has it.
OWN: dict[str, dict[str, str]] = {
"cs": {
"Server": "Server",
"Project folders": "Složky projektů",
"Address of your LLeMbas server": "Adresa vašeho serveru LLeMbas",
"Connect": "Připojit",
"Sign in there as you would in a browser.": "Přihlaste se tam jako v prohlížeči.",
"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.":
"Agent umožňuje agentním konverzacím na vašem serveru pracovat v projektech na tomto počítači. Je to vestavěné LLeMbas CLI, které běží na pozadí, dokud běží aplikace.",
"Link this computer": "Propojit tento počítač",
"Start the agent": "Spustit agenta",
"Open the server": "Otevřít server",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.":
"Agentní konverzace z vašeho serveru mohou pracovat v těchto složkách a ve všem uvnitř nich — nikde jinde na tomto počítači. Spusťte ji na serveru na kartě Agenti, na zařízení „Místní“.",
"Add folder…": "Přidat složku…",
"The most an agent chat may do here": "Nejvíc, co tu agentní konverzace smí",
"Manual — asks first": "Manual — nejdřív se zeptá",
"Edit — asks before commands": "Edit — ptá se před příkazy",
"Auto — asks nothing": "Auto — na nic se neptá",
"Plan — changes nothing": "Plan — nic nemění",
"A chat asking for more is lowered to this.": "Konverzace, která chce víc, je snížena na tohle.",
"Let the server open a terminal here": "Dovolit serveru otevřít zde terminál",
"A shell as you, in one of these folders.": "Shell pod vaším účtem, v jedné z těchto složek.",
"The agent is off.": "Agent je vypnutý.",
"This computer is not linked to the server yet.": "Tento počítač ještě není propojený se serverem.",
"The agent is starting…": "Agent se spouští…",
"The agent is running: agent chats can use this computer.": "Agent běží: agentní konverzace mohou používat tento počítač.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.":
"Služba LLeMbas CLI už tu běží (pid %(pid)s); je agentem tohoto počítače.",
"The agent stopped: %(why)s. Trying again shortly.": "Agent se zastavil: %(why)s. Za chvíli to zkusí znovu.",
"The agent is connected, but may open no folder yet: add one below.":
"Agent je připojený, ale zatím nesmí otevřít žádnou složku: přidejte ji níže.",
"Link again": "Propojit znovu",
"Stop the agent": "Zastavit agenta",
"No folder yet: the agent can open nothing. Add the folder of a project.":
"Zatím žádná složka: agent nemůže nic otevřít. Přidejte složku projektu.",
"Remove %(dir)s": "Odebrat %(dir)s",
"Looking for a LLeMbas server there…": "Hledám tam server LLeMbas…",
"agent off": "agent vypnutý",
"not linked": "nepropojeno",
"agent starting…": "agent se spouští…",
"agent running": "agent běží",
"agent running (service)": "agent běží (služba)",
"agent stopped": "agent zastaven",
"agent: no folders": "agent: žádné složky",
"Agent stopped: %(why)s": "Agent se zastavil: %(why)s",
"update": "aktualizace",
"update %(version)s": "aktualizace %(version)s",
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozici LLeMbas %(version)s (tohle je %(current)s)",
"Menu": "Nabídka",
"Minimise": "Minimalizovat",
"Maximise": "Maximalizovat",
"Close (LLeMbas stays in the tray)": "Zavřít (LLeMbas zůstane v oznamovací oblasti)",
},
"de": {
"Server": "Server",
"Project folders": "Projektordner",
"Address of your LLeMbas server": "Adresse Ihres LLeMbas-Servers",
"Connect": "Verbinden",
"Sign in there as you would in a browser.": "Melden Sie sich dort wie im Browser an.",
"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.":
"Der Agent lässt Agent-Chats auf Ihrem Server in den Projekten dieses Computers arbeiten. Er ist das eingebaute LLeMbas CLI und läuft im Hintergrund, solange die App läuft.",
"Link this computer": "Diesen Computer verbinden",
"Start the agent": "Agent starten",
"Open the server": "Server öffnen",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.":
"Agent-Chats von Ihrem Server dürfen in diesen Ordnern und allem darin arbeiten — nirgends sonst auf diesem Computer. Starten Sie einen im Reiter Agenten des Servers, auf „Lokal“.",
"Add folder…": "Ordner hinzufügen…",
"The most an agent chat may do here": "Das Meiste, was ein Agent-Chat hier darf",
"Manual — asks first": "Manual — fragt zuerst",
"Edit — asks before commands": "Edit — fragt vor Befehlen",
"Auto — asks nothing": "Auto — fragt nichts",
"Plan — changes nothing": "Plan — ändert nichts",
"A chat asking for more is lowered to this.": "Ein Chat, der mehr verlangt, wird hierauf begrenzt.",
"Let the server open a terminal here": "Dem Server erlauben, hier ein Terminal zu öffnen",
"A shell as you, in one of these folders.": "Eine Shell als Sie, in einem dieser Ordner.",
"The agent is off.": "Der Agent ist aus.",
"This computer is not linked to the server yet.": "Dieser Computer ist noch nicht mit dem Server verbunden.",
"The agent is starting…": "Der Agent startet…",
"The agent is running: agent chats can use this computer.": "Der Agent läuft: Agent-Chats können diesen Computer nutzen.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.":
"Ein LLeMbas-CLI-Dienst läuft hier bereits (PID %(pid)s); er ist der Agent dieses Computers.",
"The agent stopped: %(why)s. Trying again shortly.": "Der Agent hat angehalten: %(why)s. Gleich wird es erneut versucht.",
"The agent is connected, but may open no folder yet: add one below.":
"Der Agent ist verbunden, darf aber noch keinen Ordner öffnen: Fügen Sie unten einen hinzu.",
"Link again": "Erneut verbinden",
"Stop the agent": "Agent anhalten",
"No folder yet: the agent can open nothing. Add the folder of a project.":
"Noch kein Ordner: Der Agent kann nichts öffnen. Fügen Sie den Ordner eines Projekts hinzu.",
"Remove %(dir)s": "%(dir)s entfernen",
"Looking for a LLeMbas server there…": "Suche dort nach einem LLeMbas-Server…",
"agent off": "Agent aus",
"not linked": "nicht verbunden",
"agent starting…": "Agent startet…",
"agent running": "Agent läuft",
"agent running (service)": "Agent läuft (Dienst)",
"agent stopped": "Agent angehalten",
"agent: no folders": "Agent: keine Ordner",
"Agent stopped: %(why)s": "Agent angehalten: %(why)s",
"update": "Update",
"update %(version)s": "Update %(version)s",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s ist verfügbar (dies ist %(current)s)",
"Menu": "Menü",
"Minimise": "Minimieren",
"Maximise": "Maximieren",
"Close (LLeMbas stays in the tray)": "Schließen (LLeMbas bleibt im Benachrichtigungsbereich)",
},
"es": {
"Server": "Servidor",
"Project folders": "Carpetas de proyectos",
"Address of your LLeMbas server": "Dirección de su servidor LLeMbas",
"Connect": "Conectar",
"Sign in there as you would in a browser.": "Inicie sesión allí como lo haría en un navegador.",
"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.":
"El agente permite que los chats de agente de su servidor trabajen en los proyectos de este ordenador. Es el LLeMbas CLI, integrado, que se ejecuta en segundo plano mientras la aplicación está abierta.",
"Link this computer": "Vincular este ordenador",
"Start the agent": "Iniciar el agente",
"Open the server": "Abrir el servidor",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.":
"Los chats de agente de su servidor pueden trabajar en estas carpetas y en todo lo que contienen — en ningún otro lugar de este ordenador. Inicie uno en la pestaña Agentes del servidor, en «Local».",
"Add folder…": "Añadir carpeta…",
"The most an agent chat may do here": "Lo máximo que un chat de agente puede hacer aquí",
"Manual — asks first": "Manual — pregunta primero",
"Edit — asks before commands": "Edit — pregunta antes de los comandos",
"Auto — asks nothing": "Auto — no pregunta nada",
"Plan — changes nothing": "Plan — no cambia nada",
"A chat asking for more is lowered to this.": "Un chat que pida más se limita a esto.",
"Let the server open a terminal here": "Permitir que el servidor abra un terminal aquí",
"A shell as you, in one of these folders.": "Un shell con su usuario, en una de estas carpetas.",
"The agent is off.": "El agente está apagado.",
"This computer is not linked to the server yet.": "Este ordenador aún no está vinculado al servidor.",
"The agent is starting…": "El agente se está iniciando…",
"The agent is running: agent chats can use this computer.": "El agente está en marcha: los chats de agente pueden usar este ordenador.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.":
"Ya se está ejecutando aquí un servicio de LLeMbas CLI (pid %(pid)s); es el agente de este ordenador.",
"The agent stopped: %(why)s. Trying again shortly.": "El agente se detuvo: %(why)s. Se volverá a intentar en breve.",
"The agent is connected, but may open no folder yet: add one below.":
"El agente está conectado, pero aún no puede abrir ninguna carpeta: añada una abajo.",
"Link again": "Vincular de nuevo",
"Stop the agent": "Detener el agente",
"No folder yet: the agent can open nothing. Add the folder of a project.":
"Aún no hay ninguna carpeta: el agente no puede abrir nada. Añada la carpeta de un proyecto.",
"Remove %(dir)s": "Quitar %(dir)s",
"Looking for a LLeMbas server there…": "Buscando un servidor LLeMbas allí…",
"agent off": "agente apagado",
"not linked": "no vinculado",
"agent starting…": "agente iniciando…",
"agent running": "agente en marcha",
"agent running (service)": "agente en marcha (servicio)",
"agent stopped": "agente detenido",
"agent: no folders": "agente: sin carpetas",
"Agent stopped: %(why)s": "El agente se detuvo: %(why)s",
"update": "actualizar",
"update %(version)s": "actualizar a %(version)s",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s está disponible (esta es la %(current)s)",
"Menu": "Menú",
"Minimise": "Minimizar",
"Maximise": "Maximizar",
"Close (LLeMbas stays in the tray)": "Cerrar (LLeMbas sigue en la bandeja del sistema)",
},
"fr": {
"Server": "Serveur",
"Project folders": "Dossiers de projets",
"Address of your LLeMbas server": "Adresse de votre serveur LLeMbas",
"Connect": "Se connecter",
"Sign in there as you would in a browser.": "Connectez-vous là-bas comme dans un navigateur.",
"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.":
"L'agent permet aux conversations d'agent de votre serveur de travailler dans les projets de cet ordinateur. C'est le LLeMbas CLI, intégré, qui tourne en arrière-plan tant que l'application est ouverte.",
"Link this computer": "Relier cet ordinateur",
"Start the agent": "Démarrer l'agent",
"Open the server": "Ouvrir le serveur",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.":
"Les conversations d'agent de votre serveur peuvent travailler dans ces dossiers et tout ce qu'ils contiennent — nulle part ailleurs sur cet ordinateur. Démarrez-en une dans l'onglet Agents du serveur, sur « Local ».",
"Add folder…": "Ajouter un dossier…",
"The most an agent chat may do here": "Le maximum qu'une conversation d'agent peut faire ici",
"Manual — asks first": "Manual — demande d'abord",
"Edit — asks before commands": "Edit — demande avant les commandes",
"Auto — asks nothing": "Auto — ne demande rien",
"Plan — changes nothing": "Plan — ne change rien",
"A chat asking for more is lowered to this.": "Une conversation qui en demande plus est ramenée à ceci.",
"Let the server open a terminal here": "Laisser le serveur ouvrir un terminal ici",
"A shell as you, in one of these folders.": "Un shell en votre nom, dans l'un de ces dossiers.",
"The agent is off.": "L'agent est arrêté.",
"This computer is not linked to the server yet.": "Cet ordinateur n'est pas encore relié au serveur.",
"The agent is starting…": "L'agent démarre…",
"The agent is running: agent chats can use this computer.": "L'agent tourne : les conversations d'agent peuvent utiliser cet ordinateur.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.":
"Un service LLeMbas CLI tourne déjà ici (pid %(pid)s) ; c'est l'agent de cet ordinateur.",
"The agent stopped: %(why)s. Trying again shortly.": "L'agent s'est arrêté : %(why)s. Nouvel essai sous peu.",
"The agent is connected, but may open no folder yet: add one below.":
"L'agent est connecté, mais ne peut encore ouvrir aucun dossier : ajoutez-en un ci-dessous.",
"Link again": "Relier à nouveau",
"Stop the agent": "Arrêter l'agent",
"No folder yet: the agent can open nothing. Add the folder of a project.":
"Aucun dossier pour l'instant : l'agent ne peut rien ouvrir. Ajoutez le dossier d'un projet.",
"Remove %(dir)s": "Retirer %(dir)s",
"Looking for a LLeMbas server there…": "Recherche d'un serveur LLeMbas à cette adresse…",
"agent off": "agent arrêté",
"not linked": "non relié",
"agent starting…": "agent en démarrage…",
"agent running": "agent actif",
"agent running (service)": "agent actif (service)",
"agent stopped": "agent interrompu",
"agent: no folders": "agent : aucun dossier",
"Agent stopped: %(why)s": "L'agent s'est arrêté : %(why)s",
"update": "mise à jour",
"update %(version)s": "mise à jour %(version)s",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s est disponible (ceci est la %(current)s)",
"Menu": "Menu",
"Minimise": "Réduire",
"Maximise": "Agrandir",
"Close (LLeMbas stays in the tray)": "Fermer (LLeMbas reste dans la zone de notification)",
},
"hu": {
"Server": "Kiszolgáló",
"Project folders": "Projektmappák",
"Address of your LLeMbas server": "A LLeMbas-kiszolgáló címe",
"Connect": "Csatlakozás",
"Sign in there as you would in a browser.": "Jelentkezzen be ott úgy, mint egy böngészőben.",
"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.":
"Az ügynök lehetővé teszi, hogy a kiszolgálón futó ügynök-csevegések ennek a számítógépnek a projektjeiben dolgozzanak. Ez a beépített LLeMbas CLI, amely a háttérben fut, amíg az alkalmazás fut.",
"Link this computer": "A számítógép összekapcsolása",
"Start the agent": "Ügynök indítása",
"Open the server": "Kiszolgáló megnyitása",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.":
"A kiszolgáló ügynök-csevegései ezekben a mappákban és a bennük lévőkben dolgozhatnak — a számítógépen máshol nem. Indítson egyet a kiszolgáló Ügynökök lapján, a „Helyi” eszközön.",
"Add folder…": "Mappa hozzáadása…",
"The most an agent chat may do here": "A legtöbb, amit egy ügynök-csevegés itt tehet",
"Manual — asks first": "Manual — előbb kérdez",
"Edit — asks before commands": "Edit — parancsok előtt kérdez",
"Auto — asks nothing": "Auto — semmit nem kérdez",
"Plan — changes nothing": "Plan — semmit nem változtat",
"A chat asking for more is lowered to this.": "Az ennél többet kérő csevegés erre korlátozódik.",
"Let the server open a terminal here": "A kiszolgáló nyithasson itt terminált",
"A shell as you, in one of these folders.": "Egy shell az Ön nevében, ezek egyik mappájában.",
"The agent is off.": "Az ügynök ki van kapcsolva.",
"This computer is not linked to the server yet.": "Ez a számítógép még nincs összekapcsolva a kiszolgálóval.",
"The agent is starting…": "Az ügynök indul…",
"The agent is running: agent chats can use this computer.": "Az ügynök fut: az ügynök-csevegések használhatják ezt a számítógépet.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.":
"Itt már fut egy LLeMbas CLI szolgáltatás (pid %(pid)s); ez a számítógép ügynöke.",
"The agent stopped: %(why)s. Trying again shortly.": "Az ügynök leállt: %(why)s. Hamarosan újra próbálja.",
"The agent is connected, but may open no folder yet: add one below.":
"Az ügynök csatlakozott, de még egy mappát sem nyithat meg: adjon hozzá egyet lent.",
"Link again": "Újra összekapcsolás",
"Stop the agent": "Ügynök leállítása",
"No folder yet: the agent can open nothing. Add the folder of a project.":
"Még nincs mappa: az ügynök semmit nem nyithat meg. Adja hozzá egy projekt mappáját.",
"Remove %(dir)s": "%(dir)s eltávolítása",
"Looking for a LLeMbas server there…": "LLeMbas-kiszolgáló keresése ott…",
"agent off": "ügynök kikapcsolva",
"not linked": "nincs összekapcsolva",
"agent starting…": "ügynök indul…",
"agent running": "ügynök fut",
"agent running (service)": "ügynök fut (szolgáltatás)",
"agent stopped": "ügynök leállt",
"agent: no folders": "ügynök: nincs mappa",
"Agent stopped: %(why)s": "Az ügynök leállt: %(why)s",
"update": "frissítés",
"update %(version)s": "frissítés: %(version)s",
"LLeMbas %(version)s is available (this is %(current)s)": "Elérhető a LLeMbas %(version)s (ez a %(current)s)",
"Menu": "Menü",
"Minimise": "Kis méret",
"Maximise": "Teljes méret",
"Close (LLeMbas stays in the tray)": "Bezárás (a LLeMbas a tálcán marad)",
},
"it": {
"Server": "Server",
"Project folders": "Cartelle dei progetti",
"Address of your LLeMbas server": "Indirizzo del tuo server LLeMbas",
"Connect": "Connetti",
"Sign in there as you would in a browser.": "Accedi lì come faresti in un browser.",
"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.":
"L'agente permette alle chat agente del tuo server di lavorare nei progetti di questo computer. È il LLeMbas CLI, integrato, in esecuzione in background finché l'app è aperta.",
"Link this computer": "Collega questo computer",
"Start the agent": "Avvia l'agente",
"Open the server": "Apri il server",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.":
"Le chat agente del tuo server possono lavorare in queste cartelle e in tutto ciò che contengono — in nessun altro posto di questo computer. Avviane una nella scheda Agenti del server, su “Locale”.",
"Add folder…": "Aggiungi cartella…",
"The most an agent chat may do here": "Il massimo che una chat agente può fare qui",
"Manual — asks first": "Manual — chiede prima",
"Edit — asks before commands": "Edit — chiede prima dei comandi",
"Auto — asks nothing": "Auto — non chiede nulla",
"Plan — changes nothing": "Plan — non cambia nulla",
"A chat asking for more is lowered to this.": "Una chat che chiede di più viene limitata a questo.",
"Let the server open a terminal here": "Lascia che il server apra un terminale qui",
"A shell as you, in one of these folders.": "Una shell con il tuo utente, in una di queste cartelle.",
"The agent is off.": "L'agente è spento.",
"This computer is not linked to the server yet.": "Questo computer non è ancora collegato al server.",
"The agent is starting…": "L'agente si sta avviando…",
"The agent is running: agent chats can use this computer.": "L'agente è in esecuzione: le chat agente possono usare questo computer.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.":
"Un servizio LLeMbas CLI è già in esecuzione qui (pid %(pid)s); è l'agente di questo computer.",
"The agent stopped: %(why)s. Trying again shortly.": "L'agente si è fermato: %(why)s. Tra poco riprova.",
"The agent is connected, but may open no folder yet: add one below.":
"L'agente è connesso, ma non può ancora aprire nessuna cartella: aggiungine una qui sotto.",
"Link again": "Collega di nuovo",
"Stop the agent": "Ferma l'agente",
"No folder yet: the agent can open nothing. Add the folder of a project.":
"Ancora nessuna cartella: l'agente non può aprire nulla. Aggiungi la cartella di un progetto.",
"Remove %(dir)s": "Rimuovi %(dir)s",
"Looking for a LLeMbas server there…": "Cerco un server LLeMbas lì…",
"agent off": "agente spento",
"not linked": "non collegato",
"agent starting…": "agente in avvio…",
"agent running": "agente attivo",
"agent running (service)": "agente attivo (servizio)",
"agent stopped": "agente fermato",
"agent: no folders": "agente: nessuna cartella",
"Agent stopped: %(why)s": "L'agente si è fermato: %(why)s",
"update": "aggiorna",
"update %(version)s": "aggiorna a %(version)s",
"LLeMbas %(version)s is available (this is %(current)s)": "È disponibile LLeMbas %(version)s (questa è la %(current)s)",
"Menu": "Menu",
"Minimise": "Riduci a icona",
"Maximise": "Ingrandisci",
"Close (LLeMbas stays in the tray)": "Chiudi (LLeMbas resta nell'area di notifica)",
},
"pl": {
"Server": "Serwer",
"Project folders": "Foldery projektów",
"Address of your LLeMbas server": "Adres Twojego serwera LLeMbas",
"Connect": "Połącz",
"Sign in there as you would in a browser.": "Zaloguj się tam tak jak w przeglądarce.",
"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.":
"Agent pozwala czatom agenta z Twojego serwera pracować w projektach na tym komputerze. To wbudowane LLeMbas CLI, działające w tle, dopóki działa aplikacja.",
"Link this computer": "Połącz ten komputer",
"Start the agent": "Uruchom agenta",
"Open the server": "Otwórz serwer",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.":
"Czaty agenta z Twojego serwera mogą pracować w tych folderach i we wszystkim, co zawierają — nigdzie indziej na tym komputerze. Uruchom czat na karcie Agenci na serwerze, na urządzeniu „Lokalne”.",
"Add folder…": "Dodaj folder…",
"The most an agent chat may do here": "Najwięcej, co czat agenta może tu zrobić",
"Manual — asks first": "Manual — najpierw pyta",
"Edit — asks before commands": "Edit — pyta przed poleceniami",
"Auto — asks nothing": "Auto — o nic nie pyta",
"Plan — changes nothing": "Plan — niczego nie zmienia",
"A chat asking for more is lowered to this.": "Czat, który prosi o więcej, zostaje ograniczony do tego.",
"Let the server open a terminal here": "Pozwól serwerowi otworzyć tu terminal",
"A shell as you, in one of these folders.": "Powłoka na Twoim koncie, w jednym z tych folderów.",
"The agent is off.": "Agent jest wyłączony.",
"This computer is not linked to the server yet.": "Ten komputer nie jest jeszcze połączony z serwerem.",
"The agent is starting…": "Agent się uruchamia…",
"The agent is running: agent chats can use this computer.": "Agent działa: czaty agenta mogą używać tego komputera.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.":
"Usługa LLeMbas CLI już tu działa (pid %(pid)s); jest agentem tego komputera.",
"The agent stopped: %(why)s. Trying again shortly.": "Agent się zatrzymał: %(why)s. Za chwilę spróbuje ponownie.",
"The agent is connected, but may open no folder yet: add one below.":
"Agent jest połączony, ale nie może jeszcze otworzyć żadnego folderu: dodaj go poniżej.",
"Link again": "Połącz ponownie",
"Stop the agent": "Zatrzymaj agenta",
"No folder yet: the agent can open nothing. Add the folder of a project.":
"Nie ma jeszcze folderu: agent nie może nic otworzyć. Dodaj folder projektu.",
"Remove %(dir)s": "Usuń %(dir)s",
"Looking for a LLeMbas server there…": "Szukam tam serwera LLeMbas…",
"agent off": "agent wyłączony",
"not linked": "niepołączony",
"agent starting…": "agent się uruchamia…",
"agent running": "agent działa",
"agent running (service)": "agent działa (usługa)",
"agent stopped": "agent zatrzymany",
"agent: no folders": "agent: brak folderów",
"Agent stopped: %(why)s": "Agent się zatrzymał: %(why)s",
"update": "aktualizacja",
"update %(version)s": "aktualizacja %(version)s",
"LLeMbas %(version)s is available (this is %(current)s)": "Dostępny jest LLeMbas %(version)s (to jest %(current)s)",
"Menu": "Menu",
"Minimise": "Minimalizuj",
"Maximise": "Maksymalizuj",
"Close (LLeMbas stays in the tray)": "Zamknij (LLeMbas zostaje w zasobniku systemowym)",
},
"sk": {
"Server": "Server",
"Project folders": "Priečinky projektov",
"Address of your LLeMbas server": "Adresa vášho servera LLeMbas",
"Connect": "Pripojiť",
"Sign in there as you would in a browser.": "Prihláste sa tam ako v prehliadači.",
"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.":
"Agent umožňuje agentským konverzáciám na vašom serveri pracovať v projektoch na tomto počítači. Je to vstavané LLeMbas CLI, ktoré beží na pozadí, kým beží aplikácia.",
"Link this computer": "Prepojiť tento počítač",
"Start the agent": "Spustiť agenta",
"Open the server": "Otvoriť server",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.":
"Agentské konverzácie z vášho servera môžu pracovať v týchto priečinkoch a vo všetkom, čo obsahujú — nikde inde na tomto počítači. Spustite ju na serveri na karte Agenti, na zariadení „Lokálny“.",
"Add folder…": "Pridať priečinok…",
"The most an agent chat may do here": "Najviac, čo tu agentská konverzácia smie",
"Manual — asks first": "Manual — najprv sa opýta",
"Edit — asks before commands": "Edit — pýta sa pred príkazmi",
"Auto — asks nothing": "Auto — na nič sa nepýta",
"Plan — changes nothing": "Plan — nič nemení",
"A chat asking for more is lowered to this.": "Konverzácia, ktorá chce viac, je znížená na toto.",
"Let the server open a terminal here": "Dovoliť serveru otvoriť tu terminál",
"A shell as you, in one of these folders.": "Shell pod vaším účtom, v jednom z týchto priečinkov.",
"The agent is off.": "Agent je vypnutý.",
"This computer is not linked to the server yet.": "Tento počítač ešte nie je prepojený so serverom.",
"The agent is starting…": "Agent sa spúšťa…",
"The agent is running: agent chats can use this computer.": "Agent beží: agentské konverzácie môžu používať tento počítač.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.":
"Služba LLeMbas CLI tu už beží (pid %(pid)s); je agentom tohto počítača.",
"The agent stopped: %(why)s. Trying again shortly.": "Agent sa zastavil: %(why)s. O chvíľu to skúsi znova.",
"The agent is connected, but may open no folder yet: add one below.":
"Agent je pripojený, ale zatiaľ nesmie otvoriť žiadny priečinok: pridajte ho nižšie.",
"Link again": "Prepojiť znova",
"Stop the agent": "Zastaviť agenta",
"No folder yet: the agent can open nothing. Add the folder of a project.":
"Zatiaľ žiadny priečinok: agent nemôže nič otvoriť. Pridajte priečinok projektu.",
"Remove %(dir)s": "Odstrániť %(dir)s",
"Looking for a LLeMbas server there…": "Hľadám tam server LLeMbas…",
"agent off": "agent vypnutý",
"not linked": "neprepojené",
"agent starting…": "agent sa spúšťa…",
"agent running": "agent beží",
"agent running (service)": "agent beží (služba)",
"agent stopped": "agent zastavený",
"agent: no folders": "agent: žiadne priečinky",
"Agent stopped: %(why)s": "Agent sa zastavil: %(why)s",
"update": "aktualizácia",
"update %(version)s": "aktualizácia %(version)s",
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozícii LLeMbas %(version)s (toto je %(current)s)",
"Menu": "Ponuka",
"Minimise": "Minimalizovať",
"Maximise": "Maximalizovať",
"Close (LLeMbas stays in the tray)": "Zavrieť (LLeMbas zostane v oznamovacej oblasti)",
},
}
FIELD = re.compile(r"%\((\w+)\)s")
def server_catalogue(server: Path, lang: str) -> dict[str, str]:
"""The server's MESSAGES for `lang`: the module loaded from its file, nothing imported."""
path = server / "src" / "lembas" / "web" / "i18n" / f"{lang}.py"
if not path.is_file():
sys.exit(f"gen-i18n: {path} is missing: is {server} the LLeMbas server checkout?")
spec = importlib.util.spec_from_file_location(f"lembas_i18n_{lang}", path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
messages = getattr(module, "MESSAGES", None)
if not isinstance(messages, dict):
sys.exit(f"gen-i18n: {path} has no MESSAGES")
return messages
def main() -> None:
server = Path(sys.argv[1] if len(sys.argv) > 1 else os.environ.get("LEMBAS_SERVER", ROOT.parent / "LLeMbas"))
catalogue: dict[str, dict[str, str]] = {}
problems = 0
for lang in LANGS:
theirs = server_catalogue(server, lang)
ours = OWN.get(lang, {})
table: dict[str, str] = {}
from_server = 0
for key in KEYS:
text = theirs.get(key)
if text:
from_server += 1
else:
text = ours.get(key)
if not text:
print(f"gen-i18n: {lang}: no translation of {key!r} (English will show)", file=sys.stderr)
problems += 1
continue
if set(FIELD.findall(text)) != set(FIELD.findall(key)):
print(f"gen-i18n: {lang}: the fields of {text!r} do not match {key!r}", file=sys.stderr)
problems += 1
continue
table[key] = text
for key in ours:
if key not in KEYS:
print(f"gen-i18n: {lang}: OWN has {key!r}, which no page uses", file=sys.stderr)
problems += 1
catalogue[lang] = table
print(f"gen-i18n: {lang}: {len(table)} of {len(KEYS)} ({from_server} from the server)")
body = json.dumps(catalogue, ensure_ascii=False, indent=2, sort_keys=True)
out = (
"// The app's own pages' words in the server's languages. GENERATED by scripts/gen-i18n.py:\n"
"// the sentences the server also has come from its web/i18n catalogues, the rest from the\n"
"// script's own table. Do not edit; run the script.\n"
"//\n"
"// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a\n"
"// missing entry is the English. The language is the system's (navigator.language),\n"
"// English unless it is one of these. Elements marked `data-i18n` (their text) and\n"
"// `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute)\n"
"// are translated once the page is parsed; this script is deferred, before the page's.\n"
'"use strict"\n'
"\n"
f"const LEMBAS_I18N = {body}\n"
"\n"
"const LEMBAS_LANG = (() => {\n"
' const code = String(navigator.language || "").slice(0, 2).toLowerCase()\n'
' return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"\n'
"})()\n"
"document.documentElement.lang = LEMBAS_LANG\n"
"\n"
"function t(text, fields) {\n"
' const key = String(text).replace(/\\s+/g, " ").trim()\n'
" const table = LEMBAS_I18N[LEMBAS_LANG] || {}\n"
" const out = Object.prototype.hasOwnProperty.call(table, key) ? table[key] : key\n"
" if (!fields) return out\n"
" return out.replace(/%\\((\\w+)\\)s/g, (all, name) =>\n"
" Object.prototype.hasOwnProperty.call(fields, name) ? String(fields[name]) : all)\n"
"}\n"
"\n"
"function translatePage() {\n"
' for (const el of document.querySelectorAll("[data-i18n]")) el.textContent = t(el.textContent)\n'
' for (const attr of ["title", "aria-label", "placeholder"]) {\n'
" for (const el of document.querySelectorAll(`[data-i18n-${attr}]`)) {\n"
" el.setAttribute(attr, t(el.getAttribute(attr)))\n"
" }\n"
" }\n"
"}\n"
'if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", translatePage)\n'
"else translatePage()\n"
)
OUT.write_text(out, encoding="utf-8")
print(f"gen-i18n: wrote {OUT.relative_to(ROOT)} ({len(LANGS)} languages, {len(KEYS)} sentences)")
if problems:
sys.exit(1)
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Offer a release to installed copies of LLeMbas Desktop: sign its packages with the updater key
# and write the update manifest the app reads (https://llembas.eu/desktop/latest.json).
#
# MINISIGN_KEY=path/to/updater.key scripts/publish-update.sh v0.3.0 path/to/LLeMbas-Website
#
# The packages are the ones release.yml published on GitHub for the tag, checked against that
# release's SHA256SUMS first. The app verifies each package's signature with the public key in
# src-tauri/tauri.conf.json before installing it, so the manifest's host need not be trusted.
# This writes site/desktop/latest.json in the website's checkout; committing and pushing it is
# what publishes the update.
set -euo pipefail
TAG="${1:?usage: publish-update.sh vX.Y.Z path/to/LLeMbas-Website}"
SITE="${2:?usage: publish-update.sh vX.Y.Z path/to/LLeMbas-Website}"
KEY="${MINISIGN_KEY:?set MINISIGN_KEY to the minisign secret key of the updater}"
VERSION="${TAG#v}"
BASE="https://github.com/LLeMbas/LLeMbas-Desktop/releases/download/$TAG"
DEB="llembas-desktop_${VERSION}_amd64.deb"
APPIMAGE="llembas-desktop_${VERSION}_amd64.AppImage"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
for f in SHA256SUMS "$DEB" "$APPIMAGE"; do
curl -fsSL --retry 3 -o "$work/$f" "$BASE/$f"
done
[ "$(sed -n -E 's/^# llembas-desktop //p' "$work/SHA256SUMS" | head -n1)" = "$VERSION" ] \
|| { echo "publish-update: SHA256SUMS is not for $VERSION" >&2; exit 1; }
(cd "$work" && grep -E " ($DEB|$APPIMAGE)\$" SHA256SUMS | sha256sum -c --strict --quiet -) \
|| { echo "publish-update: the packages do not match SHA256SUMS" >&2; exit 1; }
sig() {
# Tab-separated, with the version: the app refuses a package whose signature names another one.
minisign -S -s "$KEY" -m "$work/$1" -x "$work/$1.minisig" \
-t "$(printf 'timestamp:%s\tfile:%s\tversion:%s' "$(date +%s)" "$1" "$VERSION")" >/dev/null
base64 -w0 "$work/$1.minisig"
}
notes="$(git tag -l --format='%(contents)' "$TAG" | sed '/-----BEGIN SSH SIGNATURE-----/,$d' | tail -n +3)"
mkdir -p "$SITE/site/desktop"
jq -n --arg v "$VERSION" --arg notes "$notes" --arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg deb_url "$BASE/$DEB" --arg deb_sig "$(sig "$DEB")" \
--arg app_url "$BASE/$APPIMAGE" --arg app_sig "$(sig "$APPIMAGE")" \
'{version: $v, notes: $notes, pub_date: $date, platforms: {
"linux-x86_64-deb": {url: $deb_url, signature: $deb_sig},
"linux-x86_64-appimage": {url: $app_url, signature: $app_sig}}}' \
> "$SITE/site/desktop/latest.json"
echo "publish-update: wrote $SITE/site/desktop/latest.json for $VERSION — commit and push it to publish"
+317 -9
View File
@@ -47,6 +47,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -708,6 +717,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.8",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -976,6 +996,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
@@ -1520,6 +1550,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.21"
@@ -1539,9 +1584,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1844,6 +1891,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.119",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -1987,8 +2064,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "llembas-desktop"
version = "0.1.0"
version = "0.7.2"
dependencies = [
"gtk",
"libc",
"serde",
"serde_json",
@@ -1997,6 +2075,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"ureq",
]
@@ -2047,6 +2126,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2312,6 +2397,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2392,6 +2489,12 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-probe"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2408,6 +2511,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2813,15 +2930,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -2920,11 +3042,23 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
dependencies = [
"openssl-probe",
"openssl-probe 0.1.6",
"rustls-pemfile",
"rustls-pki-types",
"schannel",
"security-framework",
"security-framework 2.11.1",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe 0.2.1",
"rustls-pki-types",
"schannel",
"security-framework 3.7.0",
]
[[package]]
@@ -2945,6 +3079,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs 0.8.5",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
@@ -3050,6 +3211,19 @@ dependencies = [
"security-framework-sys",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
@@ -3281,6 +3455,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.4"
@@ -3462,6 +3652,27 @@ dependencies = [
"syn 3.0.8",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -3493,7 +3704,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3526,6 +3737,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -3549,7 +3771,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3741,6 +3963,40 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"libc",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.21",
"time",
"tokio",
"url",
"windows-sys 0.61.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.12.1"
@@ -3751,7 +4007,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3774,7 +4030,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -3963,6 +4219,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.20"
@@ -4227,7 +4493,7 @@ dependencies = [
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-native-certs 0.7.3",
"rustls-pki-types",
"serde",
"serde_json",
@@ -4489,6 +4755,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -4671,6 +4946,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -4987,7 +5273,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -5034,6 +5320,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -5187,6 +5483,18 @@ dependencies = [
"syn 3.0.8",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.2",
"memchr",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
+7 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "llembas-desktop"
version = "0.1.0"
version = "0.7.2"
description = "LLeMbas Desktop: your LLeMbas server in a window, with the agent on this computer"
authors = ["Jaroslav Beneš"]
license = "MIT"
@@ -17,10 +17,11 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri = { version = "2.12", features = ["tray-icon", "unstable"] }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ureq = { version = "2", default-features = false, features = ["tls", "native-certs", "json"] }
@@ -28,6 +29,10 @@ ureq = { version = "2", default-features = false, features = ["tls", "native-cer
[target.'cfg(unix)'.dependencies]
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]
codegen-units = 1
lto = true
+69
View File
@@ -1,3 +1,72 @@
//! Besides Tauri's own build step: the two themes' colours the window itself paints, 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.
use std::env;
use std::fs;
use std::path::Path;
/// The themes the app knows, by the name `tokens.css` selects them with (`data-theme`), and the
/// Rust names the constants get.
const THEMES: [(&str, &str); 2] = [("moria", "MORIA"), ("shire", "SHIRE")];
/// The tokens taken from each theme, by the custom property and the constant's suffix.
const TOKENS: [(&str, &str); 2] = [("--bg", "BG"), ("--border", "BORDER")];
fn main() {
let tokens = Path::new("../ui/tokens.css");
println!("cargo:rerun-if-changed={}", tokens.display());
let css = fs::read_to_string(tokens)
.unwrap_or_else(|e| panic!("{}: {e}; run scripts/fetch-tokens.sh", tokens.display()));
let mut out = String::from(
"// Generated by build.rs from ui/tokens.css: the colours the window paints itself.\n",
);
for (theme, name) in THEMES {
let block = theme_block(&css, theme).unwrap_or_else(|| {
panic!("ui/tokens.css has no `:root[data-theme=\"{theme}\"]` block")
});
for (token, suffix) in TOKENS {
let (r, g, b) = hex_token(block, token).unwrap_or_else(|| {
panic!(
"ui/tokens.css: `{token}` of the {theme} theme is not a six-digit hex colour"
)
});
out.push_str(&format!(
"pub const {name}_{suffix}: (u8, u8, u8) = (0x{r:02X}, 0x{g:02X}, 0x{b:02X});\n"
));
}
}
let dest = Path::new(&env::var("OUT_DIR").expect("OUT_DIR")).join("tokens.rs");
fs::write(&dest, out).expect("write tokens.rs");
tauri_build::build()
}
/// The declarations of the theme's block: from the selector naming it (at the start of a line,
/// so a mention in a comment does not count) to the block's closing brace at the start of a
/// line, which is the file's own layout, one block per theme.
fn theme_block<'a>(css: &'a str, theme: &str) -> Option<&'a str> {
let selector = format!("\n:root[data-theme=\"{theme}\"]");
let at = css.find(&selector)?;
let open = at + css[at..].find('{')?;
let close = open + css[open..].find("\n}")?;
Some(&css[open + 1..close])
}
/// `--token: #RRGGBB;` in the block, as bytes.
fn hex_token(block: &str, token: &str) -> Option<(u8, u8, u8)> {
let value = block
.lines()
.map(str::trim)
.find_map(|l| {
l.strip_prefix(token)
.and_then(|r| r.trim_start().strip_prefix(':'))
})?
.trim()
.trim_end_matches(';')
.trim();
let hex = value.strip_prefix('#')?;
if hex.len() != 6 {
return None;
}
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
Some((byte(0)?, byte(2)?, byte(4)?))
}
+10 -2
View File
@@ -1,7 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "The app's own pages (the first-run page). The server's web UI is a remote page and gets no IPC at all: this capability lists no remote URLs.",
"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).",
"windows": ["main"],
"permissions": ["core:default"]
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-internal-toggle-maximize",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 515 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

+12
View File
@@ -0,0 +1,12 @@
[Desktop Entry]
Categories={{categories}}
{{#if comment}}
Comment={{comment}}
{{/if}}
Exec={{exec}}
StartupWMClass={{exec}}
Icon={{icon}}
Name=LLeMbas
GenericName=LLM workspace
Terminal=false
Type=Application
+44 -9
View File
@@ -6,22 +6,38 @@
//! installed by hand on the same computer are one device to the server, not two.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// The bundled binary: Tauri puts an `externalBin` beside the app's own executable.
/// `LLEMBAS_DESKTOP_CLI` points elsewhere, for development.
static BUNDLED: OnceLock<PathBuf> = OnceLock::new();
/// Where the bundle put the CLI: the app's resource directory (`/usr/lib/llembas-desktop/` from
/// the .deb, inside the AppImage otherwise) — the app's own copy, never on PATH, so it neither
/// shadows nor is shadowed by a `lembas` installed by hand. Set once at start.
pub fn set_bundled(resource_dir: &Path) {
let _ = BUNDLED.set(resource_dir.join(exe_name()));
}
fn exe_name() -> &'static str {
if cfg!(windows) {
"lembas.exe"
} else {
"lembas"
}
}
/// The CLI to run: `LLEMBAS_DESKTOP_CLI` (development), else the bundled one, else one beside
/// the app's executable (`cargo tauri dev`).
pub fn binary() -> PathBuf {
if let Some(p) = std::env::var_os("LLEMBAS_DESKTOP_CLI") {
return PathBuf::from(p);
}
let name = if cfg!(windows) {
"lembas.exe"
} else {
"lembas"
};
if let Some(p) = BUNDLED.get().filter(|p| p.exists()) {
return p.clone();
}
std::env::current_exe()
.ok()
.and_then(|e| e.parent().map(|d| d.join(name)))
.unwrap_or_else(|| PathBuf::from(name))
.and_then(|e| e.parent().map(|d| d.join(exe_name())))
.unwrap_or_else(|| PathBuf::from(exe_name()))
}
fn home() -> PathBuf {
@@ -92,6 +108,25 @@ fn running_in(state: &Path) -> Option<u32> {
(pid != 0 && alive(pid)).then_some(pid)
}
/// 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.
#[cfg(unix)]
pub fn hostname() -> Option<String> {
let mut buf = [0u8; 256];
let r = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
if r != 0 {
return None;
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let name = String::from_utf8_lossy(&buf[..end]).trim().to_string();
(!name.is_empty()).then_some(name)
}
#[cfg(not(unix))]
pub fn hostname() -> Option<String> {
std::env::var("COMPUTERNAME").ok().filter(|s| !s.is_empty())
}
#[cfg(unix)]
pub fn alive(pid: u32) -> bool {
// Signal 0 checks without sending; EPERM means it exists but is someone else's.
+635 -27
View File
@@ -1,15 +1,25 @@
//! LLeMbas Desktop: the server's web UI in a window of its own, and the LLeMbas CLI's agent
//! running on this computer, so agent chats can work in its projects without a terminal.
//!
//! - **One window, `main`.** Before a server is chosen it shows the app's own first-run page
//! (`ui/`); after, the server itself. Closing it hides it; the tray brings it back.
//! - **One window, `main`, drawn by the app**: no system decoration, the app's own title bar (a
//! local page, `ui/titlebar.html`) on top and the content below it — before a server is chosen
//! the first-run page (`ui/index.html`), after it the server itself. Two webviews in one window,
//! so the server's pages never share a document with the app's controls. Closing hides the
//! window; the tray, the title bar's menu or starting the app again bring it back.
//! - **The server's pages get no IPC.** They are remote content, and the one capability lists no
//! remote URL. Links that leave the server open in the system browser.
//! - **The window follows the server's theme** (Moria or Shire, the account's choice, not the
//! desktop's): a script in the content view reports the page's `data-theme` by trying to
//! navigate to `llembas-theme:<name>`, which the navigation handler notes and refuses (see
//! `THEME_SCRIPT`). The frame, the title bar and the first-run page are painted in it, and it
//! is remembered for the next start. No IPC is granted for this: a navigation attempt is all
//! the page can make, and the only thing it can say is one of two theme names.
//! - **The agent is the CLI's**: `lembas login --json` links this computer, and
//! `lembas service run` is the device (see `agent.rs`).
mod agent;
mod cli;
mod remote;
mod settings;
use std::io::{BufRead, BufReader};
@@ -20,16 +30,81 @@ use std::sync::Mutex;
use serde::Serialize;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::webview::{PermissionKind, PermissionResponse, WebviewBuilder};
use tauri::window::{Color, WindowBuilder};
use tauri::{
AppHandle, Emitter, Manager, RunEvent, Url, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Url, Webview, WebviewUrl,
Window, WindowEvent, Wry,
};
use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_updater::UpdaterExt;
use agent::{Agent, State as AgentState};
use remote::Remote;
use settings::Settings;
/// The colours the window paints itself, read out of `ui/tokens.css` by `build.rs`.
mod tokens {
include!(concat!(env!("OUT_DIR"), "/tokens.rs"));
}
const MAIN: &str = "main";
const TITLEBAR: &str = "titlebar";
const CONTENT: &str = "content";
/// The title bar's height, in logical pixels: `--titlebar-h` in `ui/titlebar.css`.
const TITLE_H: f64 = 36.0;
/// The frame left around the webviews while the window is not maximized: the window's own
/// surface, where the pointer resizes it (a webview over the edge would take the events).
const FRAME: f64 = 4.0;
/// The scheme the content page's theme report is a navigation to: `llembas-theme:shire`.
const THEME_SCHEME: &str = "llembas-theme";
/// Run in the content view on every page: reports the page's theme (see the module's notes).
const THEME_SCRIPT: &str = include_str!("theme_report.js");
/// The two looks the server's tokens define, named as `tokens.css` selects them (`data-theme`).
/// A custom theme on the server reports its base, which is one of these.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Theme {
Moria,
Shire,
}
impl Theme {
fn parse(name: &str) -> Option<Theme> {
match name {
"moria" => Some(Theme::Moria),
"shire" => Some(Theme::Shire),
_ => None,
}
}
fn name(self) -> &'static str {
match self {
Theme::Moria => "moria",
Theme::Shire => "shire",
}
}
/// The frame's colour: the theme's `--border`, which the pages draw their edges in.
fn frame(self) -> Color {
let (r, g, b) = match self {
Theme::Moria => tokens::MORIA_BORDER,
Theme::Shire => tokens::SHIRE_BORDER,
};
Color(r, g, b, 0xFF)
}
/// A webview's own colour, `--bg`: what shows where a page has not painted yet, so a
/// server page loading in the dark theme is not a white flash first.
fn background(self) -> Color {
let (r, g, b) = match self {
Theme::Moria => tokens::MORIA_BG,
Theme::Shire => tokens::SHIRE_BG,
};
Color(r, g, b, 0xFF)
}
}
struct App {
config_dir: PathBuf,
@@ -37,6 +112,12 @@ struct App {
agent: Agent,
tray: Mutex<Option<TrayItems>>,
linking: Mutex<bool>,
/// The newer release the last check found, if any.
update: Mutex<Option<String>>,
/// The agent's folders, as `lembas remote` last answered.
remote: Mutex<Option<Remote>>,
/// What the window is painted in: the server page's theme, as last reported.
theme: Mutex<Theme>,
}
struct TrayItems {
@@ -56,6 +137,10 @@ impl App {
.map_err(|e| format!("could not save the settings: {e}"))?;
Ok(s.clone())
}
fn theme(&self) -> Theme {
*self.theme.lock().unwrap()
}
}
/// What the first-run page shows.
@@ -66,6 +151,12 @@ struct Status {
linked: bool,
agent: AgentState,
agent_wanted: bool,
/// A newer release of the app, when the last check found one.
update: Option<String>,
/// Which folders the agent may open sessions in (None: not known yet).
remote: Option<Remote>,
/// The theme the app's own pages are painted in: `moria` or `shire`, the server page's.
theme: &'static str,
}
fn status(app: &App) -> Status {
@@ -76,6 +167,9 @@ fn status(app: &App) -> Status {
server: s.server,
agent: app.agent.state(),
agent_wanted: app.agent.wanted(),
update: app.update.lock().unwrap().clone(),
remote: app.remote.lock().unwrap().clone(),
theme: app.theme().name(),
}
}
@@ -140,12 +234,134 @@ fn set_agent_wanted(handle: &AppHandle, on: bool) -> Result<(), String> {
let app = handle.state::<App>();
let s = app.update(|s| s.agent = on)?;
match (on, s.server) {
(true, Some(server)) => app.agent.start(server),
(true, Some(server)) => {
app.agent.start(server);
let h = handle.clone();
std::thread::spawn(move || {
// Connected but allowed into no folder is the state a new computer starts in:
// say so where the folders are chosen, rather than showing a green light.
if let Some(r) = refresh_remote(&h) {
if !r.usable() {
let _ = h.run_on_main_thread({
let h = h.clone();
move || {
let _ = open_main(&h, None);
}
});
}
}
});
}
_ => app.agent.stop(),
}
Ok(())
}
/// Ask the CLI which folders the agent may use; remember and announce it.
fn refresh_remote(handle: &AppHandle) -> Option<Remote> {
let r = remote::run(&["list"]).ok();
*handle.state::<App>().remote.lock().unwrap() = r.clone();
let _ = handle.emit("status", status_of(handle));
r
}
/// After a change: remember the new state, and start the app's own agent again when the CLI
/// says the running service cannot take the change live.
fn remote_changed(handle: &AppHandle, r: Remote) {
let app = handle.state::<App>();
*app.remote.lock().unwrap() = Some(r.clone());
if r.restart_needed && app.agent.wanted() {
if let Some(server) = app.settings().server {
app.agent.start(server);
}
}
let _ = handle.emit("status", status_of(handle));
}
/// Run a `lembas remote` change off the main thread; the page hears the outcome as `status`,
/// or as `remote-error`.
fn change_remote(handle: &AppHandle, args: Vec<String>) {
let h = handle.clone();
std::thread::spawn(move || {
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
match remote::run(&refs) {
Ok(r) => remote_changed(&h, r),
Err(e) => {
let _ = h.emit("remote-error", e);
}
}
});
}
#[tauri::command]
fn remote_refresh(handle: AppHandle) {
std::thread::spawn(move || {
refresh_remote(&handle);
});
}
/// Choose a folder with the system's dialog and let the agent work in it (this turns remote
/// control on, if it was off).
#[tauri::command]
fn remote_add(handle: AppHandle) {
let h = handle.clone();
handle
.dialog()
.file()
.set_title("A folder the agent may work in")
.pick_folder(move |picked| {
if let Some(path) = picked.and_then(|p| p.into_path().ok()) {
change_remote(&h, vec!["add".into(), path.to_string_lossy().into_owned()]);
}
});
}
#[tauri::command]
fn remote_remove(handle: AppHandle, dir: String) {
change_remote(&handle, vec!["remove".into(), dir]);
}
#[tauri::command]
fn remote_mode(handle: AppHandle, mode: String) -> Result<(), String> {
if !["manual", "edit", "auto", "plan"].contains(&mode.as_str()) {
return Err(format!("no such mode: {mode}"));
}
change_remote(&handle, vec!["mode".into(), mode]);
Ok(())
}
#[tauri::command]
fn remote_terminal(handle: AppHandle, on: bool) {
change_remote(
&handle,
vec!["terminal".into(), if on { "on" } else { "off" }.into()],
);
}
/// The server's pages are told they are in LLeMbas Desktop, and on which computer: it shows
/// the device that is this computer as "Local". A label only — it grants nothing.
fn user_agent() -> String {
let platform = if cfg!(target_os = "linux") {
"X11; Linux x86_64"
} else if cfg!(target_os = "macos") {
"Macintosh; Intel Mac OS X 10_15_7"
} else {
"Windows NT 10.0; Win64; x64"
};
let host = cli::hostname()
.filter(|h| {
h.len() <= 253
&& h.chars()
.all(|c| c.is_ascii_alphanumeric() || ".-_".contains(c))
})
.map(|h| format!(" (host={h})"))
.unwrap_or_default();
format!(
"Mozilla/5.0 ({platform}) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15 LLeMbasDesktop/{}{host}",
env!("CARGO_PKG_VERSION")
)
}
/// The app's own first-run page, as the webview addresses it on this platform.
fn local_page() -> Url {
let base = if cfg!(windows) {
@@ -156,37 +372,163 @@ fn local_page() -> Url {
Url::parse(base).expect("a fixed URL")
}
fn content(handle: &AppHandle) -> Option<Webview> {
handle.get_webview(CONTENT)
}
fn main_window(handle: &AppHandle) -> Option<Window> {
handle.get_window(MAIN)
}
/// Show the main window on `server`, or on the first-run page; made when it does not exist.
fn open_main(handle: &AppHandle, server: Option<&str>) -> Result<(), String> {
let target = match server {
Some(s) => Some(Url::parse(s).map_err(|e| format!("{s}: {e}"))?),
None => None,
};
if let Some(w) = handle.get_webview_window(MAIN) {
w.navigate(target.unwrap_or_else(local_page))
if let (Some(w), Some(c)) = (main_window(handle), content(handle)) {
c.navigate(target.unwrap_or_else(local_page))
.map_err(|e| e.to_string())?;
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
return Ok(());
}
build_main(handle, target).map_err(|e| e.to_string())
}
fn build_main(handle: &AppHandle, target: Option<Url>) -> tauri::Result<()> {
let theme = handle.state::<App>().theme();
let window = WindowBuilder::new(handle, MAIN)
.title("LLeMbas")
.inner_size(1280.0, 840.0)
.min_inner_size(420.0, 560.0)
.decorations(false)
// The frame's colour: the theme's border, as the pages draw theirs.
.background_color(theme.frame())
.build()?;
let size = window
.inner_size()?
.to_logical::<f64>(window.scale_factor()?);
window.add_child(
WebviewBuilder::new(TITLEBAR, WebviewUrl::App("titlebar.html".into()))
.background_color(theme.background()),
LogicalPosition::new(FRAME, FRAME),
LogicalSize::new(size.width - 2.0 * FRAME, TITLE_H),
)?;
let url = match target {
Some(u) => WebviewUrl::External(u),
None => WebviewUrl::App("index.html".into()),
};
#[cfg(target_os = "linux")]
if let Some(bar) = handle.get_webview(TITLEBAR) {
pin_titlebar(&bar);
}
let nav = handle.clone();
WebviewWindowBuilder::new(handle, MAIN, url)
.title("LLeMbas")
.inner_size(1280.0, 840.0)
.min_inner_size(420.0, 560.0)
let perm = handle.clone();
window.add_child(
WebviewBuilder::new(CONTENT, url)
.user_agent(&user_agent())
.background_color(theme.background())
.initialization_script(THEME_SCRIPT)
.on_navigation(move |url| allowed(&nav, url))
.build()
.map_err(|e| e.to_string())?;
.on_permission_request(move |view, kind| permission(&perm, &view, kind)),
LogicalPosition::new(FRAME, FRAME + TITLE_H),
LogicalSize::new(
size.width - 2.0 * FRAME,
(size.height - TITLE_H - 2.0 * FRAME).max(0.0),
),
)?;
layout(&window);
Ok(())
}
/// The server's dictation asks for the microphone, and a webview answers a permission request
/// only if its app does: WebKitGTK refuses anything it is not answered on, and has no prompt of
/// its own -- so the microphone simply said "Permission may be blocked" with no way to allow it.
/// Granted for the microphone, in the content view, once a server is chosen, and for nothing
/// else (a camera or a screen gets the platform's default, which on Linux is no). The content view
/// never shows another origin: `allowed` sends every other site to the system browser.
fn permission(handle: &AppHandle, view: &Webview, kind: PermissionKind) -> PermissionResponse {
if kind != PermissionKind::Microphone || view.label() != CONTENT {
return PermissionResponse::Default;
}
if handle.state::<App>().settings().server.is_some() {
PermissionResponse::Allow
} else {
PermissionResponse::Deny
}
}
/// On Linux, Tauri packs every webview of a window into the window's GTK box, each with an equal
/// share of the height, and positions and sizes set on a webview are not applied there. So the
/// title bar is told directly: a fixed height, no share of what is left (the content takes it).
#[cfg(target_os = "linux")]
fn pin_titlebar(bar: &Webview) {
let _ = bar.with_webview(|pv| {
use gtk::prelude::*;
let view = pv.inner();
view.set_size_request(-1, TITLE_H as i32);
if let Some(parent) = view.parent().and_then(|p| p.downcast::<gtk::Box>().ok()) {
parent.set_child_packing(&view, false, false, 0, gtk::PackType::Start);
}
});
}
/// Lay the two webviews out in the window: the title bar on top, the content under it, and the
/// frame around both unless the window is maximized (then there is nothing to resize).
fn layout(window: &Window) {
let framed =
!(window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false));
#[cfg(target_os = "linux")]
{
use gtk::prelude::*;
// The box's border is the frame: the window's own surface, which GTK resizes by.
if let Ok(vbox) = window.default_vbox() {
vbox.set_border_width(if framed { FRAME as u32 } else { 0 });
}
}
#[cfg(not(target_os = "linux"))]
place(window, framed);
}
#[cfg(not(target_os = "linux"))]
fn place(window: &Window, framed: bool) {
use tauri::{PhysicalPosition, PhysicalSize};
let handle = window.app_handle();
let (Some(bar), Some(body)) = (handle.get_webview(TITLEBAR), handle.get_webview(CONTENT))
else {
return;
};
let (Ok(size), Ok(scale)) = (window.inner_size(), window.scale_factor()) else {
return;
};
let frame = if window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false)
{
0
} else {
(FRAME * scale).round() as u32
};
let title = (TITLE_H * scale).round() as u32;
let width = size.width.saturating_sub(2 * frame);
let _ = bar.set_position(PhysicalPosition::new(frame, frame));
let _ = bar.set_size(PhysicalSize::new(width, title));
let _ = body.set_position(PhysicalPosition::new(frame, frame + title));
let _ = body.set_size(PhysicalSize::new(
width,
size.height.saturating_sub(title + 2 * frame),
));
}
/// The window may show the app's own pages and the server; anything else opens in the browser.
/// A `llembas-theme:` "navigation" is the content page reporting its theme: noted, never loaded.
fn allowed(handle: &AppHandle, url: &Url) -> bool {
if url.scheme() == THEME_SCHEME {
if let Some(theme) = Theme::parse(url.path()) {
set_theme(handle, theme);
}
return false;
}
let local = matches!(url.scheme(), "tauri" | "asset")
|| url.host_str() == Some("tauri.localhost")
|| url.scheme() == "about"
@@ -205,6 +547,29 @@ fn allowed(handle: &AppHandle, url: &Url) -> bool {
same
}
/// The server page shows `theme`: paint the frame and the webviews in it, remember it for the
/// next start, and tell the app's own pages (they hear it as `status`).
fn set_theme(handle: &AppHandle, theme: Theme) {
let app = handle.state::<App>();
{
let mut current = app.theme.lock().unwrap();
if *current == theme {
return;
}
*current = theme;
}
let _ = app.update(|s| s.theme = Some(theme.name().into()));
if let Some(w) = main_window(handle) {
let _ = w.set_background_color(Some(theme.frame()));
}
for label in [TITLEBAR, CONTENT] {
if let Some(v) = handle.get_webview(label) {
let _ = v.set_background_color(Some(theme.background()));
}
}
let _ = handle.emit("status", status_of(handle));
}
/// Link this computer: `lembas login <server> --json`, with the approval shown in the window
/// (where the user is already signed in to the server), then the agent started.
fn start_link(handle: &AppHandle) -> Result<(), String> {
@@ -250,8 +615,10 @@ fn start_link(handle: &AppHandle) -> Result<(), String> {
.and_then(|v| v.as_str())
.unwrap_or("");
if let Ok(url) = Url::parse(link) {
if let Some(w) = handle.get_webview_window(MAIN) {
let _ = w.navigate(url);
if let Some(c) = content(&handle) {
let _ = c.navigate(url);
}
if let Some(w) = main_window(&handle) {
let _ = w.show();
let _ = w.set_focus();
}
@@ -304,7 +671,7 @@ fn status_of(handle: &AppHandle) -> Status {
fn show(handle: &AppHandle) {
let server = handle.state::<App>().settings().server;
if let Some(w) = handle.get_webview_window(MAIN) {
if let Some(w) = main_window(handle) {
let _ = w.show();
let _ = w.unminimize();
let _ = w.set_focus();
@@ -324,7 +691,7 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
)?;
let toggle = MenuItem::with_id(handle, "toggle", "Start the agent", true, None::<&str>)?;
let link = MenuItem::with_id(handle, "link", "Link this computer…", true, None::<&str>)?;
let server = MenuItem::with_id(handle, "server", "Change server…", true, None::<&str>)?;
let server = MenuItem::with_id(handle, "server", "Settings…", true, None::<&str>)?;
let quit = MenuItem::with_id(handle, "quit", "Quit LLeMbas", true, None::<&str>)?;
let menu = Menu::with_items(
handle,
@@ -341,8 +708,121 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
)?;
let mut tray = TrayIconBuilder::with_id("tray")
.tooltip("LLeMbas")
.menu(&menu)
.on_menu_event(|h, ev| match ev.id().as_ref() {
.menu(&menu);
if let Some(icon) = handle.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.build(handle)?;
Ok(TrayItems { status, toggle })
}
/// How often a running app looks for a new release (and once shortly after it starts).
const UPDATE_EVERY: std::time::Duration = std::time::Duration::from_secs(6 * 3600);
/// Ask the update manifest whether a newer release exists; remember and announce the answer.
/// The manifest and each package are signed with the updater key, which the app checks before
/// installing anything.
async fn check_update(handle: &AppHandle) -> Result<Option<String>, String> {
let found = handle
.updater()
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?
.map(|u| u.version);
*handle.state::<App>().update.lock().unwrap() = found.clone();
let _ = handle.emit("status", status_of(handle));
Ok(found)
}
fn watch_updates(handle: AppHandle) {
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(20));
loop {
let _ = tauri::async_runtime::block_on(check_update(&handle));
std::thread::sleep(UPDATE_EVERY);
}
});
}
/// Download and install the newer release, then start again on it. A .deb is installed with
/// the system's password prompt (pkexec); an AppImage replaces itself.
async fn install_update(handle: AppHandle) -> Result<(), String> {
let update = handle
.updater()
.map_err(|e| e.to_string())?
.check()
.await
.map_err(|e| e.to_string())?
.ok_or("LLeMbas is up to date.")?;
update
.download_and_install(|_, _| {}, || {})
.await
.map_err(|e| format!("The update could not be installed: {e}"))?;
handle.state::<App>().agent.stop();
handle.restart()
}
fn offer_update(h: &AppHandle) {
let h = h.clone();
tauri::async_runtime::spawn(async move {
let version = match check_update(&h).await {
Ok(Some(v)) => v,
Ok(None) => {
h.dialog()
.message(format!(
"LLeMbas {} is the newest release.",
env!("CARGO_PKG_VERSION")
))
.title("LLeMbas is up to date")
.show(|_| {});
return;
}
Err(e) => {
h.dialog()
.message(format!("Could not look for updates: {e}"))
.title("LLeMbas")
.kind(MessageDialogKind::Warning)
.show(|_| {});
return;
}
};
let again = h.clone();
h.dialog()
.message(format!(
"LLeMbas {version} is available (this is {}). Install it now? LLeMbas starts again afterwards; the agent stops for a moment.",
env!("CARGO_PKG_VERSION")
))
.title("Update LLeMbas")
.buttons(tauri_plugin_dialog::MessageDialogButtons::OkCancelCustom(
"Install".into(),
"Later".into(),
))
.show(move |yes| {
if yes {
tauri::async_runtime::spawn(async move {
if let Err(e) = install_update(again.clone()).await {
again
.dialog()
.message(e)
.title("LLeMbas")
.kind(MessageDialogKind::Error)
.show(|_| {});
}
});
}
});
});
}
#[tauri::command]
fn update_app(handle: AppHandle) {
offer_update(&handle);
}
/// A menu item chosen, from the tray or from the title bar's menu: the same ids in both.
fn on_menu(h: &AppHandle, id: &str) {
match id {
"open" => show(h),
"toggle" => {
let on = !h.state::<App>().agent.wanted();
@@ -361,6 +841,14 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
.show(|_| {});
}
}
"update" => offer_update(h),
"reload" => {
if let Some(c) = content(h) {
if let Ok(url) = c.url() {
let _ = c.navigate(url);
}
}
}
"server" => {
let _ = open_main(h, None);
}
@@ -369,12 +857,61 @@ fn build_tray(handle: &AppHandle) -> tauri::Result<TrayItems> {
h.exit(0);
}
_ => {}
});
if let Some(icon) = handle.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.build(handle)?;
Ok(TrayItems { status, toggle })
}
/// The title bar's menu: the tray's items, where the window is (a desktop without a tray —
/// GNOME without the AppIndicator extension — has no other way to reach them).
/// The title bar's menu, opened under the button that asked for it: `x`, `y` are the button's
/// bottom-left corner in the title bar page. (A plain popup opens at the pointer, which a Wayland
/// compositor does not tell the app — it landed in the middle of the window.)
#[tauri::command]
fn titlebar_menu(window: Window, x: f64, y: f64) -> Result<(), String> {
let h = window.app_handle();
let app = h.state::<App>();
let m = |id: &str, text: &str, on: bool| MenuItem::with_id(h, id, text, on, None::<&str>);
let build = || -> tauri::Result<Menu<Wry>> {
let status = m("status", &app.agent.state().label(), false)?;
let toggle = m(
"toggle",
if app.agent.wanted() {
"Stop the agent"
} else {
"Start the agent"
},
true,
)?;
let link = m("link", "Link this computer…", true)?;
let update = match app.update.lock().unwrap().clone() {
Some(v) => m("update", &format!("Install LLeMbas {v}…"), true)?,
None => m("update", "Check for updates…", true)?,
};
let reload = m("reload", "Reload", true)?;
let server = m("server", "Settings…", true)?;
let quit = m("quit", "Quit LLeMbas", true)?;
Menu::with_items(
h,
&[
&status,
&toggle,
&link,
&PredefinedMenuItem::separator(h)?,
&reload,
&update,
&server,
&PredefinedMenuItem::separator(h)?,
&quit,
],
)
};
let menu = build().map_err(|e| e.to_string())?;
// The title bar sits inside the frame while the window is not maximized.
let framed =
!(window.is_maximized().unwrap_or(false) || window.is_fullscreen().unwrap_or(false));
let inset = if framed { FRAME } else { 0.0 };
window
.popup_menu_at(&menu, LogicalPosition::new(x + inset, y + inset))
.map_err(|e| e.to_string())
}
fn on_agent_state(handle: &AppHandle, state: &AgentState) {
@@ -395,15 +932,27 @@ pub fn run() {
.plugin(tauri_plugin_single_instance::init(|h, _argv, _cwd| show(h)))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![
get_status,
set_server,
set_agent,
link_computer
link_computer,
titlebar_menu,
update_app,
remote_refresh,
remote_add,
remote_remove,
remote_mode,
remote_terminal
])
.on_menu_event(|h, ev| on_menu(h, ev.id().as_ref()))
.setup(|app| {
let handle = app.handle().clone();
let config_dir = app.path().app_config_dir()?;
if let Ok(dir) = app.path().resource_dir() {
cli::set_bundled(&dir);
}
let log = app.path().app_log_dir()?.join("agent.log");
let notify_handle = handle.clone();
let agent = Agent::new(log, move |s| {
@@ -413,29 +962,54 @@ pub fn run() {
let _ = notify_handle.run_on_main_thread(move || on_agent_state(&h, &s));
});
let loaded = settings::load(&config_dir);
// Until a server page has said, the server's own default: Moria.
let theme = loaded
.theme
.as_deref()
.and_then(Theme::parse)
.unwrap_or(Theme::Moria);
app.manage(App {
config_dir,
settings: Mutex::new(loaded.clone()),
agent,
tray: Mutex::new(None),
linking: Mutex::new(false),
update: Mutex::new(None),
remote: Mutex::new(None),
theme: Mutex::new(theme),
});
let items = build_tray(&handle)?;
*handle.state::<App>().tray.lock().unwrap() = Some(items);
open_main(&handle, loaded.server.as_deref())?;
watch_updates(handle.clone());
{
let h = handle.clone();
std::thread::spawn(move || {
refresh_remote(&h);
});
}
if let (true, Some(server)) = (loaded.agent, loaded.server) {
handle.state::<App>().agent.start(server);
}
Ok(())
})
.on_window_event(|window, event| {
.on_window_event(|window, event| match event {
// Closing the window keeps the app (and the agent) in the tray.
if let WindowEvent::CloseRequested { api, .. } = event {
if window.label() == MAIN {
WindowEvent::CloseRequested { api, .. } if window.label() == MAIN => {
let _ = window.hide();
api.prevent_close();
}
WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. }
if window.label() == MAIN =>
{
layout(window);
let _ = window.emit_to(
TITLEBAR,
"maximized",
window.is_maximized().unwrap_or(false),
);
}
_ => {}
})
.build(tauri::generate_context!())
.expect("LLeMbas Desktop could not start")
@@ -445,3 +1019,37 @@ pub fn run() {
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_theme_is_one_of_two_names() {
assert_eq!(Theme::parse("moria"), Some(Theme::Moria));
assert_eq!(Theme::parse("shire"), Some(Theme::Shire));
assert_eq!(Theme::parse("Shire"), None);
assert_eq!(Theme::parse(""), None);
for t in [Theme::Moria, Theme::Shire] {
assert_eq!(Theme::parse(t.name()), Some(t));
}
}
#[test]
fn the_themes_come_out_of_tokens_css_as_two_different_looks() {
// build.rs read these out of ui/tokens.css: a light and a dark theme cannot share
// either colour, and the frame is not the page.
assert_ne!(Theme::Moria.frame(), Theme::Shire.frame());
assert_ne!(Theme::Moria.background(), Theme::Shire.background());
assert_ne!(Theme::Moria.frame(), Theme::Moria.background());
assert_ne!(Theme::Shire.frame(), Theme::Shire.background());
}
#[test]
fn the_theme_report_is_a_navigation_the_handler_can_read() {
let url = Url::parse("llembas-theme:shire").unwrap();
assert_eq!(url.scheme(), THEME_SCHEME);
assert_eq!(Theme::parse(url.path()), Some(Theme::Shire));
assert!(THEME_SCRIPT.contains(THEME_SCHEME));
}
}
+77
View File
@@ -0,0 +1,77 @@
//! Which folders the agent may work in: the CLI's `remote` settings, read and changed through
//! `lembas remote … --json` so the app never edits the CLI's files itself. A freshly linked
//! computer allows no folder at all (remote control is off and `roots` is empty), so the agent
//! is connected but every agent chat on it is refused until at least one folder is added.
use std::process::{Command, Stdio};
use serde::{Deserialize, Serialize};
use crate::cli;
/// `lembas remote list --json`, and what every changing command answers with.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct Remote {
pub enabled: bool,
pub roots: Vec<String>,
pub max_mode: String,
pub terminal: bool,
/// The running service must start again for the change to apply.
pub restart_needed: bool,
}
impl Remote {
/// Agent chats can be opened here at all.
pub fn usable(&self) -> bool {
self.enabled && !self.roots.is_empty()
}
}
/// Run `lembas remote <args…> --json`: the new state, or what the CLI said went wrong.
pub fn run(args: &[&str]) -> Result<Remote, String> {
let out = Command::new(cli::binary())
.arg("remote")
.args(args)
.arg("--json")
.stdin(Stdio::null())
.output()
.map_err(|e| format!("Could not start {}: {e}", cli::binary().display()))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(if err.is_empty() {
format!("lembas remote {} failed ({})", args.join(" "), out.status)
} else {
err.trim_start_matches("lembas: ").to_string()
});
}
parse(&String::from_utf8_lossy(&out.stdout))
}
/// The last JSON object the CLI printed (it may say a word before it).
fn parse(stdout: &str) -> Result<Remote, String> {
stdout
.lines()
.rev()
.find_map(|l| serde_json::from_str::<Remote>(l.trim()).ok())
.ok_or_else(|| "lembas remote gave no answer this version of the app understands — is the bundled LLeMbas CLI too old?".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_the_state_and_defaults_what_is_missing() {
let r = parse(
"added /home/a/project\n{\"enabled\":true,\"roots\":[\"/home/a/project\"],\"max_mode\":\"edit\",\"terminal\":false}\n",
)
.unwrap();
assert!(r.usable());
assert_eq!(r.roots, vec!["/home/a/project"]);
assert!(!r.restart_needed);
let off = parse("{\"enabled\":false,\"roots\":[]}").unwrap();
assert!(!off.usable());
assert!(parse("lembas: unknown command remote").is_err());
}
}
+5
View File
@@ -13,6 +13,10 @@ pub struct Settings {
pub server: Option<String>,
/// Run the agent while the app runs (once this computer is linked to the server).
pub agent: bool,
/// The server page's theme the last time it said (`moria` or `shire`): what the window's
/// frame, the title bar and the first-run page are painted in until the server says again.
/// None until a server page has been shown.
pub theme: Option<String>,
}
pub fn file(config_dir: &Path) -> PathBuf {
@@ -84,6 +88,7 @@ mod tests {
let s = Settings {
server: Some("https://ai.example".into()),
agent: true,
theme: Some("shire".into()),
};
save(&dir, &s).unwrap();
assert_eq!(load(&dir), s);
+44
View File
@@ -0,0 +1,44 @@
// 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();
}
})();
+28 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "LLeMbas",
"productName": "llembas-desktop",
"mainBinaryName": "llembas-desktop",
"identifier": "eu.llembas.desktop",
"build": {
@@ -15,7 +15,10 @@
},
"bundle": {
"active": true,
"targets": ["deb", "appimage"],
"targets": [
"deb",
"appimage"
],
"category": "Productivity",
"shortDescription": "Your LLeMbas server in a window, with the agent on this computer",
"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.",
@@ -30,6 +33,28 @@
"icons/icon.png",
"icons/icon.ico"
],
"externalBin": ["binaries/lembas"]
"resources": {
"binaries/lembas": "lembas"
},
"linux": {
"deb": {
"desktopTemplate": "llembas-desktop.desktop",
"conflicts": [
"l-le-mbas"
],
"replaces": [
"l-le-mbas"
]
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgMzNBNUJCMkNCQTBCOTEwRgpSV1FQa1F1NkxMdWxNOCtnT2NMQU5QOTBHZ3hUaC9UNHVFZThUR2lqOGdWWUVObi9mbU00amoyago=",
"endpoints": [
"https://llembas.eu/desktop/latest.json"
],
"requireSignedVersion": true
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"tag": "v1.3.0",
"commit": "61cf4c267c983d5895dba3a609f90ae1efd891a0",
"sha256": "aff95291186b57693469cf1f80452fd9a9e460aa360d45ed94c2f4f979e10c6c"
}
+87 -64
View File
@@ -1,57 +1,12 @@
/* The first-run page: the LLeMbas look (Moria dark, Shire light), no build step. */
:root {
--font-ui: ui-monospace, "JetBrains Mono", "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-xl: 1.5rem;
--sp-2: 0.5rem;
--sp-3: 0.75rem;
--sp-4: 1rem;
--sp-6: 1.5rem;
--sp-8: 2rem;
--radius: 2px;
--press: 2px;
--control-h: 2.5rem;
--card-max: 34rem;
color-scheme: dark;
--bg: #0B0D10;
--surface: #11151A;
--surface-hover: #1D232B;
--border: #26303A;
--border-strong: #5C6976;
--ink: #E2E8EC;
--ink-muted: #A6B2BC;
--accent: #8FD8E8;
--accent-hover: #B4E6F1;
--accent-ink: #061418;
--leaf: #7EE081;
--warning: #F0A840;
--shadow-card: 4px 4px 0 rgba(0, 0, 0, 0.55);
}
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
--bg: #F1EAD6;
--surface: #F8F3E5;
--surface-hover: #ECE4CF;
--border: #CBBF9D;
--border-strong: #2A2E22;
--ink: #1F2418;
--ink-muted: #474D3B;
--accent: #285F3C;
--accent-hover: #1F4D30;
--accent-ink: #F8F3E5;
--leaf: #3D6B1C;
--warning: #7E5200;
--shadow-card: 4px 4px 0 var(--border-strong);
}
}
/* The first-run page. Every colour, size and space is a token from tokens.css (the server's,
fetched by scripts/fetch-tokens.sh); the one value of this page's own is the card's width. */
* { box-sizing: border-box; }
:root {
/* The card's widest: a layout fact of this page, not a token the server has. */
--card-max: 40rem;
}
html, body { margin: 0; min-height: 100%; }
body {
@@ -61,7 +16,7 @@ body {
padding: var(--sp-4);
background: var(--bg);
color: var(--ink);
font: var(--text-base) / 1.6 var(--font-body);
font: var(--text-base) / var(--leading-normal) var(--font-body);
}
.card {
@@ -70,21 +25,32 @@ body {
gap: var(--sp-6);
padding: var(--sp-8);
background: var(--surface);
border: 1px solid var(--border);
border: var(--border-w) solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
box-shadow: var(--shadow-block-lg);
}
.brand { display: grid; grid-template-columns: 40px 1fr; gap: var(--sp-3); align-items: center; }
h1 { margin: 0; font: 600 var(--text-xl) / 1.2 var(--font-ui); }
.brand { display: grid; grid-template-columns: var(--sp-10) 1fr; gap: var(--sp-3); align-items: center; }
.brand img { width: var(--sp-10); height: var(--sp-10); }
h1 { margin: 0; font: 600 var(--text-xl) / var(--leading-tight) var(--font-ui); }
h1 b { color: var(--leaf); font-weight: 700; }
h2 { margin: 0 0 var(--sp-3); font: 600 var(--text-sm) / 1.2 var(--font-ui); letter-spacing: 0.08em; color: var(--accent); }
/* A section's heading, in brackets as on the server's screens: `[ SERVER ]`. The brackets are
drawn here so the words alone are what gets translated. */
h2 {
margin: 0 0 var(--sp-3);
font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
}
h2::before { content: "[ "; }
h2::after { content: " ]"; }
p { margin: 0; }
.muted { color: var(--ink-muted); font-size: var(--text-sm); }
/* A field is three stacked parts on fixed rows: label, control row, hint. */
.field { display: grid; grid-template-rows: auto var(--control-h) auto; gap: var(--sp-2); }
label { font: var(--text-sm) / 1.4 var(--font-ui); color: var(--ink-muted); }
label, .label { font: var(--text-sm) / 1.4 var(--font-ui); color: var(--ink-muted); }
.hint { font-size: var(--text-sm); color: var(--ink-muted); min-height: 1.4em; }
.hint.is-error, .status.is-error { color: var(--warning); }
@@ -94,14 +60,17 @@ label { font: var(--text-sm) / 1.4 var(--font-ui); color: var(--ink-muted); }
input {
height: var(--control-h);
min-width: 0;
padding: 0 var(--sp-3);
padding: 0 var(--control-px);
font: var(--text-base) var(--font-ui);
color: var(--ink);
background: var(--bg);
border: 1px solid var(--border-strong);
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius);
}
input:focus-visible, .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
input:focus-visible, .btn:focus-visible {
outline: var(--outline-w) solid var(--accent);
outline-offset: var(--outline-w);
}
.btn {
height: var(--control-h);
@@ -109,9 +78,9 @@ input:focus-visible, .btn:focus-visible { outline: 2px solid var(--accent); outl
font: 600 var(--text-sm) var(--font-ui);
color: var(--ink);
background: var(--surface);
border: 1px solid var(--border-strong);
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--press) var(--press) 0 rgba(0, 0, 0, 0.45);
box-shadow: var(--shadow-block);
cursor: pointer;
}
.btn:hover { background: var(--surface-hover); }
@@ -128,3 +97,57 @@ input:focus-visible, .btn:focus-visible { outline: 2px solid var(--accent); outl
.row { grid-template-columns: 1fr; grid-template-rows: var(--control-h) var(--control-h); }
.field { grid-template-rows: auto auto auto; }
}
/* The folder list: every row the same two tracks, the path cut from the left (its end is the
part that tells folders apart), the full path in the title. */
.folders { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); }
.folders li {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
align-items: center;
min-height: var(--control-h);
padding: 0 0 0 var(--control-px);
background: var(--bg);
border: var(--border-w) solid var(--border);
border-radius: var(--radius);
}
.folders .path {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
font: var(--text-sm) var(--font-ui);
}
.folders .path bdi { direction: ltr; unicode-bidi: isolate; }
.folders .btn {
height: calc(var(--control-h) - 2 * var(--border-w));
box-shadow: none;
border-width: 0 0 0 var(--border-w);
}
.folders .empty { padding: var(--control-px); color: var(--warning); font-size: var(--text-sm); }
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
grid-auto-rows: auto var(--control-h) auto;
column-gap: var(--sp-4);
row-gap: var(--sp-2);
margin-top: var(--sp-4);
}
/* Each field spans three shared rows (label, control, hint), so the controls of side-by-side
fields sit on one line whatever their labels and hints do. */
.settings-grid .field { grid-row: span 3; grid-template-rows: subgrid; }
select {
height: var(--control-h);
min-width: 0;
padding: 0 var(--control-px-sm);
font: var(--text-sm) var(--font-ui);
color: var(--ink);
background: var(--bg);
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius);
}
.check { display: flex; align-items: center; gap: var(--sp-2); color: var(--ink); }
.check input { width: var(--check-size); height: var(--check-size); margin: 0; accent-color: var(--accent); }
+62 -11
View File
@@ -1,5 +1,6 @@
// The first-run page. It talks to the app over Tauri's IPC — four commands, see src-tauri/src/lib.rs.
// 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
@@ -8,25 +9,66 @@ const { listen } = window.__TAURI__.event
const $ = (id) => document.getElementById(id)
const AGENT = {
off: () => ["The agent is off.", ""],
not_linked: () => ["This computer is not linked to the server yet.", ""],
starting: () => ["The agent is starting…", ""],
running: () => ["The agent is running: agent chats can use this computer.", "is-ok"],
elsewhere: (pid) => [`A LLeMbas CLI service already runs here (pid ${pid}); it is this computer's agent.`, "is-ok"],
failed: (why) => [`The agent stopped: ${why}. Trying again shortly.`, "is-error"],
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 [text, cls] = (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail)
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 ? "Link again" : "Link this computer"
$("toggle").textContent = s.agent_wanted ? "Stop the agent" : "Start the agent"
$("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() {
@@ -39,7 +81,7 @@ $("server-form").addEventListener("submit", async (e) => {
const button = e.target.querySelector("button")
button.disabled = true
hint.className = "hint"
hint.textContent = "Looking for a LLeMbas server there…"
hint.textContent = t("Looking for a LLeMbas server there…")
try {
await invoke("set_server", { address: $("address").value })
// The window now shows the server.
@@ -70,5 +112,14 @@ $("open").addEventListener("click", async () => {
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")
+463
View File
@@ -0,0 +1,463 @@
// The app's own pages' words in the server's languages. GENERATED by scripts/gen-i18n.py:
// the sentences the server also has come from its web/i18n catalogues, the rest from the
// script's own table. Do not edit; run the script.
//
// `t(text, fields)` is the page's English with `%(name)s` fields, as on the server; a
// missing entry is the English. The language is the system's (navigator.language),
// English unless it is one of these. Elements marked `data-i18n` (their text) and
// `data-i18n-title`, `data-i18n-aria-label`, `data-i18n-placeholder` (that attribute)
// are translated once the page is parsed; this script is deferred, before the page's.
"use strict"
const LEMBAS_I18N = {
"cs": {
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Služba LLeMbas CLI už tu běží (pid %(pid)s); je agentem tohoto počítače.",
"A chat asking for more is lowered to this.": "Konverzace, která chce víc, je snížena na tohle.",
"A shell as you, in one of these folders.": "Shell pod vaším účtem, v jedné z těchto složek.",
"Add folder…": "Přidat složku…",
"Address of your LLeMbas server": "Adresa vašeho serveru LLeMbas",
"Agent": "Agent",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Agentní konverzace z vašeho serveru mohou pracovat v těchto složkách a ve všem uvnitř nich — nikde jinde na tomto počítači. Spusťte ji na serveru na kartě Agenti, na zařízení „Místní“.",
"Agent stopped: %(why)s": "Agent se zastavil: %(why)s",
"Auto — asks nothing": "Auto — na nic se neptá",
"Close": "Zavřít",
"Close (LLeMbas stays in the tray)": "Zavřít (LLeMbas zůstane v oznamovací oblasti)",
"Connect": "Připojit",
"Edit — asks before commands": "Edit — ptá se před příkazy",
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozici LLeMbas %(version)s (tohle je %(current)s)",
"Let the server open a terminal here": "Dovolit serveru otevřít zde terminál",
"Link again": "Propojit znovu",
"Link this computer": "Propojit tento počítač",
"Looking for a LLeMbas server there…": "Hledám tam server LLeMbas…",
"Manual — asks first": "Manual — nejdřív se zeptá",
"Maximise": "Maximalizovat",
"Menu": "Nabídka",
"Minimise": "Minimalizovat",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Zatím žádná složka: agent nemůže nic otevřít. Přidejte složku projektu.",
"Open the server": "Otevřít server",
"Plan — changes nothing": "Plan — nic nemění",
"Project folders": "Složky projektů",
"Remove": "Odebrat",
"Remove %(dir)s": "Odebrat %(dir)s",
"Restore": "Obnovit",
"Server": "Server",
"Sign in there as you would in a browser.": "Přihlaste se tam jako v prohlížeči.",
"Start the agent": "Spustit agenta",
"Stop the agent": "Zastavit agenta",
"Terminal": "Terminál",
"The agent is connected, but may open no folder yet: add one below.": "Agent je připojený, ale zatím nesmí otevřít žádnou složku: přidejte ji níže.",
"The agent is off.": "Agent je vypnutý.",
"The agent is running: agent chats can use this computer.": "Agent běží: agentní konverzace mohou používat tento počítač.",
"The agent is starting…": "Agent se spouš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.": "Agent umožňuje agentním konverzacím na vašem serveru pracovat v projektech na tomto počítači. Je to vestavěné LLeMbas CLI, které běží na pozadí, dokud běží aplikace.",
"The agent stopped: %(why)s. Trying again shortly.": "Agent se zastavil: %(why)s. Za chvíli to zkusí znovu.",
"The most an agent chat may do here": "Nejvíc, co tu agentní konverzace smí",
"This computer is not linked to the server yet.": "Tento počítač ještě není propojený se serverem.",
"agent off": "agent vypnutý",
"agent running": "agent běží",
"agent running (service)": "agent běží (služba)",
"agent starting…": "agent se spouští…",
"agent stopped": "agent zastaven",
"agent: no folders": "agent: žádné složky",
"not linked": "nepropojeno",
"update": "aktualizace",
"update %(version)s": "aktualizace %(version)s"
},
"de": {
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Ein LLeMbas-CLI-Dienst läuft hier bereits (PID %(pid)s); er ist der Agent dieses Computers.",
"A chat asking for more is lowered to this.": "Ein Chat, der mehr verlangt, wird hierauf begrenzt.",
"A shell as you, in one of these folders.": "Eine Shell als Sie, in einem dieser Ordner.",
"Add folder…": "Ordner hinzufügen…",
"Address of your LLeMbas server": "Adresse Ihres LLeMbas-Servers",
"Agent": "Agent",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Agent-Chats von Ihrem Server dürfen in diesen Ordnern und allem darin arbeiten — nirgends sonst auf diesem Computer. Starten Sie einen im Reiter Agenten des Servers, auf „Lokal“.",
"Agent stopped: %(why)s": "Agent angehalten: %(why)s",
"Auto — asks nothing": "Auto — fragt nichts",
"Close": "Schließen",
"Close (LLeMbas stays in the tray)": "Schließen (LLeMbas bleibt im Benachrichtigungsbereich)",
"Connect": "Verbinden",
"Edit — asks before commands": "Edit — fragt vor Befehlen",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s ist verfügbar (dies ist %(current)s)",
"Let the server open a terminal here": "Dem Server erlauben, hier ein Terminal zu öffnen",
"Link again": "Erneut verbinden",
"Link this computer": "Diesen Computer verbinden",
"Looking for a LLeMbas server there…": "Suche dort nach einem LLeMbas-Server…",
"Manual — asks first": "Manual — fragt zuerst",
"Maximise": "Maximieren",
"Menu": "Menü",
"Minimise": "Minimieren",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Noch kein Ordner: Der Agent kann nichts öffnen. Fügen Sie den Ordner eines Projekts hinzu.",
"Open the server": "Server öffnen",
"Plan — changes nothing": "Plan — ändert nichts",
"Project folders": "Projektordner",
"Remove": "Entfernen",
"Remove %(dir)s": "%(dir)s entfernen",
"Restore": "Wiederherstellen",
"Server": "Server",
"Sign in there as you would in a browser.": "Melden Sie sich dort wie im Browser an.",
"Start the agent": "Agent starten",
"Stop the agent": "Agent anhalten",
"Terminal": "Terminal",
"The agent is connected, but may open no folder yet: add one below.": "Der Agent ist verbunden, darf aber noch keinen Ordner öffnen: Fügen Sie unten einen hinzu.",
"The agent is off.": "Der Agent ist aus.",
"The agent is running: agent chats can use this computer.": "Der Agent läuft: Agent-Chats können diesen Computer nutzen.",
"The agent is starting…": "Der Agent startet…",
"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.": "Der Agent lässt Agent-Chats auf Ihrem Server in den Projekten dieses Computers arbeiten. Er ist das eingebaute LLeMbas CLI und läuft im Hintergrund, solange die App läuft.",
"The agent stopped: %(why)s. Trying again shortly.": "Der Agent hat angehalten: %(why)s. Gleich wird es erneut versucht.",
"The most an agent chat may do here": "Das Meiste, was ein Agent-Chat hier darf",
"This computer is not linked to the server yet.": "Dieser Computer ist noch nicht mit dem Server verbunden.",
"agent off": "Agent aus",
"agent running": "Agent läuft",
"agent running (service)": "Agent läuft (Dienst)",
"agent starting…": "Agent startet…",
"agent stopped": "Agent angehalten",
"agent: no folders": "Agent: keine Ordner",
"not linked": "nicht verbunden",
"update": "Update",
"update %(version)s": "Update %(version)s"
},
"es": {
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Ya se está ejecutando aquí un servicio de LLeMbas CLI (pid %(pid)s); es el agente de este ordenador.",
"A chat asking for more is lowered to this.": "Un chat que pida más se limita a esto.",
"A shell as you, in one of these folders.": "Un shell con su usuario, en una de estas carpetas.",
"Add folder…": "Añadir carpeta…",
"Address of your LLeMbas server": "Dirección de su servidor LLeMbas",
"Agent": "Agente",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Los chats de agente de su servidor pueden trabajar en estas carpetas y en todo lo que contienen — en ningún otro lugar de este ordenador. Inicie uno en la pestaña Agentes del servidor, en «Local».",
"Agent stopped: %(why)s": "El agente se detuvo: %(why)s",
"Auto — asks nothing": "Auto — no pregunta nada",
"Close": "Cerrar",
"Close (LLeMbas stays in the tray)": "Cerrar (LLeMbas sigue en la bandeja del sistema)",
"Connect": "Conectar",
"Edit — asks before commands": "Edit — pregunta antes de los comandos",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s está disponible (esta es la %(current)s)",
"Let the server open a terminal here": "Permitir que el servidor abra un terminal aquí",
"Link again": "Vincular de nuevo",
"Link this computer": "Vincular este ordenador",
"Looking for a LLeMbas server there…": "Buscando un servidor LLeMbas allí…",
"Manual — asks first": "Manual — pregunta primero",
"Maximise": "Maximizar",
"Menu": "Menú",
"Minimise": "Minimizar",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Aún no hay ninguna carpeta: el agente no puede abrir nada. Añada la carpeta de un proyecto.",
"Open the server": "Abrir el servidor",
"Plan — changes nothing": "Plan — no cambia nada",
"Project folders": "Carpetas de proyectos",
"Remove": "Quitar",
"Remove %(dir)s": "Quitar %(dir)s",
"Restore": "Restaurar",
"Server": "Servidor",
"Sign in there as you would in a browser.": "Inicie sesión allí como lo haría en un navegador.",
"Start the agent": "Iniciar el agente",
"Stop the agent": "Detener el agente",
"Terminal": "Terminal",
"The agent is connected, but may open no folder yet: add one below.": "El agente está conectado, pero aún no puede abrir ninguna carpeta: añada una abajo.",
"The agent is off.": "El agente está apagado.",
"The agent is running: agent chats can use this computer.": "El agente está en marcha: los chats de agente pueden usar este ordenador.",
"The agent is starting…": "El agente se está iniciando…",
"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.": "El agente permite que los chats de agente de su servidor trabajen en los proyectos de este ordenador. Es el LLeMbas CLI, integrado, que se ejecuta en segundo plano mientras la aplicación está abierta.",
"The agent stopped: %(why)s. Trying again shortly.": "El agente se detuvo: %(why)s. Se volverá a intentar en breve.",
"The most an agent chat may do here": "Lo máximo que un chat de agente puede hacer aquí",
"This computer is not linked to the server yet.": "Este ordenador aún no está vinculado al servidor.",
"agent off": "agente apagado",
"agent running": "agente en marcha",
"agent running (service)": "agente en marcha (servicio)",
"agent starting…": "agente iniciando…",
"agent stopped": "agente detenido",
"agent: no folders": "agente: sin carpetas",
"not linked": "no vinculado",
"update": "actualizar",
"update %(version)s": "actualizar a %(version)s"
},
"fr": {
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Un service LLeMbas CLI tourne déjà ici (pid %(pid)s) ; c'est l'agent de cet ordinateur.",
"A chat asking for more is lowered to this.": "Une conversation qui en demande plus est ramenée à ceci.",
"A shell as you, in one of these folders.": "Un shell en votre nom, dans l'un de ces dossiers.",
"Add folder…": "Ajouter un dossier…",
"Address of your LLeMbas server": "Adresse de votre serveur LLeMbas",
"Agent": "Agent",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Les conversations d'agent de votre serveur peuvent travailler dans ces dossiers et tout ce qu'ils contiennent — nulle part ailleurs sur cet ordinateur. Démarrez-en une dans l'onglet Agents du serveur, sur « Local ».",
"Agent stopped: %(why)s": "L'agent s'est arrêté : %(why)s",
"Auto — asks nothing": "Auto — ne demande rien",
"Close": "Fermer",
"Close (LLeMbas stays in the tray)": "Fermer (LLeMbas reste dans la zone de notification)",
"Connect": "Se connecter",
"Edit — asks before commands": "Edit — demande avant les commandes",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s est disponible (ceci est la %(current)s)",
"Let the server open a terminal here": "Laisser le serveur ouvrir un terminal ici",
"Link again": "Relier à nouveau",
"Link this computer": "Relier cet ordinateur",
"Looking for a LLeMbas server there…": "Recherche d'un serveur LLeMbas à cette adresse…",
"Manual — asks first": "Manual — demande d'abord",
"Maximise": "Agrandir",
"Menu": "Menu",
"Minimise": "Réduire",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Aucun dossier pour l'instant : l'agent ne peut rien ouvrir. Ajoutez le dossier d'un projet.",
"Open the server": "Ouvrir le serveur",
"Plan — changes nothing": "Plan — ne change rien",
"Project folders": "Dossiers de projets",
"Remove": "Retirer",
"Remove %(dir)s": "Retirer %(dir)s",
"Restore": "Restaurer",
"Server": "Serveur",
"Sign in there as you would in a browser.": "Connectez-vous là-bas comme dans un navigateur.",
"Start the agent": "Démarrer l'agent",
"Stop the agent": "Arrêter l'agent",
"Terminal": "Terminal",
"The agent is connected, but may open no folder yet: add one below.": "L'agent est connecté, mais ne peut encore ouvrir aucun dossier : ajoutez-en un ci-dessous.",
"The agent is off.": "L'agent est arrêté.",
"The agent is running: agent chats can use this computer.": "L'agent tourne : les conversations d'agent peuvent utiliser cet ordinateur.",
"The agent is starting…": "L'agent démarre…",
"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.": "L'agent permet aux conversations d'agent de votre serveur de travailler dans les projets de cet ordinateur. C'est le LLeMbas CLI, intégré, qui tourne en arrière-plan tant que l'application est ouverte.",
"The agent stopped: %(why)s. Trying again shortly.": "L'agent s'est arrêté : %(why)s. Nouvel essai sous peu.",
"The most an agent chat may do here": "Le maximum qu'une conversation d'agent peut faire ici",
"This computer is not linked to the server yet.": "Cet ordinateur n'est pas encore relié au serveur.",
"agent off": "agent arrêté",
"agent running": "agent actif",
"agent running (service)": "agent actif (service)",
"agent starting…": "agent en démarrage…",
"agent stopped": "agent interrompu",
"agent: no folders": "agent : aucun dossier",
"not linked": "non relié",
"update": "mise à jour",
"update %(version)s": "mise à jour %(version)s"
},
"hu": {
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Itt már fut egy LLeMbas CLI szolgáltatás (pid %(pid)s); ez a számítógép ügynöke.",
"A chat asking for more is lowered to this.": "Az ennél többet kérő csevegés erre korlátozódik.",
"A shell as you, in one of these folders.": "Egy shell az Ön nevében, ezek egyik mappájában.",
"Add folder…": "Mappa hozzáadása…",
"Address of your LLeMbas server": "A LLeMbas-kiszolgáló címe",
"Agent": "Ügynök",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "A kiszolgáló ügynök-csevegései ezekben a mappákban és a bennük lévőkben dolgozhatnak — a számítógépen máshol nem. Indítson egyet a kiszolgáló Ügynökök lapján, a „Helyi” eszközön.",
"Agent stopped: %(why)s": "Az ügynök leállt: %(why)s",
"Auto — asks nothing": "Auto — semmit nem kérdez",
"Close": "Bezárás",
"Close (LLeMbas stays in the tray)": "Bezárás (a LLeMbas a tálcán marad)",
"Connect": "Csatlakozás",
"Edit — asks before commands": "Edit — parancsok előtt kérdez",
"LLeMbas %(version)s is available (this is %(current)s)": "Elérhető a LLeMbas %(version)s (ez a %(current)s)",
"Let the server open a terminal here": "A kiszolgáló nyithasson itt terminált",
"Link again": "Újra összekapcsolás",
"Link this computer": "A számítógép összekapcsolása",
"Looking for a LLeMbas server there…": "LLeMbas-kiszolgáló keresése ott…",
"Manual — asks first": "Manual — előbb kérdez",
"Maximise": "Teljes méret",
"Menu": "Menü",
"Minimise": "Kis méret",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Még nincs mappa: az ügynök semmit nem nyithat meg. Adja hozzá egy projekt mappáját.",
"Open the server": "Kiszolgáló megnyitása",
"Plan — changes nothing": "Plan — semmit nem változtat",
"Project folders": "Projektmappák",
"Remove": "Eltávolítás",
"Remove %(dir)s": "%(dir)s eltávolítása",
"Restore": "Visszaállítás",
"Server": "Kiszolgáló",
"Sign in there as you would in a browser.": "Jelentkezzen be ott úgy, mint egy böngészőben.",
"Start the agent": "Ügynök indítása",
"Stop the agent": "Ügynök leállítása",
"Terminal": "Terminál",
"The agent is connected, but may open no folder yet: add one below.": "Az ügynök csatlakozott, de még egy mappát sem nyithat meg: adjon hozzá egyet lent.",
"The agent is off.": "Az ügynök ki van kapcsolva.",
"The agent is running: agent chats can use this computer.": "Az ügynök fut: az ügynök-csevegések használhatják ezt a számítógépet.",
"The agent is starting…": "Az ügynök indul…",
"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.": "Az ügynök lehetővé teszi, hogy a kiszolgálón futó ügynök-csevegések ennek a számítógépnek a projektjeiben dolgozzanak. Ez a beépített LLeMbas CLI, amely a háttérben fut, amíg az alkalmazás fut.",
"The agent stopped: %(why)s. Trying again shortly.": "Az ügynök leállt: %(why)s. Hamarosan újra próbálja.",
"The most an agent chat may do here": "A legtöbb, amit egy ügynök-csevegés itt tehet",
"This computer is not linked to the server yet.": "Ez a számítógép még nincs összekapcsolva a kiszolgálóval.",
"agent off": "ügynök kikapcsolva",
"agent running": "ügynök fut",
"agent running (service)": "ügynök fut (szolgáltatás)",
"agent starting…": "ügynök indul…",
"agent stopped": "ügynök leállt",
"agent: no folders": "ügynök: nincs mappa",
"not linked": "nincs összekapcsolva",
"update": "frissítés",
"update %(version)s": "frissítés: %(version)s"
},
"it": {
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Un servizio LLeMbas CLI è già in esecuzione qui (pid %(pid)s); è l'agente di questo computer.",
"A chat asking for more is lowered to this.": "Una chat che chiede di più viene limitata a questo.",
"A shell as you, in one of these folders.": "Una shell con il tuo utente, in una di queste cartelle.",
"Add folder…": "Aggiungi cartella…",
"Address of your LLeMbas server": "Indirizzo del tuo server LLeMbas",
"Agent": "Agente",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Le chat agente del tuo server possono lavorare in queste cartelle e in tutto ciò che contengono — in nessun altro posto di questo computer. Avviane una nella scheda Agenti del server, su “Locale”.",
"Agent stopped: %(why)s": "L'agente si è fermato: %(why)s",
"Auto — asks nothing": "Auto — non chiede nulla",
"Close": "Chiudi",
"Close (LLeMbas stays in the tray)": "Chiudi (LLeMbas resta nell'area di notifica)",
"Connect": "Connetti",
"Edit — asks before commands": "Edit — chiede prima dei comandi",
"LLeMbas %(version)s is available (this is %(current)s)": "È disponibile LLeMbas %(version)s (questa è la %(current)s)",
"Let the server open a terminal here": "Lascia che il server apra un terminale qui",
"Link again": "Collega di nuovo",
"Link this computer": "Collega questo computer",
"Looking for a LLeMbas server there…": "Cerco un server LLeMbas lì…",
"Manual — asks first": "Manual — chiede prima",
"Maximise": "Ingrandisci",
"Menu": "Menu",
"Minimise": "Riduci a icona",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Ancora nessuna cartella: l'agente non può aprire nulla. Aggiungi la cartella di un progetto.",
"Open the server": "Apri il server",
"Plan — changes nothing": "Plan — non cambia nulla",
"Project folders": "Cartelle dei progetti",
"Remove": "Rimuovi",
"Remove %(dir)s": "Rimuovi %(dir)s",
"Restore": "Ripristina",
"Server": "Server",
"Sign in there as you would in a browser.": "Accedi lì come faresti in un browser.",
"Start the agent": "Avvia l'agente",
"Stop the agent": "Ferma l'agente",
"Terminal": "Terminale",
"The agent is connected, but may open no folder yet: add one below.": "L'agente è connesso, ma non può ancora aprire nessuna cartella: aggiungine una qui sotto.",
"The agent is off.": "L'agente è spento.",
"The agent is running: agent chats can use this computer.": "L'agente è in esecuzione: le chat agente possono usare questo computer.",
"The agent is starting…": "L'agente si sta avviando…",
"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.": "L'agente permette alle chat agente del tuo server di lavorare nei progetti di questo computer. È il LLeMbas CLI, integrato, in esecuzione in background finché l'app è aperta.",
"The agent stopped: %(why)s. Trying again shortly.": "L'agente si è fermato: %(why)s. Tra poco riprova.",
"The most an agent chat may do here": "Il massimo che una chat agente può fare qui",
"This computer is not linked to the server yet.": "Questo computer non è ancora collegato al server.",
"agent off": "agente spento",
"agent running": "agente attivo",
"agent running (service)": "agente attivo (servizio)",
"agent starting…": "agente in avvio…",
"agent stopped": "agente fermato",
"agent: no folders": "agente: nessuna cartella",
"not linked": "non collegato",
"update": "aggiorna",
"update %(version)s": "aggiorna a %(version)s"
},
"pl": {
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Usługa LLeMbas CLI już tu działa (pid %(pid)s); jest agentem tego komputera.",
"A chat asking for more is lowered to this.": "Czat, który prosi o więcej, zostaje ograniczony do tego.",
"A shell as you, in one of these folders.": "Powłoka na Twoim koncie, w jednym z tych folderów.",
"Add folder…": "Dodaj folder…",
"Address of your LLeMbas server": "Adres Twojego serwera LLeMbas",
"Agent": "Agent",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Czaty agenta z Twojego serwera mogą pracować w tych folderach i we wszystkim, co zawierają — nigdzie indziej na tym komputerze. Uruchom czat na karcie Agenci na serwerze, na urządzeniu „Lokalne”.",
"Agent stopped: %(why)s": "Agent się zatrzymał: %(why)s",
"Auto — asks nothing": "Auto — o nic nie pyta",
"Close": "Zamknij",
"Close (LLeMbas stays in the tray)": "Zamknij (LLeMbas zostaje w zasobniku systemowym)",
"Connect": "Połącz",
"Edit — asks before commands": "Edit — pyta przed poleceniami",
"LLeMbas %(version)s is available (this is %(current)s)": "Dostępny jest LLeMbas %(version)s (to jest %(current)s)",
"Let the server open a terminal here": "Pozwól serwerowi otworzyć tu terminal",
"Link again": "Połącz ponownie",
"Link this computer": "Połącz ten komputer",
"Looking for a LLeMbas server there…": "Szukam tam serwera LLeMbas…",
"Manual — asks first": "Manual — najpierw pyta",
"Maximise": "Maksymalizuj",
"Menu": "Menu",
"Minimise": "Minimalizuj",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Nie ma jeszcze folderu: agent nie może nic otworzyć. Dodaj folder projektu.",
"Open the server": "Otwórz serwer",
"Plan — changes nothing": "Plan — niczego nie zmienia",
"Project folders": "Foldery projektów",
"Remove": "Usuń",
"Remove %(dir)s": "Usuń %(dir)s",
"Restore": "Przywróć",
"Server": "Serwer",
"Sign in there as you would in a browser.": "Zaloguj się tam tak jak w przeglądarce.",
"Start the agent": "Uruchom agenta",
"Stop the agent": "Zatrzymaj agenta",
"Terminal": "Terminal",
"The agent is connected, but may open no folder yet: add one below.": "Agent jest połączony, ale nie może jeszcze otworzyć żadnego folderu: dodaj go poniżej.",
"The agent is off.": "Agent jest wyłączony.",
"The agent is running: agent chats can use this computer.": "Agent działa: czaty agenta mogą używać tego komputera.",
"The agent is starting…": "Agent się uruchamia…",
"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.": "Agent pozwala czatom agenta z Twojego serwera pracować w projektach na tym komputerze. To wbudowane LLeMbas CLI, działające w tle, dopóki działa aplikacja.",
"The agent stopped: %(why)s. Trying again shortly.": "Agent się zatrzymał: %(why)s. Za chwilę spróbuje ponownie.",
"The most an agent chat may do here": "Najwięcej, co czat agenta może tu zrobić",
"This computer is not linked to the server yet.": "Ten komputer nie jest jeszcze połączony z serwerem.",
"agent off": "agent wyłączony",
"agent running": "agent działa",
"agent running (service)": "agent działa (usługa)",
"agent starting…": "agent się uruchamia…",
"agent stopped": "agent zatrzymany",
"agent: no folders": "agent: brak folderów",
"not linked": "niepołączony",
"update": "aktualizacja",
"update %(version)s": "aktualizacja %(version)s"
},
"sk": {
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Služba LLeMbas CLI tu už beží (pid %(pid)s); je agentom tohto počítača.",
"A chat asking for more is lowered to this.": "Konverzácia, ktorá chce viac, je znížená na toto.",
"A shell as you, in one of these folders.": "Shell pod vaším účtom, v jednom z týchto priečinkov.",
"Add folder…": "Pridať priečinok…",
"Address of your LLeMbas server": "Adresa vášho servera LLeMbas",
"Agent": "Agent",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Agentské konverzácie z vášho servera môžu pracovať v týchto priečinkoch a vo všetkom, čo obsahujú — nikde inde na tomto počítači. Spustite ju na serveri na karte Agenti, na zariadení „Lokálny“.",
"Agent stopped: %(why)s": "Agent sa zastavil: %(why)s",
"Auto — asks nothing": "Auto — na nič sa nepýta",
"Close": "Zavrieť",
"Close (LLeMbas stays in the tray)": "Zavrieť (LLeMbas zostane v oznamovacej oblasti)",
"Connect": "Pripojiť",
"Edit — asks before commands": "Edit — pýta sa pred príkazmi",
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozícii LLeMbas %(version)s (toto je %(current)s)",
"Let the server open a terminal here": "Dovoliť serveru otvoriť tu terminál",
"Link again": "Prepojiť znova",
"Link this computer": "Prepojiť tento počítač",
"Looking for a LLeMbas server there…": "Hľadám tam server LLeMbas…",
"Manual — asks first": "Manual — najprv sa opýta",
"Maximise": "Maximalizovať",
"Menu": "Ponuka",
"Minimise": "Minimalizovať",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Zatiaľ žiadny priečinok: agent nemôže nič otvoriť. Pridajte priečinok projektu.",
"Open the server": "Otvoriť server",
"Plan — changes nothing": "Plan — nič nemení",
"Project folders": "Priečinky projektov",
"Remove": "Odstrániť",
"Remove %(dir)s": "Odstrániť %(dir)s",
"Restore": "Obnoviť",
"Server": "Server",
"Sign in there as you would in a browser.": "Prihláste sa tam ako v prehliadači.",
"Start the agent": "Spustiť agenta",
"Stop the agent": "Zastaviť agenta",
"Terminal": "Terminál",
"The agent is connected, but may open no folder yet: add one below.": "Agent je pripojený, ale zatiaľ nesmie otvoriť žiadny priečinok: pridajte ho nižšie.",
"The agent is off.": "Agent je vypnutý.",
"The agent is running: agent chats can use this computer.": "Agent beží: agentské konverzácie môžu používať tento počítač.",
"The agent is starting…": "Agent sa spúšťa…",
"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.": "Agent umožňuje agentským konverzáciám na vašom serveri pracovať v projektoch na tomto počítači. Je to vstavané LLeMbas CLI, ktoré beží na pozadí, kým beží aplikácia.",
"The agent stopped: %(why)s. Trying again shortly.": "Agent sa zastavil: %(why)s. O chvíľu to skúsi znova.",
"The most an agent chat may do here": "Najviac, čo tu agentská konverzácia smie",
"This computer is not linked to the server yet.": "Tento počítač ešte nie je prepojený so serverom.",
"agent off": "agent vypnutý",
"agent running": "agent beží",
"agent running (service)": "agent beží (služba)",
"agent starting…": "agent sa spúšťa…",
"agent stopped": "agent zastavený",
"agent: no folders": "agent: žiadne priečinky",
"not linked": "neprepojené",
"update": "aktualizácia",
"update %(version)s": "aktualizácia %(version)s"
}
}
const LEMBAS_LANG = (() => {
const code = String(navigator.language || "").slice(0, 2).toLowerCase()
return Object.prototype.hasOwnProperty.call(LEMBAS_I18N, code) ? code : "en"
})()
document.documentElement.lang = LEMBAS_LANG
function t(text, fields) {
const key = String(text).replace(/\s+/g, " ").trim()
const table = LEMBAS_I18N[LEMBAS_LANG] || {}
const out = Object.prototype.hasOwnProperty.call(table, key) ? table[key] : key
if (!fields) return out
return out.replace(/%\((\w+)\)s/g, (all, name) =>
Object.prototype.hasOwnProperty.call(fields, name) ? String(fields[name]) : all)
}
function translatePage() {
for (const el of document.querySelectorAll("[data-i18n]")) el.textContent = t(el.textContent)
for (const attr of ["title", "aria-label", "placeholder"]) {
for (const el of document.querySelectorAll(`[data-i18n-${attr}]`)) {
el.setAttribute(attr, t(el.getAttribute(attr)))
}
}
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", translatePage)
else translatePage()
+40 -9
View File
@@ -5,7 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<title>LLeMbas</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="app.css">
<script src="theme.js"></script>
<script src="i18n.js" defer></script>
<script src="app.js" defer></script>
</head>
<body>
@@ -19,27 +22,55 @@
</header>
<section aria-labelledby="server-h">
<h2 id="server-h">[ SERVER ]</h2>
<h2 id="server-h" data-i18n>Server</h2>
<form id="server-form" class="field" novalidate>
<label for="address">Address of your LLeMbas server</label>
<label for="address" data-i18n>Address of your LLeMbas server</label>
<div class="row">
<input id="address" name="address" type="text" inputmode="url" autocomplete="url"
placeholder="https://ai.example" spellcheck="false" required>
<button type="submit" class="btn btn--primary">Connect</button>
<button type="submit" class="btn btn--primary" data-i18n>Connect</button>
</div>
<p class="hint" id="server-hint" aria-live="polite">Sign in there as you would in a browser.</p>
<p class="hint" id="server-hint" aria-live="polite" data-i18n>Sign in there as you would in a browser.</p>
</form>
</section>
<section aria-labelledby="agent-h" id="agent-section" hidden>
<h2 id="agent-h">[ AGENT ]</h2>
<p class="muted">The agent lets agent chats on your server work in this computer's projects.
<h2 id="agent-h" data-i18n>Agent</h2>
<p class="muted" data-i18n>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.</p>
<p class="status" id="agent-status" aria-live="polite"></p>
<div class="row row--buttons">
<button type="button" class="btn" id="link">Link this computer</button>
<button type="button" class="btn" id="toggle">Start the agent</button>
<button type="button" class="btn btn--primary" id="open">Open the server</button>
<button type="button" class="btn" id="link" data-i18n>Link this computer</button>
<button type="button" class="btn" id="toggle" data-i18n>Start the agent</button>
<button type="button" class="btn btn--primary" id="open" data-i18n>Open the server</button>
</div>
</section>
<section aria-labelledby="folders-h" id="folders-section" hidden>
<h2 id="folders-h" data-i18n>Project folders</h2>
<p class="muted" data-i18n>Agent chats from your server can work in these folders and anything inside
them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.</p>
<ul class="folders" id="folders"></ul>
<p class="status" id="folders-status" aria-live="polite"></p>
<div class="row row--buttons">
<button type="button" class="btn btn--primary" id="add-folder" data-i18n>Add folder…</button>
</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 class="field">
<span class="label" data-i18n>Terminal</span>
<label class="check"><input type="checkbox" id="terminal"> <span data-i18n>Let the server open a terminal here</span></label>
<p class="hint" data-i18n>A shell as you, in one of these folders.</p>
</div>
</div>
</section>
</main>
+15
View File
@@ -0,0 +1,15 @@
// 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
@@ -0,0 +1,99 @@
/* 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
@@ -0,0 +1,39 @@
<!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
@@ -0,0 +1,66 @@
// 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)
+642
View File
@@ -0,0 +1,642 @@
/* LLeMbas v1.3.0 (61cf4c267c98) 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. */
/*
Design tokens.
Every colour, space and radius in the application resolves through a variable
declared here. Component CSS must never hard-code a hex value -- that is what
makes adding a theme a matter of writing one new block rather than auditing
every stylesheet.
Themes are selected with data-theme on <html>. `moria` is the default and is
declared on :root so the page is styled even before the theme script runs.
*/
:root {
/* --- Type ------------------------------------------------------------- */
/* The chrome -- buttons, navigation, tabs, labels, headings --
is set in `--font-ui`, a monospace face: "Middle-earth on a terminal".
What people *read* at length, a reply or a note, stays in `--font-body`,
because eighty columns of mono prose is tiring and the look is not worth
that. `--font-display` follows the chrome. */
--font-ui: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Code", Menlo,
Consolas, monospace;
--font-display: var(--font-ui);
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", "Fira Code",
"Cascadia Code", Menlo, Consolas, monospace;
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.9375rem;
--text-md: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
--leading-tight: 1.25;
--leading-normal: 1.6;
--leading-relaxed: 1.75;
/* --- Space (4px scale) ------------------------------------------------ */
/* Half a step: the gap between the lines of one tool result, the bleed of a
mention's highlight. The one place a 2px space is allowed to be named. */
--sp-half: 0.125rem;
--sp-1: 0.25rem;
--sp-2: 0.5rem;
--sp-3: 0.75rem;
--sp-4: 1rem;
--sp-5: 1.25rem;
--sp-6: 1.5rem;
--sp-8: 2rem;
--sp-10: 2.5rem;
--sp-12: 3rem;
/* --- Radius & shadow -------------------------------------------------- */
/* Blocks, not pills. Small on purpose: a corner of 2px reads as
square at reading distance and still stops an edge looking cut. Avatars and
dots keep `--radius-full`. */
--radius-sm: 2px;
--radius: 2px;
--radius-md: 3px;
--radius-lg: 4px;
--radius-xl: 6px;
--radius-full: 999px;
/* Square, on purpose: the sidebar's Chats/Agents switch, which has
to read as one solid control rather than as two buttons. */
--radius-none: 0;
/* How far a block button travels when pressed: into its own shadow, which
is drawn exactly this far out. The two are one number on purpose. */
--press: 2px;
/* The load-state dot on a model's avatar in the model menu. Its colours are
tokens of their own, defaulting to the theme's success and warning, so
an instance whose success colour is not green can still say "loaded" in
green -- that is what people read a dot beside a name as. */
--model-state-size: 0.625rem;
--model-state-loaded: var(--success);
--model-state-loading: var(--warning);
/*
--- Controls ----------------------------------------------------------
Every button, input and select resolves its height from these. That is the
whole reason things line up: a row of mixed controls has one height, not
whatever each element's padding and font happened to add up to.
*/
--control-h: 2.25rem;
--control-h-sm: 1.75rem;
--control-h-lg: 2.75rem;
--control-px: 0.75rem;
--control-px-sm: 0.5rem;
/* --- Layout ----------------------------------------------------------- */
--sidebar-width: 17.5rem;
--inspector-width: 24rem;
/* Wider than the inspector because the content is not prose: eighty columns
of --font-mono do not fit in 24rem, and a terminal narrower than eighty
re-wraps everything a program prints. */
--terminal-width: 34rem;
--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;
--header-height: 3.5rem;
/* --- The sizes of things -------------------------------------------------
Caps and floors that used to be a literal rem in seventy places across
three stylesheets -- 20, 22, 24, 26 -- with no way to tell which of them
meant the same thing. A handful of roles instead, and a component takes the
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
`--control-h`; these are the boxes around them. */
--w-field-xs: 8rem; /* a short input: a count, a duration */
--w-field-sm: 10rem; /* a short select's floor */
--w-field: 14rem; /* a capped field, a label column, a small menu */
--w-chip: 16rem; /* a name that truncates: a model, a path */
--w-menu-sm: 20rem; /* a compact popover, an attachment chip */
--w-menu: 24rem; /* a popover, a results list, a card's column */
--w-menu-lg: 28rem; /* a wide popover */
--w-dialog: 34rem; /* a dialog with a list in it */
--w-prose-sm: 32rem; /* a short reading measure: an empty state */
--w-prose: 44rem; /* a reading measure: a lede, a card grid */
--h-list: 22rem; /* a scrolling list or menu */
--h-block: 24rem; /* a scrolling block inside a card */
--h-block-lg: 26rem; /* a reasoning body, a preview */
--h-media: 20rem; /* a picture in the transcript */
/* The live reply's two one-line rows -- the waiting row and the metrics --
keep this height whether or not they have anything in them, so their
content arriving or leaving never moves the transcript. One line of the
small type. */
--row-h: calc(var(--text-xs) * var(--leading-normal) + 2 * var(--sp-2));
/* A dot that marks state: unsaved, unread. */
--dot-size: 0.375rem;
/* The streaming caret: a thin bar the height of a line. */
--caret-bar-w: 0.45rem;
/* The drag handle on a panel's edge: how wide, and how far it overhangs the
panel so the border itself is inside the target. */
--resize-w: 9px;
--resize-overhang: 3px;
/* A faded control: something present but not the point. */
--opacity-dim: 0.45;
/* What `--header-height` does at the top of the shell, this does at the
bottom. The sidebar's footer and the composer sit either side of the same
vertical line and both are content-sized, so without this they end at
different heights -- and neither can be made to match the other by
accident: the footer's height depends on which entries the reader's
permissions allow, and the composer's on how much they have typed.
This began as the fix for a broken *line*: both carried a top border, and
the two met the sidebar's edge at different heights. The borders are gone
now -- an edge that content scrolls under reads better undrawn than drawn
and aligned -- and the token stays, because the two ends of the shell
sitting at different heights is visible without any border to prove it.
A calc of the pieces the footer is actually built from -- four rows at
`--control-h`, the gaps between them, and its own padding -- so it stays
true if those tokens move. Applied as a `min-height` to both: it holds the
footer at full height even for somebody who sees fewer entries, and lifts
the composer to meet it. A composer that grows past this as somebody types
is expected; the input is getting bigger, and nothing is pretending the
sidebar should follow it. */
--footer-height: calc(4 * var(--control-h) + 3 * var(--sp-1) + 2 * var(--sp-2));
/* The terminal's own type. xterm holds this as a number rather than reading
it from CSS, so terminal.js parses it back out -- it must stay a plain
pixel value. */
--terminal-font-size: 13px;
/*
--- Stacking ----------------------------------------------------------
These were four bare numbers scattered across two stylesheets, which is
fine until something new has to sit between two of them and nobody can
say what is already there.
*/
--z-raised: 5;
--z-handle: 10;
--z-dropdown: 30;
--z-panel: 40;
--z-toast: 60;
--transition-fast: 120ms ease;
--transition: 200ms ease;
/* --- Borders -----------------------------------------------------------
A hairline was a literal `1px` in about ninety places, which made it the
largest category of hard-coded value left in the codebase -- and the one
thing a theme cannot currently change. */
--border-w: 1px;
--border-w-thick: 2px;
--border-w-accent: 3px;
/* The focus outline's own width. Not `--border-w-thick`, though they are the
same number today: an outline is drawn outside the box and takes no space,
a border is part of the box and does. Making one of them follow the other
means a theme that wants a heavier border gets a heavier focus ring too,
which is two decisions tied together by a coincidence. */
--outline-w: 2px;
/* How far the focus ring stands off the box it rings. */
--outline-offset: 2px;
/* --- Touch --------------------------------------------------------------
A control a thumb has to hit is 44px. `--control-h` is 2.25rem, which is
36 -- comfortable with a pointer and under every published minimum for a
finger -- so the coarse-pointer block at the foot of this file raises the
control tokens to this rather than patching components one at a time.
Raising the token is the only version that reaches all of them, and it is
what `--control-h` exists for. */
--tap-min: 2.75rem;
/* A tick box, which does not take its size from `--control-h`: the browser
draws it and only `width`/`height` move it. */
--check-size: 1rem;
/* --- Controls as a family -----------------------------------------
The dropdown's caret is a solid ▾ drawn with two gradients rather than a
glyph -- a <select> takes no pseudo-element -- and it sits behind a rule,
`[ value │ ▾ ]`, so a select reads as a field with a handle rather than as a
button. `--caret-gutter` is everything to the right of the value: the rule,
the caret, and the padding between them, so `padding-right` and the
background positions are computed from one number. */
--caret-w: 0.6rem;
--caret-h: 0.3rem;
--caret-gutter: 2.25rem;
/* --- The window's own edges ---------------------------------------------
Installed on a phone, the page runs under the notch and the home
indicator: base.html asks iOS for `black-translucent`, which is what puts
it there, and `viewport-fit=cover` is what lets these resolve to anything
but zero. Declared here so no component spells `env()` out -- and so a
desktop browser, where all four are 0, costs nothing. */
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
/* --- Breakpoints --------------------------------------------------------
A media query cannot read a custom property, so these cannot be *used*
here. They are declared anyway so the numbers have one home and a grep for
one lands somewhere that says what it means -- and
`tests/test_layout_bounds.py` refuses a width in any stylesheet that is not
declared here, so a fourth breakpoint invented in passing fails the suite
rather than joining the set unannounced.
--bp-admin 44rem 704px a two-column reference row stacks
--bp-narrow 48rem 768px the sidebar becomes a drawer, and controls
grow to a thumb's size
--bp-wide 64rem 1024px the right-hand panels become overlays */
--bp-admin: 44rem;
--bp-narrow: 48rem;
--bp-wide: 64rem;
/* --- Motion -------------------------------------------------------------
Durations and curves, so the `prefers-reduced-motion` block at the foot of
this file keeps covering everything by construction: a literal `1.6s` in a
component is a value that block can still neutralise, but one nobody can
tune. `--ease-out` is the one to reach for -- something arriving should
decelerate; `--ease-spring` overshoots slightly and belongs on a thing
that appears, never on a thing that moves under the pointer. */
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0.05, 0.36, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--dur-1: 120ms;
--dur-2: 200ms;
--dur-3: 320ms;
--dur-slow: 1.6s;
/* --- Panel minimums -----------------------------------------------------
`api/preferences.py:LAYOUT_BOUNDS` allows four panels' widths to be stored
against an account and only two of them -- the two with a drag handle --
had a `-min` token or a `min-width` to clamp with. The other two are not
draggable, so nothing in the interface could produce a bad value; but the
endpoint takes one from anybody signed in, `base.html` applies stored
widths to <html> before first paint, and with no clamp a stored 800px
sidebar is one nothing in the application can drag back. */
--sidebar-width-min: 12.5rem;
--inspector-width-min: 17.5rem;
/* The focus treatment, written once. Three components spelled it out. It
resolves --accent-soft at the point of use, so it follows the theme even
though it is declared above them. */
--ring: 0 0 0 3px var(--accent-soft);
}
/*
---------------------------------------------------------------------------
MORIA (default, dark)
The halls under the mountain on a terminal screen: stone-black surfaces kept
near-neutral so the ithildin and mallorn accents carry all the colour, and
the doors themselves drawn in moon-letter pixels on the empty screens.
---------------------------------------------------------------------------
*/
:root,
:root[data-theme="moria"] {
color-scheme: dark;
/* "Ithildin phosphor": stone-black, with the moon-letters' silver
cyan for focus and the primary action and a phosphor leaf for the brand.
Contrast measured, not eyeballed -- ink is 15.7:1 on bg, ink-faint 5.4:1 on
a card, border-strong 3.3:1. */
--bg: #0B0D10;
--bg-sunken: #07090B;
--surface: #11151A;
--surface-raised: #171C22;
--surface-hover: #1D232B;
--surface-active: #232B34;
--border: #26303A;
/* Bright enough to be an edge (3:1 on a card): the blocky look is drawn in
edges, and a 1.9:1 hairline disappears on a dim screen. */
--border-strong: #5C6976;
--ink: #E2E8EC;
--ink-muted: #A6B2BC;
--ink-faint: #808D98;
--ink-inverse: #07090B;
/* Ithildin: the cool primary, focus and interactive accents. */
--accent: #8FD8E8;
--accent-hover: #B4E6F1;
--accent-ink: #061418;
--accent-soft: rgba(143, 216, 232, 0.12);
/* Mallorn: the brand, the assistant's mark, the leaf badges -- phosphor
green on the dark, the colour of an old terminal's text. */
--leaf: #7EE081;
--leaf-hover: #A2EAA3;
--leaf-soft: rgba(126, 224, 129, 0.12);
--danger: #F0735F;
--danger-hover: #F4907F;
--danger-soft: rgba(240, 115, 95, 0.13);
/* Teal, a hue away from the leaf: a dot beside a brand badge has to be
tellable from it at a glance. */
--success: #5FD6B0;
--success-soft: rgba(95, 214, 176, 0.13);
/* Forge amber. */
--warning: #F0A840;
--warning-soft: rgba(240, 168, 64, 0.13);
--bubble-user: #161C23;
--bubble-assistant: transparent;
--code-bg: #07090B;
--code-border: #26303A;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
--shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 12px 34px rgba(0, 0, 0, 0.6);
/* Blocks: a hard offset shadow, `--press` out, that a button presses into.
On the dark the accent one also glows -- ithildin shows by its light. */
--shadow-block: var(--press) var(--press) 0 rgba(0, 0, 0, 0.55);
--shadow-block-accent: var(--press) var(--press) 0
color-mix(in srgb, var(--accent) 28%, transparent),
0 0 22px -6px color-mix(in srgb, var(--accent) 55%, transparent);
--shadow-block-lg: 6px 6px 0 rgba(0, 0, 0, 0.6),
0 0 40px -12px color-mix(in srgb, var(--accent) 30%, transparent);
/* The two mostly-empty screens -- a new chat, signing in -- sit on a
screen: fine scanlines over a grid that fades out from the middle, lit
faintly from the top. Mixed from the accent so a custom theme on this base
gets its own colour. Nowhere else: a conversation is read for hours. */
--texture-screen:
repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.22) 0 1px, transparent 1px 3px),
radial-gradient(ellipse 60% 45% at 50% 18%,
color-mix(in srgb, var(--accent) 10%, transparent), transparent 70%),
radial-gradient(ellipse 75% 65% at 50% 35%, transparent 45%, var(--bg) 92%),
linear-gradient(color-mix(in srgb, var(--accent) 5%, transparent) 1px, transparent 1px)
0 0 / 24px 24px,
linear-gradient(90deg, color-mix(in srgb, var(--accent) 5%, transparent) 1px,
transparent 1px) 0 0 / 24px 24px,
var(--bg);
--glow-title: 0 0 18px color-mix(in srgb, var(--accent) 35%, transparent);
/* The pixel artwork's colours. Each drawing is a set of named roles
(scripts/build_artwork.py); a theme only supplies these. */
--art-leaf: #7EE081;
--art-leaf-dark: #4FAE5A;
--art-leaf-light: #B6F2A6;
--art-vein: #D8FFD0;
--art-line: #8FD8E8;
--art-faint: #4F8794;
--art-star: #EAFCFF;
--art-glow: rgba(143, 216, 232, 0.55);
--scrim: rgba(5, 7, 9, 0.7);
/*
The sixteen ANSI colours, for the terminal panel.
A shell picks its own colours -- `ls --color`, a git diff, htop's meters --
and until these existed it got xterm's defaults, which are a different
palette from this one and read as a foreign window pasted into the
application. Where a slot has an obvious counterpart above it takes it, so
an error in the terminal is the same ember as an error anywhere else.
"black" is not #000: it is what a program picks for a dim background or
faint text, and on a near-black surface a true black is invisible.
*/
--ansi-black: #1A1F26;
--ansi-red: #F0735F;
--ansi-green: #7EE081;
--ansi-yellow: #F0A840;
--ansi-blue: #7AA7E8;
--ansi-magenta: #C79BE0;
--ansi-cyan: #8FD8E8;
--ansi-white: #C6CDD4;
--ansi-bright-black: #5C6976;
--ansi-bright-red: #F4907F;
--ansi-bright-green: #A2EAA3;
--ansi-bright-yellow: #F5C06E;
--ansi-bright-blue: #9CC0F0;
--ansi-bright-magenta: #DBBBEB;
--ansi-bright-cyan: #B4E6F1;
--ansi-bright-white: #E2E8EC;
}
/*
---------------------------------------------------------------------------
SHIRE (light)
Parchment, ink and moss, printed in one bit: warm and easy to read for a
long time, with hard ink edges where Moria glows. Backgrounds are
deliberately off-white -- pure #FFF next to the leaf accent reads as
clinical rather than as paper.
---------------------------------------------------------------------------
*/
/*
`[data-base="shire"]` as well as `[data-theme="shire"]`, and that second
selector is what makes a custom theme possible. A custom theme is a handful of
token overrides served from /branding.css; everything it does not set has to
come from somewhere, and Moria's block above matches bare `:root` so it always
applies. Without this a custom *light* theme would be four light colours on
near-black surfaces. `<html>` carries both attributes -- see base.html and
app.js:applyTheme.
*/
:root[data-theme="shire"],
:root[data-base="shire"] {
color-scheme: light;
/* "1-bit almanac": parchment and ink, a door-green primary, moss
for the brand, brass for warnings -- and ink-black block shadows, which is
what makes it read as printed. Ink is 13.9:1 on bg, ink-faint 5.6:1. */
--bg: #F1EAD6;
--bg-sunken: #E7DEC6;
--surface: #F8F3E5;
--surface-raised: #FCF9F0;
--surface-hover: #ECE4CF;
--surface-active: #E2D8BD;
--border: #CBBF9D;
/* Ink, not a darker parchment: the blocks are drawn in it. */
--border-strong: #2A2E22;
--ink: #1F2418;
--ink-muted: #474D3B;
--ink-faint: #5E6250;
--ink-inverse: #F8F3E5;
/* The round door's green: the primary. */
--accent: #285F3C;
--accent-hover: #1F4D30;
--accent-ink: #F8F3E5;
--accent-soft: rgba(40, 95, 60, 0.1);
/* The same mallorn as moss, dark enough to be text on parchment. */
--leaf: #3D6B1C;
--leaf-hover: #2F5615;
--leaf-soft: rgba(61, 107, 28, 0.1);
--danger: #A3321F;
--danger-hover: #862818;
--danger-soft: rgba(163, 50, 31, 0.1);
--success: #2B6A55;
--success-soft: rgba(43, 106, 85, 0.1);
/* Brass. */
--warning: #7E5200;
--warning-soft: rgba(126, 82, 0, 0.1);
--bubble-user: #ECE4CF;
--bubble-assistant: transparent;
--code-bg: #F1EAD6;
--code-border: #CBBF9D;
--shadow-sm: 0 1px 2px rgba(42, 46, 34, 0.1);
--shadow: 0 4px 14px rgba(42, 46, 34, 0.12);
--shadow-lg: 0 12px 34px rgba(42, 46, 34, 0.18);
/* On paper the block shadow is solid ink: one bit, no blur. */
--shadow-block: var(--press) var(--press) 0 var(--border-strong);
--shadow-block-accent: var(--press) var(--press) 0 var(--border-strong);
--shadow-block-lg: 6px 6px 0 var(--border-strong);
/* A fine dot dither in place of Moria's scanlines, fading at the edges. */
--texture-screen:
radial-gradient(ellipse 80% 70% at 50% 30%, transparent 50%, var(--bg) 95%),
radial-gradient(color-mix(in srgb, var(--ink) 14%, transparent) 0.8px, transparent 1.1px)
0 0 / 6px 6px,
var(--bg);
--glow-title: none;
--art-leaf: #4F8A2A;
--art-leaf-dark: #2F5E1A;
--art-leaf-light: #86B553;
--art-vein: #E9F2C8;
--art-grass: #5D9A3A;
--art-grass-light: #86B553;
--art-grass-dark: #3F7428;
--art-sky: #CFE3E8;
--art-sun: #E8B84A;
--art-frame: #5A4425;
--art-door: #2F6B3E;
--art-door-dark: #24553A;
--art-brass: #E8B84A;
--art-window: #F3D27A;
--art-path: #B89A6A;
--art-flower-a: #D9534F;
--art-flower-b: #F0C94A;
--scrim: rgba(31, 36, 24, 0.4);
/*
The same sixteen on parchment, and the reason they are not the Moria set
lightened: every one of these has to be readable as *text* on the code
background, so the whole palette is darkened rather than brightened.
"bright" therefore means more emphatic here, not lighter -- a light
terminal theme that made bright yellow actually bright would render it
invisible.
*/
--ansi-black: #1F2418;
--ansi-red: #A3321F;
--ansi-green: #3D6B1C;
--ansi-yellow: #7E5200;
--ansi-blue: #2F5D86;
--ansi-magenta: #74336A;
--ansi-cyan: #2B6A55;
--ansi-white: #474D3B;
--ansi-bright-black: #6F6A55;
--ansi-bright-red: #862818;
--ansi-bright-green: #2F5615;
--ansi-bright-yellow: #634000;
--ansi-bright-blue: #244A6C;
--ansi-bright-magenta: #5C2853;
--ansi-bright-cyan: #1F5443;
--ansi-bright-white: #2A2E22;
}
/*
--- Touch -----------------------------------------------------------------
A pointer is precise and a finger is about 9mm across, so the same control
cannot be the right size for both. `--control-h` is 36px, which is comfortable
with a mouse and under every published minimum for a thumb; `--control-h-sm`
is 28px, which is a target most people miss.
Raised here rather than patched per component, because there are upwards of
forty of them and the next one added would be 36px again. `--control-h` is
what every button, input and select resolves its height from, so one block
moves all of them -- which is the reason that token exists.
Two conditions, either of which is enough.
`(pointer: coarse)` is the honest one: it is the input device that decides how
big a target has to be, and a touchscreen laptop at 1440px has the same thumb
as a phone. But a layout below the phone breakpoint is a one-column, drawer-
navigated layout whatever is pointing at it -- there is room for bigger
controls and every reason to use it -- and that half is also the half a
headless browser can be made to prove, which is not nothing: a rule that can
only be checked by holding a phone is a rule that quietly rots.
*/
@media (pointer: coarse), (max-width: 48rem) {
:root {
--control-h: var(--tap-min);
/* 40px, not the 36 a comfortable pointer gets. A `.btn--sm` is a secondary
action, not an unimportant one -- Edit, Enable and Use default are all
`.btn--sm`, and on a phone they are the whole interaction. */
--control-h-sm: 2.5rem;
--control-px: var(--sp-4);
--control-px-sm: var(--sp-3);
/* A native checkbox is 13-16px whatever the surrounding type is, and no
amount of padding on its label changes the box itself. It is the
smallest target in the application on a phone by some margin. */
--check-size: 1.375rem;
}
}
/* Respect a stated preference for reduced motion everywhere, at once. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
/* The motion tokens too, for anything that composes a duration rather than
declaring one -- a `transition: transform var(--dur-3)` is neutralised by
the rule above, but an `animation-delay` built from one is not. */
:root {
--dur-1: 0.01ms;
--dur-2: 0.01ms;
--dur-3: 0.01ms;
--dur-slow: 0.01ms;
--transition-fast: 0.01ms;
--transition: 0.01ms;
--transition-slow: 0.01ms;
}
}