Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf13d49ed6
|
||
|
|
4b5b22c7d4
|
||
|
|
89c769c806
|
||
|
|
a91394f71d
|
||
|
|
3baae016e1
|
||
|
|
3ef0bbaad3
|
||
|
|
ac0d4ce623
|
||
|
|
ec32bca644
|
||
|
|
5a87eec28d
|
||
|
|
10c82a3c7b
|
||
|
|
3510582c8f
|
||
|
|
ede91887f7
|
||
|
|
6bad293b29
|
No files matched your search
@@ -1,7 +1,8 @@
|
|||||||
# Checks on every push to main and dev and every pull request: formatting, lints, tests, the
|
# Checks on every push to main and dev and every pull request: formatting, lints, tests, the
|
||||||
# page's scripts, the pins (tokens, CodeMirror), the public-repository hygiene -- and the app
|
# page's scripts, the pins (tokens, CodeMirror, xterm), the public-repository hygiene, the page
|
||||||
# started for real, on X11 and on Wayland, to prove it gets as far as its window. The packages
|
# itself under a headless browser (tests/ui) -- and the app started for real, on X11 and on
|
||||||
# are built only by release.yml, from a tag.
|
# Wayland, to prove it gets as far as its window. The packages are built only by release.yml,
|
||||||
|
# from a tag.
|
||||||
name: ci
|
name: ci
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -40,11 +41,32 @@ jobs:
|
|||||||
run: cargo test --locked
|
run: cargo test --locked
|
||||||
- name: The page's scripts parse, and none sets HTML
|
- name: The page's scripts parse, and none sets HTML
|
||||||
run: scripts/check-ui.sh
|
run: scripts/check-ui.sh
|
||||||
- name: The design tokens and CodeMirror are the server's, as pinned
|
- name: The design tokens, CodeMirror and xterm are the server's, as pinned
|
||||||
run: |
|
run: |
|
||||||
scripts/fetch-tokens.sh
|
scripts/fetch-tokens.sh
|
||||||
scripts/fetch-vendor.sh
|
scripts/fetch-vendor.sh
|
||||||
|
|
||||||
|
# The page (ui/) under a headless Chromium, against a stand-in for the app's bridge
|
||||||
|
# (tests/ui/mock-tauri.js) with the server's answers as a table: nothing is built and no server
|
||||||
|
# is started. Playwright is the library only, installed for the run and not a dependency of
|
||||||
|
# the repository (it has no package.json), with the Chromium it was made for.
|
||||||
|
ui:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: Playwright and its Chromium
|
||||||
|
run: |
|
||||||
|
npm install --no-save --no-package-lock --prefix "$RUNNER_TEMP/pw" playwright-core@1.65.0
|
||||||
|
node "$RUNNER_TEMP/pw/node_modules/playwright-core/cli.js" install --with-deps chromium
|
||||||
|
- name: The page under a headless browser
|
||||||
|
env:
|
||||||
|
NODE_PATH: ${{ runner.temp }}/pw/node_modules
|
||||||
|
run: node tests/ui/run.mjs
|
||||||
|
|
||||||
# The app started for real: it must still be running after 25 seconds (timeout's 124), and its
|
# The app started for real: it must still be running after 25 seconds (timeout's 124), and its
|
||||||
# log must not say it stopped. Once under Xvfb (X11) and once under a headless Weston (Wayland,
|
# log must not say it stopped. Once under Xvfb (X11) and once under a headless Weston (Wayland,
|
||||||
# GTK's Wayland backend forced), each with a session bus (the tray and the notifications want
|
# GTK's Wayland backend forced), each with a session bus (the tray and the notifications want
|
||||||
|
|||||||
+82
-1
@@ -4,7 +4,88 @@ All notable changes to LLeMbas Desktop. The format follows
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||||
[Semantic Versioning](https://semver.org/).
|
[Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.0.0-beta.6] — 2026-11-10
|
||||||
|
|
||||||
|
LLeMbas CLI 2.0.0-beta.6 inside, the look and words of LLeMbas 2.0.0-beta.8.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Approvals show again.** An agent asking to run a command, edit a file or carry out a plan
|
||||||
|
stopped with "Waiting for you to allow bash…" and no card to answer it: drawing the card's
|
||||||
|
buttons failed, and the space for it stayed empty. Stopping and asking again gave the same.
|
||||||
|
Now the card shows, with the command in the box to correct before it runs (it used to show the
|
||||||
|
words "Run a command" there instead), what the model says it is for, why it is asking, and which
|
||||||
|
subagent asks when one does. A card already on the screen is kept as it is while the reply goes
|
||||||
|
on, so a command being corrected or an option picked is no longer lost when the server sends
|
||||||
|
the list again; a card that cannot be drawn says why instead of leaving an empty space.
|
||||||
|
- **Subagents after they are switched on.** The app read which models a new chat is offered
|
||||||
|
subagents on only when it signed in, so subagents switched on (or a model added) on the server
|
||||||
|
while the app stayed open in the tray never showed on a new chat until it was restarted. A new
|
||||||
|
chat now asks the server again. (An agent chat has no Subagents setting: its subagents run on
|
||||||
|
the computer, and the server offers none for it.)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Terminal.** An agent chat has a Terminal panel: a shell on the computer the chat works on, in
|
||||||
|
its folder, beside the chat (over it in a narrow window). A new agent chat offers it as soon as
|
||||||
|
the folder is chosen, before the first message -- and the chat that message makes carries on in
|
||||||
|
the same shell. An open agent chat opens its own from the header, the same shell the web
|
||||||
|
interface shows. **Send to chat** puts the selection, or the last command and its output, into
|
||||||
|
the message box to read before sending; the panel copies them too, and steps between commands
|
||||||
|
where the shell marks them. The terminal and the activity panel take turns beside the chat;
|
||||||
|
closing either keeps the shell. Offered where the computer allows a terminal (needs the server's
|
||||||
|
2.0.0-beta.8). The app opens the connection itself, with its sign-in: the page never holds it.
|
||||||
|
"Continue in a terminal" is now in the chat's menu only.
|
||||||
|
- **What a reply is doing, the same way everywhere.** The line under a reply being written now
|
||||||
|
says it in the words every LLeMbas client uses (harness spec 3.4.0): queued, waiting for a
|
||||||
|
model and why, loading it, reading the prompt (with how far), thinking, writing (with its
|
||||||
|
speed), running a tool and what it runs, waiting for you, waiting for subagents, compacting,
|
||||||
|
retrying (counting down), stopped or failed -- each with how long it has lasted, kept moving
|
||||||
|
every second. From a server that sends no such line (before 2.0.0-beta.8), the app shows the
|
||||||
|
server's own words as before. In every language the server has.
|
||||||
|
- **Subagents, the whole tree** (harness spec 3.4.0, needs the server's 2.0.0-beta.8). The
|
||||||
|
activity panel shows the main agent and every subagent under the one it reports to: its name and
|
||||||
|
id, its position, what it is doing right now, how full its context is, its state, model and
|
||||||
|
mode (with what it may ask for), its messages in the room and the last one, how many slots are
|
||||||
|
still open, and the ones that ended -- dimmed, with how they ended. Any subagent's conversation
|
||||||
|
opens read-only, live while it works and after it ended, and so do its findings; a working one
|
||||||
|
can be stopped (it and those under it). An agent chat's tree comes from its computer; while that
|
||||||
|
computer is away the panel shows the tree as it last was and says the rest is kept there.
|
||||||
|
- **Subagents for agent chats.** A new agent chat offers the Subagents setting where the server
|
||||||
|
says this computer can run them, and an open agent chat where the server offers it for that
|
||||||
|
chat.
|
||||||
|
- **A subagent's approval names it**: "Subagent parser (s1), reviews the parser, asks:".
|
||||||
|
- `tests/ui/`: the page under a headless browser against a stand-in for the app's bridge, with
|
||||||
|
the server's answers as a table (`node tests/ui/run.mjs`, needs Playwright and a Chromium). CI
|
||||||
|
runs it on every push (the `ui` job), with Playwright and its Chromium installed for the run.
|
||||||
|
|
||||||
|
## [1.0.0-beta.5] — 2026-11-09
|
||||||
|
|
||||||
|
Carries LLeMbas CLI 2.0.0-beta.5 and the look of LLeMbas 2.0.0-beta.7, which the Subagents
|
||||||
|
setting and the panel's compaction need.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Subagents**, by that name everywhere. The chip under the message box says **Subagents** with the
|
||||||
|
least and the most agents a chat runs ("Subagents 1–4"), and opens one slider with two handles,
|
||||||
|
**Min** and **Max**, from 1 to the server's cap -- Min never passes Max, and both move with the
|
||||||
|
arrow keys as well. The numbers count the chat's own agent: **1 is the agent alone**, with no
|
||||||
|
subagents; Max 4 is the agent and up to 3 subagents. The chip shows where the
|
||||||
|
server offers subagents for the chat (for a new chat, for its model), and the most is the
|
||||||
|
server's cap. Where the chat's model serves one request at a time, it says that subagents need
|
||||||
|
another model, on another server, named in the chat. Subagents work on the chat's own model
|
||||||
|
unless you name another one in the chat -- there is no setting for a subagent's model. A
|
||||||
|
scheduled task's field is **Subagents** too. Needs LLeMbas 2.0.0-beta.7 for the server's word on
|
||||||
|
all of this; with an older one the app guesses as before.
|
||||||
|
- **The activity panel, reshaped.** A button in the chat's header opens it at any time, and the
|
||||||
|
line above the message box shows only while something runs ("3 running · 1 queued · 2
|
||||||
|
finished"). An agent chat's **Environment** (its folder, and where it is) comes first. **Context**
|
||||||
|
says how far it is to the automatic compaction ("80.6k until auto-compact") beside a **Compact**
|
||||||
|
button that summarises the earlier turns now (greyed while there is nothing to summarise). **Background tasks** lists subagents, a team's
|
||||||
|
members and background commands under **Running**, each with how long it has run and a stop
|
||||||
|
square, unfolding to what it is, its latest step, what it is writing and its conversation; the
|
||||||
|
tasks under one fold beneath it ("Tasks: 2"), and the finished ones fold away under **Finished**.
|
||||||
|
The team's **Room** and the **Plan** have sections of their own.
|
||||||
|
|
||||||
## [1.0.0-beta.4] — 2026-11-09
|
## [1.0.0-beta.4] — 2026-11-09
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2.0.0-beta.3
|
2.0.0-beta.6
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copy the server's /api/v2 contract fixtures that tests/ui reads into tests/ui/fixtures/:
|
||||||
|
#
|
||||||
|
# scripts/fetch-fixtures.sh <server-ref> (a tag such as v2.0.0-beta.8, or a branch)
|
||||||
|
#
|
||||||
|
# The server is a git checkout, LLeMbas beside this repository by default (LEMBAS_SERVER names
|
||||||
|
# another); the files are read from the ref, not from its working tree. SOURCE records the ref and
|
||||||
|
# the commit, so the tests say which server they hold the app to. CI has no server checkout: the
|
||||||
|
# copies are committed.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
SERVER="${LEMBAS_SERVER:-../LLeMbas}"
|
||||||
|
FROM="tests/fixtures/api_v2"
|
||||||
|
OUT="tests/ui/fixtures"
|
||||||
|
FILES="chat_agent.json event_ask.json event_status.json event_status_idle.json event_status_person.json
|
||||||
|
event_status_stopped.json event_tree.json models_agent.json subagent_findings.json subagent_transcript.json tree.json"
|
||||||
|
|
||||||
|
[ $# -eq 1 ] || { echo "usage: scripts/fetch-fixtures.sh <server-ref>" >&2; exit 2; }
|
||||||
|
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \
|
||||||
|
|| { echo "fetch-fixtures: $1 is not a commit in $SERVER" >&2; exit 1; }
|
||||||
|
mkdir -p "$OUT"
|
||||||
|
for f in $FILES; do
|
||||||
|
git -C "$SERVER" show "$commit:$FROM/$f" > "$OUT/$f" || { echo "fetch-fixtures: $f is not at $1" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
printf 'LLeMbas %s (%s) %s\n' "$1" "$commit" "$FROM" > "$OUT/SOURCE"
|
||||||
|
echo "fetch-fixtures: $(echo $FILES | wc -w) files from LLeMbas $1 (${commit:0:12}) -> $OUT"
|
||||||
+16
-4
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Put the server's vendored CodeMirror build where the app's own pages read it from:
|
# Put the server's vendored CodeMirror build and xterm where the app's own pages read them from:
|
||||||
# ui/vendor/*.mjs (copies of the server's static/vendor/cm-*.mjs and lezer-*.mjs)
|
# ui/vendor/*.mjs (copies of the server's static/vendor/cm-*.mjs and lezer-*.mjs)
|
||||||
|
# ui/vendor/xterm.js, xterm.css, xterm-addon-fit.js (the terminal panel's, copied unchanged:
|
||||||
|
# classic scripts the panel loads when it is first opened)
|
||||||
#
|
#
|
||||||
# scripts/fetch-vendor.sh <server-ref> copy the modules at that ref (a tag such as
|
# scripts/fetch-vendor.sh <server-ref> copy the modules at that ref (a tag such as
|
||||||
# v2.0.0-beta.1, or a branch) and write vendor.lock.json
|
# v2.0.0-beta.1, or a branch) and write vendor.lock.json
|
||||||
@@ -47,8 +49,8 @@ if [ $# -eq 0 ]; then
|
|||||||
echo "fetch-vendor: $OUT/$name does not match $LOCK (server $ref)" >&2
|
echo "fetch-vendor: $OUT/$name does not match $LOCK (server $ref)" >&2
|
||||||
bad=1
|
bad=1
|
||||||
fi
|
fi
|
||||||
done < <(sed -n -E 's/^ *"([a-z0-9-]+\.mjs)": *\{"server": *"[0-9a-f]{64}", *"copy": *"([0-9a-f]{64})"\}.*/\1\t\2/p' "$LOCK")
|
done < <(sed -n -E 's/^ *"([a-z0-9-]+\.(mjs|js|css))": *\{"server": *"[0-9a-f]{64}", *"copy": *"([0-9a-f]{64})"\}.*/\1\t\3/p' "$LOCK")
|
||||||
for f in "$OUT"/*.mjs; do
|
for f in "$OUT"/*; do
|
||||||
grep -q "\"$(basename "$f")\"" "$LOCK" || { echo "fetch-vendor: $f is not in $LOCK" >&2; bad=1; }
|
grep -q "\"$(basename "$f")\"" "$LOCK" || { echo "fetch-vendor: $f is not in $LOCK" >&2; bad=1; }
|
||||||
done
|
done
|
||||||
[ "$count" -gt 0 ] || { echo "fetch-vendor: $LOCK lists no file" >&2; exit 1; }
|
[ "$count" -gt 0 ] || { echo "fetch-vendor: $LOCK lists no file" >&2; exit 1; }
|
||||||
@@ -87,6 +89,16 @@ for name in $names; do
|
|||||||
fi
|
fi
|
||||||
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$(sha "$work/out/$name")")")
|
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$(sha "$work/out/$name")")")
|
||||||
done
|
done
|
||||||
|
# xterm and its fit addon, for the terminal panel: copied as they are, each checked against the
|
||||||
|
# server's pin like the modules.
|
||||||
|
for name in xterm.js xterm.css xterm-addon-fit.js; do
|
||||||
|
git -C "$SERVER" show "$commit:$SOURCE/$name" > "$work/out/$name" \
|
||||||
|
|| { echo "fetch-vendor: $name is not in $SOURCE at $REF" >&2; exit 1; }
|
||||||
|
pinned="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], {}).get("sha256", ""))' "$work/server-lock.json" "$name")"
|
||||||
|
have="$(sha "$work/out/$name")"
|
||||||
|
[ "$pinned" = "$have" ] || { echo "fetch-vendor: $name does not match the server's pin ($have, pinned $pinned)" >&2; exit 1; }
|
||||||
|
entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$have")")
|
||||||
|
done
|
||||||
# The polyfill stub, from the server's own scripts (it is not a download, so it has no pin there).
|
# The polyfill stub, from the server's own scripts (it is not a download, so it has no pin there).
|
||||||
stub="src/lembas/web/static/js/node-process.mjs"
|
stub="src/lembas/web/static/js/node-process.mjs"
|
||||||
git -C "$SERVER" show "$commit:$stub" > "$work/out/node-process.mjs" \
|
git -C "$SERVER" show "$commit:$stub" > "$work/out/node-process.mjs" \
|
||||||
@@ -101,7 +113,7 @@ done
|
|||||||
|
|
||||||
rm -rf "$OUT"
|
rm -rf "$OUT"
|
||||||
mkdir -p "$OUT"
|
mkdir -p "$OUT"
|
||||||
cp "$work"/out/*.mjs "$OUT/"
|
cp "$work"/out/*.mjs "$work"/out/xterm.js "$work"/out/xterm.css "$work"/out/xterm-addon-fit.js "$OUT/"
|
||||||
{
|
{
|
||||||
printf '{\n "tag": "%s",\n "commit": "%s",\n "files": {\n' "$REF" "$commit"
|
printf '{\n "tag": "%s",\n "commit": "%s",\n "files": {\n' "$REF" "$commit"
|
||||||
last=$((${#entries[@]} - 1))
|
last=$((${#entries[@]} - 1))
|
||||||
|
|||||||
+323
-36
@@ -61,6 +61,8 @@ KEYS = (
|
|||||||
"The agent asks to run %(tool)s:",
|
"The agent asks to run %(tool)s:",
|
||||||
"The agent asks:",
|
"The agent asks:",
|
||||||
"Command",
|
"Command",
|
||||||
|
"%(job)s asks:",
|
||||||
|
"It says: %(purpose)s",
|
||||||
"Recommended",
|
"Recommended",
|
||||||
"Or type an answer",
|
"Or type an answer",
|
||||||
"Your answer",
|
"Your answer",
|
||||||
@@ -194,10 +196,8 @@ KEYS = (
|
|||||||
"off",
|
"off",
|
||||||
"default",
|
"default",
|
||||||
"Think before answering",
|
"Think before answering",
|
||||||
"Fewest",
|
"Min",
|
||||||
"Most",
|
"Max",
|
||||||
"Agents",
|
|
||||||
"Its own agent included. Above one, it can add members that do the work while it leads them.",
|
|
||||||
"Continue in a terminal…",
|
"Continue in a terminal…",
|
||||||
"Continue in a terminal",
|
"Continue in a terminal",
|
||||||
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.",
|
"Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.",
|
||||||
@@ -207,8 +207,6 @@ KEYS = (
|
|||||||
"Back",
|
"Back",
|
||||||
"Open the activity panel",
|
"Open the activity panel",
|
||||||
"Close the activity panel",
|
"Close the activity panel",
|
||||||
"1 agent working",
|
|
||||||
"%(n)s agents working",
|
|
||||||
"%(n)s queued",
|
"%(n)s queued",
|
||||||
"%(n)s waiting",
|
"%(n)s waiting",
|
||||||
"%(n)s finished",
|
"%(n)s finished",
|
||||||
@@ -240,9 +238,7 @@ KEYS = (
|
|||||||
"Estimated: no reply has been counted yet.",
|
"Estimated: no reply has been counted yet.",
|
||||||
"%(n)s tools offered.",
|
"%(n)s tools offered.",
|
||||||
"No files written in this chat yet.",
|
"No files written in this chat yet.",
|
||||||
"Nothing runs in the background. Helpers, commands and a team's members show here while they work.",
|
|
||||||
"Nothing running.",
|
"Nothing running.",
|
||||||
"Team: %(size)s of %(max)s agents",
|
|
||||||
"Show %(n)s more",
|
"Show %(n)s more",
|
||||||
"Room",
|
"Room",
|
||||||
"report",
|
"report",
|
||||||
@@ -323,8 +319,6 @@ KEYS = (
|
|||||||
"A busy model is waited for, then the next free one is used.",
|
"A busy model is waited for, then the next free one is used.",
|
||||||
"A result, but not a run with nothing to report.",
|
"A result, but not a run with nothing to report.",
|
||||||
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.",
|
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.",
|
||||||
"A team of up to %(n)s",
|
|
||||||
"A team works on it together, its own agent included.",
|
|
||||||
"Choose a model to choose its effort.",
|
"Choose a model to choose its effort.",
|
||||||
"Due %(when)s",
|
"Due %(when)s",
|
||||||
"Each run sees the ones before it.",
|
"Each run sees the ones before it.",
|
||||||
@@ -333,7 +327,6 @@ KEYS = (
|
|||||||
"Edit %(title)s",
|
"Edit %(title)s",
|
||||||
"Every result, even with nothing to report.",
|
"Every result, even with nothing to report.",
|
||||||
"Models for scheduled tasks",
|
"Models for scheduled tasks",
|
||||||
"No team",
|
|
||||||
"Only in its history.",
|
"Only in its history.",
|
||||||
"Open the chat",
|
"Open the chat",
|
||||||
"Result",
|
"Result",
|
||||||
@@ -358,6 +351,100 @@ KEYS = (
|
|||||||
"Open its chat",
|
"Open its chat",
|
||||||
"The model's own",
|
"The model's own",
|
||||||
"run by hand",
|
"run by hand",
|
||||||
|
# activity.js, chat.js, models.js, scheduled.js: subagents, the panel, Compact
|
||||||
|
"%(n)s running",
|
||||||
|
"Tasks: %(n)s",
|
||||||
|
"%(tokens)s until auto-compact",
|
||||||
|
"Automatic compaction is off.",
|
||||||
|
"Compacts before the next reply.",
|
||||||
|
"Compacts by itself once the window is known.",
|
||||||
|
"Compact",
|
||||||
|
"Compacting…",
|
||||||
|
"Summarise the earlier turns now and send the summary in their place.",
|
||||||
|
"This server cannot compact a chat from the app yet; the web interface can.",
|
||||||
|
"Context and background tasks",
|
||||||
|
"Environment",
|
||||||
|
"Finished %(n)s",
|
||||||
|
"Background command",
|
||||||
|
"Subagent",
|
||||||
|
"Team member",
|
||||||
|
"Subagents",
|
||||||
|
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.",
|
||||||
|
"This model serves one request at a time: subagents need another model, on another server, named in the chat.",
|
||||||
|
"None",
|
||||||
|
"Up to %(n)s at once",
|
||||||
|
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.",
|
||||||
|
"Team: %(size)s of at most %(max)s subagents",
|
||||||
|
"How many work on it at once, the run's own agent among them, on the same model.",
|
||||||
|
# terminal.js
|
||||||
|
"Terminal",
|
||||||
|
"Close terminal",
|
||||||
|
"Send to chat",
|
||||||
|
"Connecting…",
|
||||||
|
"Connected.",
|
||||||
|
"Connected. This shell is also open in another tab, and they share a size.",
|
||||||
|
"Disconnected. Close and reopen to reconnect.",
|
||||||
|
"This terminal closed.",
|
||||||
|
"Reconnecting…",
|
||||||
|
"The terminal could not be loaded.",
|
||||||
|
"Nothing has been run in this shell yet.",
|
||||||
|
"Nothing to send: select some output first.",
|
||||||
|
"Copied the last of the screen, as it appeared. This shell does not mark where commands begin.",
|
||||||
|
"Copied.",
|
||||||
|
"The previous command",
|
||||||
|
"The previous command (Ctrl+Shift+↑)",
|
||||||
|
"The next command",
|
||||||
|
"The next command (Ctrl+Shift+↓)",
|
||||||
|
"Copy the last command and its output",
|
||||||
|
"Put the last command and its output into the message box",
|
||||||
|
"Sign in again: the server no longer accepts this app's sign-in.",
|
||||||
|
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.",
|
||||||
|
# progress.js (harness spec 3.4.0: loop.json progress.texts, keyed as the spec writes them)
|
||||||
|
"Queued",
|
||||||
|
"Queued: {{ahead}} ahead",
|
||||||
|
"Waiting for {{model}}: {{reason}}",
|
||||||
|
"Loading {{model}}",
|
||||||
|
"Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)",
|
||||||
|
"Reading the prompt",
|
||||||
|
"Writing: {{rate}} tok/s",
|
||||||
|
"Writing",
|
||||||
|
"Running {{tool}}: {{what}}",
|
||||||
|
"Running {{tool}}",
|
||||||
|
"Waiting for you: {{what}}",
|
||||||
|
"Waiting for subagents: {{running}} running",
|
||||||
|
"Compacting the conversation",
|
||||||
|
"Retrying in {{in}} s: {{reason}}",
|
||||||
|
"Stopped",
|
||||||
|
"Stopped: {{reason}}",
|
||||||
|
"Failed: {{reason}}",
|
||||||
|
"it serves one request at a time and is answering another",
|
||||||
|
"all its slots are in use",
|
||||||
|
"its server holds another model now",
|
||||||
|
"allow {{tool}}?",
|
||||||
|
"an approval",
|
||||||
|
"a question",
|
||||||
|
"the plan",
|
||||||
|
"by you",
|
||||||
|
"step limit",
|
||||||
|
"budget",
|
||||||
|
# tree.js, activity.js (the subagent tree), chat.js (a subagent's approval)
|
||||||
|
"waiting for a slot",
|
||||||
|
"finished",
|
||||||
|
"idle",
|
||||||
|
"paused: context full",
|
||||||
|
"out of context",
|
||||||
|
"the main agent",
|
||||||
|
"%(size)s of %(max)s",
|
||||||
|
"Open slots: %(n)s",
|
||||||
|
"They run on the computer this chat runs on.",
|
||||||
|
"Ended",
|
||||||
|
"Context used",
|
||||||
|
"messages: %(n)s",
|
||||||
|
"findings: %(n)s",
|
||||||
|
"Findings",
|
||||||
|
"Stop %(name)s and the subagents under it?",
|
||||||
|
"This device is not linked right now.",
|
||||||
|
"Subagent %(name)s (%(id)s), %(position)s, asks:",
|
||||||
)
|
)
|
||||||
|
|
||||||
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
|
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
|
||||||
@@ -365,6 +452,34 @@ KEYS = (
|
|||||||
# the device, as the server's catalogue has it.
|
# the device, as the server's catalogue has it.
|
||||||
OWN: dict[str, dict[str, str]] = {
|
OWN: dict[str, dict[str, str]] = {
|
||||||
"cs": {
|
"cs": {
|
||||||
|
"Sign in again: the server no longer accepts this app's sign-in.": "Přihlaste se znovu: server už nepřijímá přihlášení této aplikace.",
|
||||||
|
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminál potřebuje novější server LLeMbas. Požádejte jeho správce o aktualizaci.",
|
||||||
|
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Tento model vyřizuje najednou jeden požadavek: podagenti potřebují jiný model na jiném serveru, pojmenovaný v konverzaci.",
|
||||||
|
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "Čísla počítají i vlastního agenta konverzace: 1 je agent sám, bez podagentů; Max 4 je agent a až 3 podagenti. Podagent běží na modelu konverzace, na jiném jen tehdy, když ho v konverzaci pojmenujete.",
|
||||||
|
"Min": "Min",
|
||||||
|
"Max": "Max",
|
||||||
|
"Tasks: %(n)s": "Úlohy: %(n)s",
|
||||||
|
"%(n)s running": "Běží: %(n)s",
|
||||||
|
"%(tokens)s until auto-compact": "%(tokens)s do automatického shrnutí",
|
||||||
|
"Automatic compaction is off.": "Automatické shrnutí je vypnuté.",
|
||||||
|
"Compacts before the next reply.": "Shrne se před další odpovědí.",
|
||||||
|
"Compacts by itself once the window is known.": "Shrne se samo, jakmile bude známé okno.",
|
||||||
|
"Compact": "Shrnout",
|
||||||
|
"Compacting…": "Shrnuje se…",
|
||||||
|
"Summarise the earlier turns now and send the summary in their place.": "Shrnout dřívější kola hned a posílat místo nich shrnutí.",
|
||||||
|
"This server cannot compact a chat from the app yet; the web interface can.": "Tento server zatím neumí shrnout konverzaci z aplikace; webové rozhraní to umí.",
|
||||||
|
"Context and background tasks": "Kontext a úlohy na pozadí",
|
||||||
|
"Environment": "Prostředí",
|
||||||
|
"Finished %(n)s": "Dokončeno: %(n)s",
|
||||||
|
"Background command": "Příkaz na pozadí",
|
||||||
|
"Subagent": "Podagent",
|
||||||
|
"Team member": "Člen týmu",
|
||||||
|
"Subagents": "Podagenti",
|
||||||
|
"None": "Žádní",
|
||||||
|
"Up to %(n)s at once": "Nejvýše %(n)s současně",
|
||||||
|
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Na pozadí nic neběží. Podagenti, členové týmu a příkazy na pozadí se tu zobrazí, dokud pracují.",
|
||||||
|
"Team: %(size)s of at most %(max)s subagents": "Tým: %(size)s z nejvýše %(max)s podagentů",
|
||||||
|
"How many work on it at once, the run's own agent among them, on the same model.": "Kolik jich na ní pracuje současně, včetně vlastního agenta běhu, na stejném modelu.",
|
||||||
"Menu": "Nabídka",
|
"Menu": "Nabídka",
|
||||||
"Minimise": "Minimalizovat",
|
"Minimise": "Minimalizovat",
|
||||||
"Maximise": "Maximalizovat",
|
"Maximise": "Maximalizovat",
|
||||||
@@ -532,8 +647,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"A busy model is waited for, then the next free one is used.": "Na zaneprázdněný model se čeká, pak se použije další volný.",
|
"A busy model is waited for, then the next free one is used.": "Na zaneprázdněný model se čeká, pak se použije další volný.",
|
||||||
"A result, but not a run with nothing to report.": "Výsledek, ale ne spuštění, které nemá co hlásit.",
|
"A result, but not a run with nothing to report.": "Výsledek, ale ne spuštění, které nemá co hlásit.",
|
||||||
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Úloha bez vlastního modelu běží na prvním volném z těchto, v tomto pořadí. Žádný nezaškrtnutý: všechny.",
|
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Úloha bez vlastního modelu běží na prvním volném z těchto, v tomto pořadí. Žádný nezaškrtnutý: všechny.",
|
||||||
"A team of up to %(n)s": "Tým: nejvýše %(n)s",
|
|
||||||
"A team works on it together, its own agent included.": "Pracuje na ní tým společně, včetně jejího vlastního agenta.",
|
|
||||||
"Choose a model to choose its effort.": "Vyberte model, abyste mohli zvolit jeho náročnost.",
|
"Choose a model to choose its effort.": "Vyberte model, abyste mohli zvolit jeho náročnost.",
|
||||||
"Due %(when)s": "Naplánováno na %(when)s",
|
"Due %(when)s": "Naplánováno na %(when)s",
|
||||||
"Each run sees the ones before it.": "Každé spuštění vidí ta předchozí.",
|
"Each run sees the ones before it.": "Každé spuštění vidí ta předchozí.",
|
||||||
@@ -542,7 +655,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Edit %(title)s": "Upravit %(title)s",
|
"Edit %(title)s": "Upravit %(title)s",
|
||||||
"Every result, even with nothing to report.": "Každý výsledek, i když není co hlásit.",
|
"Every result, even with nothing to report.": "Každý výsledek, i když není co hlásit.",
|
||||||
"Models for scheduled tasks": "Modely pro naplánované úlohy",
|
"Models for scheduled tasks": "Modely pro naplánované úlohy",
|
||||||
"No team": "Bez týmu",
|
|
||||||
"Only in its history.": "Jen v její historii.",
|
"Only in its history.": "Jen v její historii.",
|
||||||
"Open the chat": "Otevřít konverzaci",
|
"Open the chat": "Otevřít konverzaci",
|
||||||
"Result": "Výsledek",
|
"Result": "Výsledek",
|
||||||
@@ -563,6 +675,34 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"No day ticked: every day.": "Žádný den nezaškrtnutý: každý den.",
|
"No day ticked: every day.": "Žádný den nezaškrtnutý: každý den.",
|
||||||
},
|
},
|
||||||
"de": {
|
"de": {
|
||||||
|
"Sign in again: the server no longer accepts this app's sign-in.": "Melden Sie sich erneut an: Der Server nimmt die Anmeldung dieser App nicht mehr an.",
|
||||||
|
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Das Terminal braucht einen neueren LLeMbas-Server. Bitten Sie seinen Administrator, ihn zu aktualisieren.",
|
||||||
|
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Dieses Modell bedient eine Anfrage zur Zeit: Subagenten brauchen ein anderes Modell auf einem anderen Server, im Chat genannt.",
|
||||||
|
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "Die Zahlen zählen den eigenen Agenten des Chats mit: 1 ist der Agent allein, ohne Subagenten; Max 4 ist der Agent und bis zu 3 Subagenten. Ein Subagent läuft auf dem Modell des Chats, auf einem anderen nur, wenn Sie es im Chat nennen.",
|
||||||
|
"Min": "Min",
|
||||||
|
"Max": "Max",
|
||||||
|
"Tasks: %(n)s": "Aufgaben: %(n)s",
|
||||||
|
"%(n)s running": "%(n)s laufen",
|
||||||
|
"%(tokens)s until auto-compact": "%(tokens)s bis zur automatischen Zusammenfassung",
|
||||||
|
"Automatic compaction is off.": "Die automatische Zusammenfassung ist aus.",
|
||||||
|
"Compacts before the next reply.": "Wird vor der nächsten Antwort zusammengefasst.",
|
||||||
|
"Compacts by itself once the window is known.": "Wird von selbst zusammengefasst, sobald das Fenster bekannt ist.",
|
||||||
|
"Compact": "Zusammenfassen",
|
||||||
|
"Compacting…": "Wird zusammengefasst…",
|
||||||
|
"Summarise the earlier turns now and send the summary in their place.": "Die früheren Runden jetzt zusammenfassen und stattdessen die Zusammenfassung senden.",
|
||||||
|
"This server cannot compact a chat from the app yet; the web interface can.": "Dieser Server kann einen Chat noch nicht aus der App zusammenfassen; die Weboberfläche kann es.",
|
||||||
|
"Context and background tasks": "Kontext und Hintergrundaufgaben",
|
||||||
|
"Environment": "Umgebung",
|
||||||
|
"Finished %(n)s": "Fertig: %(n)s",
|
||||||
|
"Background command": "Hintergrundbefehl",
|
||||||
|
"Subagent": "Subagent",
|
||||||
|
"Team member": "Teammitglied",
|
||||||
|
"Subagents": "Subagenten",
|
||||||
|
"None": "Keine",
|
||||||
|
"Up to %(n)s at once": "Bis zu %(n)s gleichzeitig",
|
||||||
|
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Im Hintergrund läuft nichts. Subagenten, die Mitglieder eines Teams und Hintergrundbefehle erscheinen hier, solange sie arbeiten.",
|
||||||
|
"Team: %(size)s of at most %(max)s subagents": "Team: %(size)s von höchstens %(max)s Subagenten",
|
||||||
|
"How many work on it at once, the run's own agent among them, on the same model.": "Wie viele gleichzeitig daran arbeiten, der eigene Agent des Laufs mitgezählt, mit demselben Modell.",
|
||||||
"Menu": "Menü",
|
"Menu": "Menü",
|
||||||
"Minimise": "Minimieren",
|
"Minimise": "Minimieren",
|
||||||
"Maximise": "Maximieren",
|
"Maximise": "Maximieren",
|
||||||
@@ -730,8 +870,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"A busy model is waited for, then the next free one is used.": "Auf ein belegtes Modell wird gewartet, dann wird das nächste freie genommen.",
|
"A busy model is waited for, then the next free one is used.": "Auf ein belegtes Modell wird gewartet, dann wird das nächste freie genommen.",
|
||||||
"A result, but not a run with nothing to report.": "Ein Ergebnis, aber kein Lauf ohne Neuigkeiten.",
|
"A result, but not a run with nothing to report.": "Ein Ergebnis, aber kein Lauf ohne Neuigkeiten.",
|
||||||
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Eine Aufgabe ohne eigenes Modell läuft auf dem ersten freien davon, in dieser Reihenfolge. Keines angehakt: alle.",
|
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Eine Aufgabe ohne eigenes Modell läuft auf dem ersten freien davon, in dieser Reihenfolge. Keines angehakt: alle.",
|
||||||
"A team of up to %(n)s": "Ein Team mit bis zu %(n)s",
|
|
||||||
"A team works on it together, its own agent included.": "Ein Team arbeitet gemeinsam daran, ihr eigener Agent eingeschlossen.",
|
|
||||||
"Choose a model to choose its effort.": "Wählen Sie ein Modell, um seinen Aufwand zu wählen.",
|
"Choose a model to choose its effort.": "Wählen Sie ein Modell, um seinen Aufwand zu wählen.",
|
||||||
"Due %(when)s": "Fällig %(when)s",
|
"Due %(when)s": "Fällig %(when)s",
|
||||||
"Each run sees the ones before it.": "Jeder Lauf sieht die vorherigen.",
|
"Each run sees the ones before it.": "Jeder Lauf sieht die vorherigen.",
|
||||||
@@ -740,7 +878,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Edit %(title)s": "%(title)s bearbeiten",
|
"Edit %(title)s": "%(title)s bearbeiten",
|
||||||
"Every result, even with nothing to report.": "Jedes Ergebnis, auch ohne Neuigkeiten.",
|
"Every result, even with nothing to report.": "Jedes Ergebnis, auch ohne Neuigkeiten.",
|
||||||
"Models for scheduled tasks": "Modelle für geplante Aufgaben",
|
"Models for scheduled tasks": "Modelle für geplante Aufgaben",
|
||||||
"No team": "Kein Team",
|
|
||||||
"Only in its history.": "Nur in ihrem Verlauf.",
|
"Only in its history.": "Nur in ihrem Verlauf.",
|
||||||
"Open the chat": "Chat öffnen",
|
"Open the chat": "Chat öffnen",
|
||||||
"Result": "Ergebnis",
|
"Result": "Ergebnis",
|
||||||
@@ -761,6 +898,34 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"No day ticked: every day.": "Kein Tag angehakt: jeden Tag.",
|
"No day ticked: every day.": "Kein Tag angehakt: jeden Tag.",
|
||||||
},
|
},
|
||||||
"es": {
|
"es": {
|
||||||
|
"Sign in again: the server no longer accepts this app's sign-in.": "Vuelva a iniciar sesión: el servidor ya no acepta el inicio de sesión de esta aplicación.",
|
||||||
|
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "El terminal necesita un servidor LLeMbas más reciente. Pida a su administrador que lo actualice.",
|
||||||
|
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Este modelo atiende una petición a la vez: los subagentes necesitan otro modelo, en otro servidor, nombrado en el chat.",
|
||||||
|
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "Los números cuentan el agente propio del chat: 1 es el agente solo, sin subagentes; Máx. 4 es el agente y hasta 3 subagentes. Un subagente usa el modelo del chat, u otro solo si lo nombra en el chat.",
|
||||||
|
"Min": "Mín.",
|
||||||
|
"Max": "Máx.",
|
||||||
|
"Tasks: %(n)s": "Tareas: %(n)s",
|
||||||
|
"%(n)s running": "%(n)s en marcha",
|
||||||
|
"%(tokens)s until auto-compact": "%(tokens)s hasta la compactación automática",
|
||||||
|
"Automatic compaction is off.": "La compactación automática está desactivada.",
|
||||||
|
"Compacts before the next reply.": "Se compacta antes de la próxima respuesta.",
|
||||||
|
"Compacts by itself once the window is known.": "Se compacta sola en cuanto se conozca la ventana.",
|
||||||
|
"Compact": "Compactar",
|
||||||
|
"Compacting…": "Compactando…",
|
||||||
|
"Summarise the earlier turns now and send the summary in their place.": "Resumir ahora los turnos anteriores y enviar el resumen en su lugar.",
|
||||||
|
"This server cannot compact a chat from the app yet; the web interface can.": "Este servidor aún no puede compactar un chat desde la aplicación; la interfaz web sí puede.",
|
||||||
|
"Context and background tasks": "Contexto y tareas en segundo plano",
|
||||||
|
"Environment": "Entorno",
|
||||||
|
"Finished %(n)s": "Terminadas: %(n)s",
|
||||||
|
"Background command": "Comando en segundo plano",
|
||||||
|
"Subagent": "Subagente",
|
||||||
|
"Team member": "Miembro del equipo",
|
||||||
|
"Subagents": "Subagentes",
|
||||||
|
"None": "Ninguno",
|
||||||
|
"Up to %(n)s at once": "Hasta %(n)s a la vez",
|
||||||
|
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "No hay nada en segundo plano. Los subagentes, los miembros de un equipo y los comandos en segundo plano aparecen aquí mientras trabajan.",
|
||||||
|
"Team: %(size)s of at most %(max)s subagents": "Equipo: %(size)s de un máximo de %(max)s subagentes",
|
||||||
|
"How many work on it at once, the run's own agent among them, on the same model.": "Cuántos trabajan en ella a la vez, contando el propio agente de la ejecución, con el mismo modelo.",
|
||||||
"Menu": "Menú",
|
"Menu": "Menú",
|
||||||
"Minimise": "Minimizar",
|
"Minimise": "Minimizar",
|
||||||
"Maximise": "Maximizar",
|
"Maximise": "Maximizar",
|
||||||
@@ -928,8 +1093,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"A busy model is waited for, then the next free one is used.": "Se espera a un modelo ocupado y luego se usa el siguiente libre.",
|
"A busy model is waited for, then the next free one is used.": "Se espera a un modelo ocupado y luego se usa el siguiente libre.",
|
||||||
"A result, but not a run with nothing to report.": "Un resultado, pero no una ejecución sin nada que informar.",
|
"A result, but not a run with nothing to report.": "Un resultado, pero no una ejecución sin nada que informar.",
|
||||||
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Una tarea sin modelo propio se ejecuta en el primero libre de estos, en este orden. Ninguno marcado: todos.",
|
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Una tarea sin modelo propio se ejecuta en el primero libre de estos, en este orden. Ninguno marcado: todos.",
|
||||||
"A team of up to %(n)s": "Un equipo de hasta %(n)s",
|
|
||||||
"A team works on it together, its own agent included.": "Un equipo trabaja en ella, incluido su propio agente.",
|
|
||||||
"Choose a model to choose its effort.": "Elija un modelo para elegir su esfuerzo.",
|
"Choose a model to choose its effort.": "Elija un modelo para elegir su esfuerzo.",
|
||||||
"Due %(when)s": "Prevista %(when)s",
|
"Due %(when)s": "Prevista %(when)s",
|
||||||
"Each run sees the ones before it.": "Cada ejecución ve las anteriores.",
|
"Each run sees the ones before it.": "Cada ejecución ve las anteriores.",
|
||||||
@@ -938,7 +1101,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Edit %(title)s": "Editar %(title)s",
|
"Edit %(title)s": "Editar %(title)s",
|
||||||
"Every result, even with nothing to report.": "Cada resultado, incluso sin nada que informar.",
|
"Every result, even with nothing to report.": "Cada resultado, incluso sin nada que informar.",
|
||||||
"Models for scheduled tasks": "Modelos para tareas programadas",
|
"Models for scheduled tasks": "Modelos para tareas programadas",
|
||||||
"No team": "Sin equipo",
|
|
||||||
"Only in its history.": "Solo en su historial.",
|
"Only in its history.": "Solo en su historial.",
|
||||||
"Open the chat": "Abrir el chat",
|
"Open the chat": "Abrir el chat",
|
||||||
"Result": "Resultado",
|
"Result": "Resultado",
|
||||||
@@ -959,6 +1121,34 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"No day ticked: every day.": "Ningún día marcado: todos los días.",
|
"No day ticked: every day.": "Ningún día marcado: todos los días.",
|
||||||
},
|
},
|
||||||
"fr": {
|
"fr": {
|
||||||
|
"Sign in again: the server no longer accepts this app's sign-in.": "Reconnectez-vous : le serveur n’accepte plus la connexion de cette application.",
|
||||||
|
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Le terminal nécessite un serveur LLeMbas plus récent. Demandez à son administrateur de le mettre à jour.",
|
||||||
|
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Ce modèle traite une requête à la fois : les sous-agents ont besoin d’un autre modèle, sur un autre serveur, nommé dans la conversation.",
|
||||||
|
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "Les nombres comptent l’agent de la conversation : 1, c’est l’agent seul, sans sous-agents ; Max 4, c’est l’agent et jusqu’à 3 sous-agents. Un sous-agent utilise le modèle de la conversation, ou un autre seulement si vous le nommez dans la conversation.",
|
||||||
|
"Min": "Min",
|
||||||
|
"Max": "Max",
|
||||||
|
"Tasks: %(n)s": "Tâches : %(n)s",
|
||||||
|
"%(n)s running": "%(n)s en cours",
|
||||||
|
"%(tokens)s until auto-compact": "%(tokens)s avant le compactage automatique",
|
||||||
|
"Automatic compaction is off.": "Le compactage automatique est désactivé.",
|
||||||
|
"Compacts before the next reply.": "Compacté avant la prochaine réponse.",
|
||||||
|
"Compacts by itself once the window is known.": "Compacté de lui-même dès que la fenêtre est connue.",
|
||||||
|
"Compact": "Compacter",
|
||||||
|
"Compacting…": "Compactage…",
|
||||||
|
"Summarise the earlier turns now and send the summary in their place.": "Résumer maintenant les tours précédents et envoyer le résumé à leur place.",
|
||||||
|
"This server cannot compact a chat from the app yet; the web interface can.": "Ce serveur ne peut pas encore compacter une conversation depuis l’application ; l’interface web le peut.",
|
||||||
|
"Context and background tasks": "Contexte et tâches en arrière-plan",
|
||||||
|
"Environment": "Environnement",
|
||||||
|
"Finished %(n)s": "Terminées : %(n)s",
|
||||||
|
"Background command": "Commande en arrière-plan",
|
||||||
|
"Subagent": "Sous-agent",
|
||||||
|
"Team member": "Membre de l’équipe",
|
||||||
|
"Subagents": "Sous-agents",
|
||||||
|
"None": "Aucun",
|
||||||
|
"Up to %(n)s at once": "Jusqu’à %(n)s à la fois",
|
||||||
|
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Rien ne tourne en arrière-plan. Les sous-agents, les membres d’une équipe et les commandes en arrière-plan s’affichent ici pendant qu’ils travaillent.",
|
||||||
|
"Team: %(size)s of at most %(max)s subagents": "Équipe : %(size)s sur %(max)s sous-agents au plus",
|
||||||
|
"How many work on it at once, the run's own agent among them, on the same model.": "Combien y travaillent à la fois, l’agent de l’exécution compris, avec le même modèle.",
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
"Minimise": "Réduire",
|
"Minimise": "Réduire",
|
||||||
"Maximise": "Agrandir",
|
"Maximise": "Agrandir",
|
||||||
@@ -1126,8 +1316,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"A busy model is waited for, then the next free one is used.": "Un modèle occupé est attendu, puis le suivant libre est utilisé.",
|
"A busy model is waited for, then the next free one is used.": "Un modèle occupé est attendu, puis le suivant libre est utilisé.",
|
||||||
"A result, but not a run with nothing to report.": "Un résultat, mais pas une exécution sans rien à signaler.",
|
"A result, but not a run with nothing to report.": "Un résultat, mais pas une exécution sans rien à signaler.",
|
||||||
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Une tâche sans modèle à elle tourne sur le premier libre de ceux-ci, dans cet ordre. Aucun coché : tous.",
|
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Une tâche sans modèle à elle tourne sur le premier libre de ceux-ci, dans cet ordre. Aucun coché : tous.",
|
||||||
"A team of up to %(n)s": "Une équipe jusqu'à %(n)s",
|
|
||||||
"A team works on it together, its own agent included.": "Une équipe y travaille ensemble, son propre agent compris.",
|
|
||||||
"Choose a model to choose its effort.": "Choisissez un modèle pour choisir son effort.",
|
"Choose a model to choose its effort.": "Choisissez un modèle pour choisir son effort.",
|
||||||
"Due %(when)s": "Prévue %(when)s",
|
"Due %(when)s": "Prévue %(when)s",
|
||||||
"Each run sees the ones before it.": "Chaque exécution voit les précédentes.",
|
"Each run sees the ones before it.": "Chaque exécution voit les précédentes.",
|
||||||
@@ -1136,7 +1324,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Edit %(title)s": "Modifier %(title)s",
|
"Edit %(title)s": "Modifier %(title)s",
|
||||||
"Every result, even with nothing to report.": "Chaque résultat, même sans rien à signaler.",
|
"Every result, even with nothing to report.": "Chaque résultat, même sans rien à signaler.",
|
||||||
"Models for scheduled tasks": "Modèles pour les tâches planifiées",
|
"Models for scheduled tasks": "Modèles pour les tâches planifiées",
|
||||||
"No team": "Pas d'équipe",
|
|
||||||
"Only in its history.": "Seulement dans son historique.",
|
"Only in its history.": "Seulement dans son historique.",
|
||||||
"Open the chat": "Ouvrir la conversation",
|
"Open the chat": "Ouvrir la conversation",
|
||||||
"Result": "Résultat",
|
"Result": "Résultat",
|
||||||
@@ -1157,6 +1344,34 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"No day ticked: every day.": "Aucun jour coché : tous les jours.",
|
"No day ticked: every day.": "Aucun jour coché : tous les jours.",
|
||||||
},
|
},
|
||||||
"hu": {
|
"hu": {
|
||||||
|
"Sign in again: the server no longer accepts this app's sign-in.": "Jelentkezzen be újra: a szerver már nem fogadja el ennek az alkalmazásnak a bejelentkezését.",
|
||||||
|
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "A terminálhoz újabb LLeMbas-szerver kell. Kérje meg a rendszergazdáját, hogy frissítse.",
|
||||||
|
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Ez a modell egyszerre egy kérést szolgál ki: az alügynököknek másik modell kell, másik szerveren, a beszélgetésben megnevezve.",
|
||||||
|
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "A számokba a beszélgetés saját ügynöke is beleszámít: az 1 maga az ügynök, alügynökök nélkül; a Max. 4 az ügynök és legfeljebb 3 alügynök. Az alügynök a beszélgetés modelljén fut, másikon csak akkor, ha megnevezi a beszélgetésben.",
|
||||||
|
"Min": "Min.",
|
||||||
|
"Max": "Max.",
|
||||||
|
"Tasks: %(n)s": "Feladatok: %(n)s",
|
||||||
|
"%(n)s running": "%(n)s fut",
|
||||||
|
"%(tokens)s until auto-compact": "%(tokens)s az automatikus tömörítésig",
|
||||||
|
"Automatic compaction is off.": "Az automatikus tömörítés ki van kapcsolva.",
|
||||||
|
"Compacts before the next reply.": "A következő válasz előtt tömörül.",
|
||||||
|
"Compacts by itself once the window is known.": "Magától tömörül, amint ismert az ablak.",
|
||||||
|
"Compact": "Tömörítés",
|
||||||
|
"Compacting…": "Tömörítés…",
|
||||||
|
"Summarise the earlier turns now and send the summary in their place.": "A korábbi köröket most összefoglalja, és helyettük az összefoglalót küldi.",
|
||||||
|
"This server cannot compact a chat from the app yet; the web interface can.": "Ez a szerver még nem tud beszélgetést tömöríteni az alkalmazásból; a webes felület tud.",
|
||||||
|
"Context and background tasks": "Kontextus és háttérfeladatok",
|
||||||
|
"Environment": "Környezet",
|
||||||
|
"Finished %(n)s": "Kész: %(n)s",
|
||||||
|
"Background command": "Háttérparancs",
|
||||||
|
"Subagent": "Alügynök",
|
||||||
|
"Team member": "Csapattag",
|
||||||
|
"Subagents": "Alügynökök",
|
||||||
|
"None": "Nincs",
|
||||||
|
"Up to %(n)s at once": "Legfeljebb %(n)s egyszerre",
|
||||||
|
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "A háttérben semmi sem fut. Az alügynökök, egy csapat tagjai és a háttérparancsok itt jelennek meg, amíg dolgoznak.",
|
||||||
|
"Team: %(size)s of at most %(max)s subagents": "Csapat: %(size)s a legfeljebb %(max)s alügynökből",
|
||||||
|
"How many work on it at once, the run's own agent among them, on the same model.": "Hányan dolgoznak rajta egyszerre, a futás saját ügynökét is beleértve, ugyanazon a modellen.",
|
||||||
"Menu": "Menü",
|
"Menu": "Menü",
|
||||||
"Minimise": "Kis méret",
|
"Minimise": "Kis méret",
|
||||||
"Maximise": "Teljes méret",
|
"Maximise": "Teljes méret",
|
||||||
@@ -1324,8 +1539,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"A busy model is waited for, then the next free one is used.": "A foglalt modellre vár, majd a következő szabadot használja.",
|
"A busy model is waited for, then the next free one is used.": "A foglalt modellre vár, majd a következő szabadot használja.",
|
||||||
"A result, but not a run with nothing to report.": "Eredmény, de nem olyan futás, amelynek nincs mit jelentenie.",
|
"A result, but not a run with nothing to report.": "Eredmény, de nem olyan futás, amelynek nincs mit jelentenie.",
|
||||||
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "A saját modell nélküli feladat ezek közül az első szabadon fut, ebben a sorrendben. Ha egy sincs bejelölve: mindegyiken.",
|
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "A saját modell nélküli feladat ezek közül az első szabadon fut, ebben a sorrendben. Ha egy sincs bejelölve: mindegyiken.",
|
||||||
"A team of up to %(n)s": "Legfeljebb %(n)s fős csapat",
|
|
||||||
"A team works on it together, its own agent included.": "Egy csapat dolgozik rajta együtt, a saját ügynökét is beleértve.",
|
|
||||||
"Choose a model to choose its effort.": "Válasszon modellt az erőfeszítés megadásához.",
|
"Choose a model to choose its effort.": "Válasszon modellt az erőfeszítés megadásához.",
|
||||||
"Due %(when)s": "Esedékes: %(when)s",
|
"Due %(when)s": "Esedékes: %(when)s",
|
||||||
"Each run sees the ones before it.": "Minden futás látja az előzőeket.",
|
"Each run sees the ones before it.": "Minden futás látja az előzőeket.",
|
||||||
@@ -1334,7 +1547,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Edit %(title)s": "%(title)s szerkesztése",
|
"Edit %(title)s": "%(title)s szerkesztése",
|
||||||
"Every result, even with nothing to report.": "Minden eredmény, akkor is, ha nincs mit jelenteni.",
|
"Every result, even with nothing to report.": "Minden eredmény, akkor is, ha nincs mit jelenteni.",
|
||||||
"Models for scheduled tasks": "Modellek az ütemezett feladatokhoz",
|
"Models for scheduled tasks": "Modellek az ütemezett feladatokhoz",
|
||||||
"No team": "Nincs csapat",
|
|
||||||
"Only in its history.": "Csak az előzményeiben.",
|
"Only in its history.": "Csak az előzményeiben.",
|
||||||
"Open the chat": "A csevegés megnyitása",
|
"Open the chat": "A csevegés megnyitása",
|
||||||
"Result": "Eredmény",
|
"Result": "Eredmény",
|
||||||
@@ -1355,6 +1567,34 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"No day ticked: every day.": "Ha egy nap sincs bejelölve: minden nap.",
|
"No day ticked: every day.": "Ha egy nap sincs bejelölve: minden nap.",
|
||||||
},
|
},
|
||||||
"it": {
|
"it": {
|
||||||
|
"Sign in again: the server no longer accepts this app's sign-in.": "Accedi di nuovo: il server non accetta più l’accesso di questa app.",
|
||||||
|
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Il terminale richiede un server LLeMbas più recente. Chiedi al suo amministratore di aggiornarlo.",
|
||||||
|
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Questo modello serve una richiesta alla volta: i sottoagenti hanno bisogno di un altro modello, su un altro server, nominato nella chat.",
|
||||||
|
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "I numeri contano anche l’agente della chat: 1 è l’agente da solo, senza sottoagenti; Max 4 è l’agente più fino a 3 sottoagenti. Un sottoagente usa il modello della chat, o un altro solo se lo nomini nella chat.",
|
||||||
|
"Min": "Min",
|
||||||
|
"Max": "Max",
|
||||||
|
"Tasks: %(n)s": "Attività: %(n)s",
|
||||||
|
"%(n)s running": "%(n)s in corso",
|
||||||
|
"%(tokens)s until auto-compact": "%(tokens)s alla compattazione automatica",
|
||||||
|
"Automatic compaction is off.": "La compattazione automatica è disattivata.",
|
||||||
|
"Compacts before the next reply.": "Si compatta prima della prossima risposta.",
|
||||||
|
"Compacts by itself once the window is known.": "Si compatta da sola appena la finestra è nota.",
|
||||||
|
"Compact": "Compatta",
|
||||||
|
"Compacting…": "Compattazione…",
|
||||||
|
"Summarise the earlier turns now and send the summary in their place.": "Riassumi ora i turni precedenti e invia il riassunto al loro posto.",
|
||||||
|
"This server cannot compact a chat from the app yet; the web interface can.": "Questo server non può ancora compattare una chat dall’app; l’interfaccia web sì.",
|
||||||
|
"Context and background tasks": "Contesto e attività in background",
|
||||||
|
"Environment": "Ambiente",
|
||||||
|
"Finished %(n)s": "Terminate: %(n)s",
|
||||||
|
"Background command": "Comando in background",
|
||||||
|
"Subagent": "Sottoagente",
|
||||||
|
"Team member": "Membro della squadra",
|
||||||
|
"Subagents": "Sottoagenti",
|
||||||
|
"None": "Nessuno",
|
||||||
|
"Up to %(n)s at once": "Fino a %(n)s alla volta",
|
||||||
|
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Nulla è in esecuzione in background. Sottoagenti, membri di una squadra e comandi in background compaiono qui mentre lavorano.",
|
||||||
|
"Team: %(size)s of at most %(max)s subagents": "Squadra: %(size)s su un massimo di %(max)s sottoagenti",
|
||||||
|
"How many work on it at once, the run's own agent among them, on the same model.": "Quanti ci lavorano alla volta, compreso l’agente dell’esecuzione, sullo stesso modello.",
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
"Minimise": "Riduci a icona",
|
"Minimise": "Riduci a icona",
|
||||||
"Maximise": "Ingrandisci",
|
"Maximise": "Ingrandisci",
|
||||||
@@ -1522,8 +1762,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"A busy model is waited for, then the next free one is used.": "Si attende un modello occupato, poi si usa il successivo libero.",
|
"A busy model is waited for, then the next free one is used.": "Si attende un modello occupato, poi si usa il successivo libero.",
|
||||||
"A result, but not a run with nothing to report.": "Un risultato, ma non un’esecuzione senza nulla da segnalare.",
|
"A result, but not a run with nothing to report.": "Un risultato, ma non un’esecuzione senza nulla da segnalare.",
|
||||||
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Un’attività senza un modello proprio gira sul primo libero di questi, in quest’ordine. Nessuno spuntato: tutti.",
|
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Un’attività senza un modello proprio gira sul primo libero di questi, in quest’ordine. Nessuno spuntato: tutti.",
|
||||||
"A team of up to %(n)s": "Un team fino a %(n)s",
|
|
||||||
"A team works on it together, its own agent included.": "Un team ci lavora insieme, incluso il suo agente.",
|
|
||||||
"Choose a model to choose its effort.": "Scegli un modello per sceglierne l’impegno.",
|
"Choose a model to choose its effort.": "Scegli un modello per sceglierne l’impegno.",
|
||||||
"Due %(when)s": "Prevista %(when)s",
|
"Due %(when)s": "Prevista %(when)s",
|
||||||
"Each run sees the ones before it.": "Ogni esecuzione vede quelle precedenti.",
|
"Each run sees the ones before it.": "Ogni esecuzione vede quelle precedenti.",
|
||||||
@@ -1532,7 +1770,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Edit %(title)s": "Modifica %(title)s",
|
"Edit %(title)s": "Modifica %(title)s",
|
||||||
"Every result, even with nothing to report.": "Ogni risultato, anche senza nulla da segnalare.",
|
"Every result, even with nothing to report.": "Ogni risultato, anche senza nulla da segnalare.",
|
||||||
"Models for scheduled tasks": "Modelli per le attività pianificate",
|
"Models for scheduled tasks": "Modelli per le attività pianificate",
|
||||||
"No team": "Nessun team",
|
|
||||||
"Only in its history.": "Solo nella sua cronologia.",
|
"Only in its history.": "Solo nella sua cronologia.",
|
||||||
"Open the chat": "Apri la chat",
|
"Open the chat": "Apri la chat",
|
||||||
"Result": "Risultato",
|
"Result": "Risultato",
|
||||||
@@ -1553,6 +1790,34 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"No day ticked: every day.": "Nessun giorno spuntato: ogni giorno.",
|
"No day ticked: every day.": "Nessun giorno spuntato: ogni giorno.",
|
||||||
},
|
},
|
||||||
"pl": {
|
"pl": {
|
||||||
|
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.",
|
||||||
|
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminal wymaga nowszego serwera LLeMbas. Poproś jego administratora o aktualizację.",
|
||||||
|
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Ten model obsługuje jedno żądanie naraz: podagenci potrzebują innego modelu, na innym serwerze, wskazanego w czacie.",
|
||||||
|
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "Liczby obejmują własnego agenta czatu: 1 to sam agent, bez podagentów; Maks 4 to agent i do 3 podagentów. Podagent działa na modelu czatu, na innym tylko wtedy, gdy wskażesz go w czacie.",
|
||||||
|
"Min": "Min",
|
||||||
|
"Max": "Maks",
|
||||||
|
"Tasks: %(n)s": "Zadania: %(n)s",
|
||||||
|
"%(n)s running": "Działa: %(n)s",
|
||||||
|
"%(tokens)s until auto-compact": "%(tokens)s do automatycznego streszczenia",
|
||||||
|
"Automatic compaction is off.": "Automatyczne streszczanie jest wyłączone.",
|
||||||
|
"Compacts before the next reply.": "Zostanie streszczona przed następną odpowiedzią.",
|
||||||
|
"Compacts by itself once the window is known.": "Zostanie streszczona sama, gdy okno będzie znane.",
|
||||||
|
"Compact": "Streść",
|
||||||
|
"Compacting…": "Streszczanie…",
|
||||||
|
"Summarise the earlier turns now and send the summary in their place.": "Streścić teraz wcześniejsze tury i wysyłać streszczenie zamiast nich.",
|
||||||
|
"This server cannot compact a chat from the app yet; the web interface can.": "Ten serwer nie umie jeszcze streścić czatu z aplikacji; interfejs WWW umie.",
|
||||||
|
"Context and background tasks": "Kontekst i zadania w tle",
|
||||||
|
"Environment": "Środowisko",
|
||||||
|
"Finished %(n)s": "Zakończone: %(n)s",
|
||||||
|
"Background command": "Polecenie w tle",
|
||||||
|
"Subagent": "Podagent",
|
||||||
|
"Team member": "Członek zespołu",
|
||||||
|
"Subagents": "Podagenci",
|
||||||
|
"None": "Brak",
|
||||||
|
"Up to %(n)s at once": "Do %(n)s naraz",
|
||||||
|
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "W tle nic nie działa. Podagenci, członkowie zespołu i polecenia w tle pojawiają się tu, gdy pracują.",
|
||||||
|
"Team: %(size)s of at most %(max)s subagents": "Zespół: %(size)s z najwyżej %(max)s podagentów",
|
||||||
|
"How many work on it at once, the run's own agent among them, on the same model.": "Ilu pracuje nad nim naraz, łącznie z własnym agentem uruchomienia, na tym samym modelu.",
|
||||||
"Menu": "Menu",
|
"Menu": "Menu",
|
||||||
"Minimise": "Minimalizuj",
|
"Minimise": "Minimalizuj",
|
||||||
"Maximise": "Maksymalizuj",
|
"Maximise": "Maksymalizuj",
|
||||||
@@ -1720,8 +1985,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"A busy model is waited for, then the next free one is used.": "Na zajęty model się czeka, potem używany jest następny wolny.",
|
"A busy model is waited for, then the next free one is used.": "Na zajęty model się czeka, potem używany jest następny wolny.",
|
||||||
"A result, but not a run with nothing to report.": "Wynik, ale nie uruchomienie bez niczego do zgłoszenia.",
|
"A result, but not a run with nothing to report.": "Wynik, ale nie uruchomienie bez niczego do zgłoszenia.",
|
||||||
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Zadanie bez własnego modelu działa na pierwszym wolnym z tych, w tej kolejności. Żaden niezaznaczony: wszystkie.",
|
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Zadanie bez własnego modelu działa na pierwszym wolnym z tych, w tej kolejności. Żaden niezaznaczony: wszystkie.",
|
||||||
"A team of up to %(n)s": "Zespół do %(n)s",
|
|
||||||
"A team works on it together, its own agent included.": "Pracuje nad nim zespół, wraz z jego własnym agentem.",
|
|
||||||
"Choose a model to choose its effort.": "Wybierz model, aby wybrać jego wysiłek.",
|
"Choose a model to choose its effort.": "Wybierz model, aby wybrać jego wysiłek.",
|
||||||
"Due %(when)s": "Zaplanowane na %(when)s",
|
"Due %(when)s": "Zaplanowane na %(when)s",
|
||||||
"Each run sees the ones before it.": "Każde uruchomienie widzi poprzednie.",
|
"Each run sees the ones before it.": "Każde uruchomienie widzi poprzednie.",
|
||||||
@@ -1730,7 +1993,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Edit %(title)s": "Edytuj %(title)s",
|
"Edit %(title)s": "Edytuj %(title)s",
|
||||||
"Every result, even with nothing to report.": "Każdy wynik, nawet bez niczego do zgłoszenia.",
|
"Every result, even with nothing to report.": "Każdy wynik, nawet bez niczego do zgłoszenia.",
|
||||||
"Models for scheduled tasks": "Modele dla zaplanowanych zadań",
|
"Models for scheduled tasks": "Modele dla zaplanowanych zadań",
|
||||||
"No team": "Bez zespołu",
|
|
||||||
"Only in its history.": "Tylko w jego historii.",
|
"Only in its history.": "Tylko w jego historii.",
|
||||||
"Open the chat": "Otwórz czat",
|
"Open the chat": "Otwórz czat",
|
||||||
"Result": "Wynik",
|
"Result": "Wynik",
|
||||||
@@ -1751,6 +2013,34 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"No day ticked: every day.": "Żaden dzień niezaznaczony: codziennie.",
|
"No day ticked: every day.": "Żaden dzień niezaznaczony: codziennie.",
|
||||||
},
|
},
|
||||||
"sk": {
|
"sk": {
|
||||||
|
"Sign in again: the server no longer accepts this app's sign-in.": "Prihláste sa znova: server už neprijíma prihlásenie tejto aplikácie.",
|
||||||
|
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminál potrebuje novší server LLeMbas. Požiadajte jeho správcu o aktualizáciu.",
|
||||||
|
"This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Tento model vybavuje naraz jednu požiadavku: podagenti potrebujú iný model na inom serveri, pomenovaný v konverzácii.",
|
||||||
|
"The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.": "Čísla počítajú aj vlastného agenta konverzácie: 1 je agent sám, bez podagentov; Max 4 je agent a až 3 podagenti. Podagent beží na modeli konverzácie, na inom len vtedy, keď ho v konverzácii pomenujete.",
|
||||||
|
"Min": "Min",
|
||||||
|
"Max": "Max",
|
||||||
|
"Tasks: %(n)s": "Úlohy: %(n)s",
|
||||||
|
"%(n)s running": "Beží: %(n)s",
|
||||||
|
"%(tokens)s until auto-compact": "%(tokens)s do automatického zhrnutia",
|
||||||
|
"Automatic compaction is off.": "Automatické zhrnutie je vypnuté.",
|
||||||
|
"Compacts before the next reply.": "Zhrnie sa pred ďalšou odpoveďou.",
|
||||||
|
"Compacts by itself once the window is known.": "Zhrnie sa samo, keď bude okno známe.",
|
||||||
|
"Compact": "Zhrnúť",
|
||||||
|
"Compacting…": "Zhŕňa sa…",
|
||||||
|
"Summarise the earlier turns now and send the summary in their place.": "Zhrnúť skoršie kolá hneď a posielať namiesto nich zhrnutie.",
|
||||||
|
"This server cannot compact a chat from the app yet; the web interface can.": "Tento server zatiaľ nevie zhrnúť konverzáciu z aplikácie; webové rozhranie to vie.",
|
||||||
|
"Context and background tasks": "Kontext a úlohy na pozadí",
|
||||||
|
"Environment": "Prostredie",
|
||||||
|
"Finished %(n)s": "Dokončené: %(n)s",
|
||||||
|
"Background command": "Príkaz na pozadí",
|
||||||
|
"Subagent": "Podagent",
|
||||||
|
"Team member": "Člen tímu",
|
||||||
|
"Subagents": "Podagenti",
|
||||||
|
"None": "Žiadni",
|
||||||
|
"Up to %(n)s at once": "Najviac %(n)s naraz",
|
||||||
|
"Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Na pozadí nič nebeží. Podagenti, členovia tímu a príkazy na pozadí sa tu zobrazia, kým pracujú.",
|
||||||
|
"Team: %(size)s of at most %(max)s subagents": "Tím: %(size)s z najviac %(max)s podagentov",
|
||||||
|
"How many work on it at once, the run's own agent among them, on the same model.": "Koľko na nej pracuje naraz, vrátane vlastného agenta behu, na rovnakom modeli.",
|
||||||
"Menu": "Ponuka",
|
"Menu": "Ponuka",
|
||||||
"Minimise": "Minimalizovať",
|
"Minimise": "Minimalizovať",
|
||||||
"Maximise": "Maximalizovať",
|
"Maximise": "Maximalizovať",
|
||||||
@@ -1918,8 +2208,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"A busy model is waited for, then the next free one is used.": "Na zaneprázdnený model sa čaká, potom sa použije ďalší voľný.",
|
"A busy model is waited for, then the next free one is used.": "Na zaneprázdnený model sa čaká, potom sa použije ďalší voľný.",
|
||||||
"A result, but not a run with nothing to report.": "Výsledok, ale nie spustenie, ktoré nemá čo hlásiť.",
|
"A result, but not a run with nothing to report.": "Výsledok, ale nie spustenie, ktoré nemá čo hlásiť.",
|
||||||
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Úloha bez vlastného modelu beží na prvom voľnom z týchto, v tomto poradí. Žiadny nezaškrtnutý: všetky.",
|
"A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Úloha bez vlastného modelu beží na prvom voľnom z týchto, v tomto poradí. Žiadny nezaškrtnutý: všetky.",
|
||||||
"A team of up to %(n)s": "Tím: najviac %(n)s",
|
|
||||||
"A team works on it together, its own agent included.": "Pracuje na nej tím spoločne, vrátane jej vlastného agenta.",
|
|
||||||
"Choose a model to choose its effort.": "Vyberte model, aby ste mohli zvoliť jeho náročnosť.",
|
"Choose a model to choose its effort.": "Vyberte model, aby ste mohli zvoliť jeho náročnosť.",
|
||||||
"Due %(when)s": "Naplánované na %(when)s",
|
"Due %(when)s": "Naplánované na %(when)s",
|
||||||
"Each run sees the ones before it.": "Každé spustenie vidí tie predchádzajúce.",
|
"Each run sees the ones before it.": "Každé spustenie vidí tie predchádzajúce.",
|
||||||
@@ -1928,7 +2216,6 @@ OWN: dict[str, dict[str, str]] = {
|
|||||||
"Edit %(title)s": "Upraviť %(title)s",
|
"Edit %(title)s": "Upraviť %(title)s",
|
||||||
"Every result, even with nothing to report.": "Každý výsledok, aj keď nie je čo hlásiť.",
|
"Every result, even with nothing to report.": "Každý výsledok, aj keď nie je čo hlásiť.",
|
||||||
"Models for scheduled tasks": "Modely pre naplánované úlohy",
|
"Models for scheduled tasks": "Modely pre naplánované úlohy",
|
||||||
"No team": "Bez tímu",
|
|
||||||
"Only in its history.": "Len v jej histórii.",
|
"Only in its history.": "Len v jej histórii.",
|
||||||
"Open the chat": "Otvoriť konverzáciu",
|
"Open the chat": "Otvoriť konverzáciu",
|
||||||
"Result": "Výsledok",
|
"Result": "Výsledok",
|
||||||
@@ -1997,7 +2284,7 @@ def main() -> None:
|
|||||||
print(f"gen-i18n: {lang}: OWN has {key!r}, which no page uses", file=sys.stderr)
|
print(f"gen-i18n: {lang}: OWN has {key!r}, which no page uses", file=sys.stderr)
|
||||||
problems += 1
|
problems += 1
|
||||||
catalogue[lang] = table
|
catalogue[lang] = table
|
||||||
print(f"gen-i18n: {lang}: {len(table)} of {len(KEYS)} ({from_server} from the server)")
|
print(f"gen-i18n: {lang}: {len(table)} sentences, {from_server} of the page's {len(KEYS)} from the server")
|
||||||
|
|
||||||
body = json.dumps(catalogue, ensure_ascii=False, indent=2, sort_keys=True)
|
body = json.dumps(catalogue, ensure_ascii=False, indent=2, sort_keys=True)
|
||||||
out = (
|
out = (
|
||||||
|
|||||||
Generated
+85
-4
@@ -279,6 +279,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -511,6 +520,12 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -623,6 +638,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
@@ -686,6 +710,12 @@ dependencies = [
|
|||||||
"syn 3.0.8",
|
"syn 3.0.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dbus"
|
name = "dbus"
|
||||||
version = "0.9.12"
|
version = "0.9.12"
|
||||||
@@ -775,8 +805,19 @@ version = "0.10.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.10.4",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.12.1",
|
||||||
|
"const-oid",
|
||||||
|
"crypto-common 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1552,6 +1593,15 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.12.0"
|
version = "1.12.0"
|
||||||
@@ -2085,7 +2135,7 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "llembas-desktop"
|
name = "llembas-desktop"
|
||||||
version = "1.0.0-beta.4"
|
version = "1.0.0-beta.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -2097,6 +2147,7 @@ dependencies = [
|
|||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
|
"tungstenite",
|
||||||
"ureq",
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3488,6 +3539,17 @@ dependencies = [
|
|||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha1"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"digest 0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -3496,7 +3558,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures 0.2.17",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4539,6 +4601,25 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.30.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs 0.8.5",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"sha1",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "llembas-desktop"
|
name = "llembas-desktop"
|
||||||
version = "1.0.0-beta.4"
|
version = "1.0.0-beta.6"
|
||||||
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
|
description = "LLeMbas Desktop: the agent on this computer and your LLeMbas server's chats, in an app of its own"
|
||||||
authors = ["Jaroslav Beneš"]
|
authors = ["Jaroslav Beneš"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -26,6 +26,8 @@ tauri-plugin-updater = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
ureq = { version = "2", default-features = false, features = ["tls", "native-certs", "json"] }
|
ureq = { version = "2", default-features = false, features = ["tls", "native-certs", "json"] }
|
||||||
|
# The terminal panel's socket (`terminal.rs`): a blocking WebSocket on the TLS ureq already brings.
|
||||||
|
tungstenite = { version = "0.30", default-features = false, features = ["handshake", "rustls-tls-native-roots"] }
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ mod remote;
|
|||||||
mod sealed;
|
mod sealed;
|
||||||
mod server;
|
mod server;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
mod terminal;
|
||||||
mod watch;
|
mod watch;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -115,6 +116,8 @@ struct App {
|
|||||||
signing_in: Mutex<Option<Arc<AtomicBool>>>,
|
signing_in: Mutex<Option<Arc<AtomicBool>>>,
|
||||||
streams: Mutex<HashMap<u64, Arc<AtomicBool>>>,
|
streams: Mutex<HashMap<u64, Arc<AtomicBool>>>,
|
||||||
next_stream: AtomicU64,
|
next_stream: AtomicU64,
|
||||||
|
/// The terminal panel's sockets (`terminal.rs`), by the page's id for each.
|
||||||
|
terminals: terminal::Sessions,
|
||||||
sealed: sealed::Pages,
|
sealed: sealed::Pages,
|
||||||
watch: watch::Watch,
|
watch: watch::Watch,
|
||||||
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
|
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
|
||||||
@@ -271,6 +274,7 @@ async fn set_server(handle: AppHandle, address: String) -> Result<Status, String
|
|||||||
if before.as_deref() != Some(d.base.as_str()) {
|
if before.as_deref() != Some(d.base.as_str()) {
|
||||||
app.agent.stop();
|
app.agent.stop();
|
||||||
app.watch.reset();
|
app.watch.reset();
|
||||||
|
app.terminals.close_all();
|
||||||
}
|
}
|
||||||
*app.account.lock().unwrap() = server::load_account(&app.config_dir, &d.base);
|
*app.account.lock().unwrap() = server::load_account(&app.config_dir, &d.base);
|
||||||
*app.server.lock().unwrap() = Some(d);
|
*app.server.lock().unwrap() = Some(d);
|
||||||
@@ -380,6 +384,7 @@ fn sign_out(handle: AppHandle) {
|
|||||||
let account = app.account.lock().unwrap().take();
|
let account = app.account.lock().unwrap().take();
|
||||||
server::forget_account(&app.config_dir);
|
server::forget_account(&app.config_dir);
|
||||||
app.watch.reset();
|
app.watch.reset();
|
||||||
|
app.terminals.close_all();
|
||||||
if let Some(a) = account {
|
if let Some(a) = account {
|
||||||
std::thread::spawn(move || server::sign_out(&a.server, &a.token));
|
std::thread::spawn(move || server::sign_out(&a.server, &a.token));
|
||||||
}
|
}
|
||||||
@@ -391,6 +396,7 @@ fn token_refused(handle: &AppHandle) {
|
|||||||
let app = handle.state::<App>();
|
let app = handle.state::<App>();
|
||||||
if app.account.lock().unwrap().take().is_some() {
|
if app.account.lock().unwrap().take().is_some() {
|
||||||
server::forget_account(&app.config_dir);
|
server::forget_account(&app.config_dir);
|
||||||
|
app.terminals.close_all();
|
||||||
announce(handle);
|
announce(handle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -511,6 +517,71 @@ fn stream_close(app: tauri::State<'_, App>, id: u64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- The terminal panel ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Open a shell on a linked device, as the terminal panel asks: an agent chat's (`chat`), or the
|
||||||
|
/// one before the chat exists (`device` and `directory`). The socket is opened here, with the
|
||||||
|
/// app's token in its handshake; the page gets its frames as `terminal` events with this id --
|
||||||
|
/// `output` (base64), `control` (the server's JSON), and a last `end`.
|
||||||
|
#[tauri::command]
|
||||||
|
fn terminal_open(
|
||||||
|
handle: AppHandle,
|
||||||
|
chat: Option<String>,
|
||||||
|
device: Option<String>,
|
||||||
|
directory: Option<String>,
|
||||||
|
cols: u16,
|
||||||
|
rows: u16,
|
||||||
|
) -> Result<u64, ApiError> {
|
||||||
|
let app = handle.state::<App>();
|
||||||
|
let Some((app_url, token)) = app.door() else {
|
||||||
|
return Err(ApiError::new(401, "Not signed in."));
|
||||||
|
};
|
||||||
|
let target = match (chat, device) {
|
||||||
|
(Some(chat), None) => terminal::Target::Chat(chat),
|
||||||
|
(None, Some(device)) => terminal::Target::Device {
|
||||||
|
device,
|
||||||
|
directory: directory.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
_ => return Err(ApiError::new(400, "A chat or a device, not both.")),
|
||||||
|
};
|
||||||
|
let url = terminal::url(&app_url, &target, cols, rows)?;
|
||||||
|
let h = handle.clone();
|
||||||
|
let on_end = handle.clone();
|
||||||
|
Ok(app.terminals.open(
|
||||||
|
url,
|
||||||
|
token,
|
||||||
|
move |down| {
|
||||||
|
let _ = h.emit("terminal", down);
|
||||||
|
},
|
||||||
|
move |id, end| {
|
||||||
|
on_end.state::<App>().terminals.forget(id);
|
||||||
|
if end.unauthorized() {
|
||||||
|
token_refused(&on_end);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keys typed (or pasted) into a terminal, as text: sent to the shell as its bytes.
|
||||||
|
#[tauri::command]
|
||||||
|
fn terminal_input(app: tauri::State<'_, App>, id: u64, data: String) -> bool {
|
||||||
|
app.terminals
|
||||||
|
.send(id, terminal::Up::Keys(data.into_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal's size changed.
|
||||||
|
#[tauri::command]
|
||||||
|
fn terminal_resize(app: tauri::State<'_, App>, id: u64, cols: u16, rows: u16) -> bool {
|
||||||
|
app.terminals.send(id, terminal::Up::Resize(cols, rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page let go of a terminal: its socket is closed (the shell on the device lives on, as
|
||||||
|
/// the server keeps it).
|
||||||
|
#[tauri::command]
|
||||||
|
fn terminal_close(app: tauri::State<'_, App>, id: u64) {
|
||||||
|
app.terminals.close(id);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Attachments ----------------------------------------------------------------------------------
|
// --- Attachments ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
/// What became of one file sent as an attachment: the server's attachment, or why not. `reason`
|
/// What became of one file sent as an attachment: the server's attachment, or why not. `reason`
|
||||||
@@ -1560,6 +1631,10 @@ pub fn run() {
|
|||||||
terminal_word,
|
terminal_word,
|
||||||
stream_open,
|
stream_open,
|
||||||
stream_close,
|
stream_close,
|
||||||
|
terminal_open,
|
||||||
|
terminal_input,
|
||||||
|
terminal_resize,
|
||||||
|
terminal_close,
|
||||||
local_list,
|
local_list,
|
||||||
local_read,
|
local_read,
|
||||||
sealed_open,
|
sealed_open,
|
||||||
@@ -1621,6 +1696,7 @@ pub fn run() {
|
|||||||
signing_in: Mutex::new(None),
|
signing_in: Mutex::new(None),
|
||||||
streams: Mutex::new(HashMap::new()),
|
streams: Mutex::new(HashMap::new()),
|
||||||
next_stream: AtomicU64::new(0),
|
next_stream: AtomicU64::new(0),
|
||||||
|
terminals: terminal::Sessions::default(),
|
||||||
sealed: sealed::Pages::default(),
|
sealed: sealed::Pages::default(),
|
||||||
watch: watch::Watch::default(),
|
watch: watch::Watch::default(),
|
||||||
dropped: Mutex::new(Vec::new()),
|
dropped: Mutex::new(Vec::new()),
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
//! The terminal panel's socket: a shell on a linked device, through the server's app door.
|
||||||
|
//!
|
||||||
|
//! The server has two WebSockets for an app (`/api/v2`, behind the app's token):
|
||||||
|
//!
|
||||||
|
//! - `/chats/{chat}/terminal/ws` -- an agent chat's shell, the one the web page's panel opens
|
||||||
|
//! too, so the two share it;
|
||||||
|
//! - `/devices/{device}/terminal/ws?directory=` -- before the chat exists, on the device and in
|
||||||
|
//! the folder a new agent chat has chosen. The chat's first message takes that shell over.
|
||||||
|
//!
|
||||||
|
//! Both take `cols` and `rows`. Down: the shell's output as binary frames, and text frames
|
||||||
|
//! `{"t": "ready" | "closed" | "behind" | "error", ...}`. Up: keystrokes as binary,
|
||||||
|
//! `{"t": "resize", "cols", "rows"}` as text. A refused token closes with 4401.
|
||||||
|
//!
|
||||||
|
//! **The token stays here.** The socket is opened in Rust with `Authorization: Bearer` in the
|
||||||
|
//! handshake, and the page only ever gets the frames (as `terminal` events) and sends keys,
|
||||||
|
//! sizes and "close" through commands -- the same rule as every other request the page makes.
|
||||||
|
//! A page cannot open the socket itself: its content security policy allows it no connection
|
||||||
|
//! but the bridge.
|
||||||
|
//!
|
||||||
|
//! One thread per terminal owns its socket: it reads with a short timeout and, between reads,
|
||||||
|
//! sends what the page queued on its channel. Blocking sockets, no async runtime of its own.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use std::net::{TcpStream, ToSocketAddrs};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tungstenite::http::Uri;
|
||||||
|
use tungstenite::stream::MaybeTlsStream;
|
||||||
|
use tungstenite::{ClientRequestBuilder, Error as WsError, HandshakeError, Message, WebSocket};
|
||||||
|
|
||||||
|
use crate::server::{base64, ApiError};
|
||||||
|
|
||||||
|
/// The server's close code for a token it refused (`api/v2/terminal.py`).
|
||||||
|
pub const CLOSE_UNAUTHORIZED: u16 = 4401;
|
||||||
|
/// The most one keystroke message may be: the server's own limit (a paste, at most).
|
||||||
|
pub const MAX_INPUT: usize = 256 * 1024;
|
||||||
|
/// The longest directory the server takes (`api/terminal.py` `MAX_DIR_CHARS`).
|
||||||
|
const MAX_DIR: usize = 4096;
|
||||||
|
/// How long a read waits before the thread looks at what the page queued.
|
||||||
|
const POLL: Duration = Duration::from_millis(40);
|
||||||
|
/// How long a close waits for the server's answer before the socket is simply dropped.
|
||||||
|
const CLOSE_WAIT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
type Socket = WebSocket<MaybeTlsStream<TcpStream>>;
|
||||||
|
|
||||||
|
/// Where the shell is: an agent chat's, or a device's folder before the chat exists.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum Target {
|
||||||
|
Chat(String),
|
||||||
|
Device { device: String, directory: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An id as the server makes them (a chat's, a device token's): letters, digits, `-` and `_`.
|
||||||
|
fn id_ok(id: &str) -> bool {
|
||||||
|
!id.is_empty()
|
||||||
|
&& id.len() <= 128
|
||||||
|
&& id
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A query value, percent-encoded: every byte but the unreserved ones (RFC 3986 §2.3).
|
||||||
|
pub fn encode(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
for b in text.bytes() {
|
||||||
|
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
|
||||||
|
out.push(b as char);
|
||||||
|
} else {
|
||||||
|
out.push_str(&format!("%{b:02X}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The socket's address on the app door (`https://host/api/v2` -> `wss://host/api/v2/...`).
|
||||||
|
pub fn url(app: &str, target: &Target, cols: u16, rows: u16) -> Result<String, ApiError> {
|
||||||
|
let door = if let Some(rest) = app.strip_prefix("https://") {
|
||||||
|
format!("wss://{rest}")
|
||||||
|
} else if let Some(rest) = app.strip_prefix("http://") {
|
||||||
|
format!("ws://{rest}")
|
||||||
|
} else {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
400,
|
||||||
|
"The server's address is not http or https.",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let cols = cols.clamp(2, 1000);
|
||||||
|
let rows = rows.clamp(1, 500);
|
||||||
|
match target {
|
||||||
|
Target::Chat(chat) => {
|
||||||
|
if !id_ok(chat) {
|
||||||
|
return Err(ApiError::new(400, "Not a chat."));
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"{door}/chats/{chat}/terminal/ws?cols={cols}&rows={rows}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Target::Device { device, directory } => {
|
||||||
|
if !id_ok(device) {
|
||||||
|
return Err(ApiError::new(400, "Not a device."));
|
||||||
|
}
|
||||||
|
let directory = directory.trim();
|
||||||
|
if directory.is_empty() || directory.len() > MAX_DIR || directory.contains('\0') {
|
||||||
|
return Err(ApiError::new(400, "Not a folder."));
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"{door}/devices/{device}/terminal/ws?directory={}&cols={cols}&rows={rows}",
|
||||||
|
encode(directory)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the page asked the socket's thread to do.
|
||||||
|
pub enum Up {
|
||||||
|
Keys(Vec<u8>),
|
||||||
|
Resize(u16, u16),
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame for the page, as the `terminal` event: `output` (the bytes, base64), `control`
|
||||||
|
/// (the server's JSON), or `end` (how it ended: `{code, reason, status, error}`).
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct Down {
|
||||||
|
pub id: u64,
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub data: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a terminal's socket ended. `code` is the close frame's (4401: the token was refused),
|
||||||
|
/// `status` an HTTP status the handshake was answered with, `error` why it could not connect or
|
||||||
|
/// broke off.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||||
|
pub struct End {
|
||||||
|
pub code: Option<u16>,
|
||||||
|
pub reason: String,
|
||||||
|
pub status: Option<u16>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl End {
|
||||||
|
fn failed(error: impl Into<String>) -> Self {
|
||||||
|
End {
|
||||||
|
error: Some(error.into()),
|
||||||
|
..End::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server refused the app's token: the page signs in again.
|
||||||
|
pub fn unauthorized(&self) -> bool {
|
||||||
|
self.code == Some(CLOSE_UNAUTHORIZED) || self.status == Some(401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The open terminals: what each one's thread listens to.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Sessions {
|
||||||
|
next: AtomicU64,
|
||||||
|
open: Mutex<HashMap<u64, Sender<Up>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sessions {
|
||||||
|
/// Open a terminal's socket on its own thread. `emit` gets every frame, the `end` last;
|
||||||
|
/// `ended` is told how it ended just before. The id is the page's handle on it.
|
||||||
|
pub fn open(
|
||||||
|
&self,
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
emit: impl Fn(Down) + Send + 'static,
|
||||||
|
ended: impl FnOnce(u64, &End) + Send + 'static,
|
||||||
|
) -> u64 {
|
||||||
|
let id = self.next.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
self.open.lock().unwrap().insert(id, tx);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let send = |kind: &'static str, data: Value| emit(Down { id, kind, data });
|
||||||
|
let end = run(&url, &token, &rx, &send);
|
||||||
|
ended(id, &end);
|
||||||
|
send("end", serde_json::to_value(&end).unwrap_or(Value::Null));
|
||||||
|
});
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand the page's keys, size or close to the terminal's thread. False: no such terminal.
|
||||||
|
pub fn send(&self, id: u64, up: Up) -> bool {
|
||||||
|
self.open
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&id)
|
||||||
|
.is_some_and(|tx| tx.send(up).is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close one terminal (the page's panel let go of it).
|
||||||
|
pub fn close(&self, id: u64) {
|
||||||
|
if let Some(tx) = self.open.lock().unwrap().remove(&id) {
|
||||||
|
let _ = tx.send(Up::Close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A terminal's thread has ended: forget it.
|
||||||
|
pub fn forget(&self, id: u64) {
|
||||||
|
self.open.lock().unwrap().remove(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every terminal closed (signed out, or another server chosen).
|
||||||
|
pub fn close_all(&self) {
|
||||||
|
for (_, tx) in self.open.lock().unwrap().drain() {
|
||||||
|
let _ = tx.send(Up::Close);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The TCP socket under the WebSocket, to set its read timeout.
|
||||||
|
fn tcp(ws: &Socket) -> Option<&TcpStream> {
|
||||||
|
match ws.get_ref() {
|
||||||
|
MaybeTlsStream::Plain(s) => Some(s),
|
||||||
|
MaybeTlsStream::Rustls(s) => Some(s.get_ref()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connect(url: &str, token: &str) -> Result<Socket, End> {
|
||||||
|
let uri: Uri = url
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| End::failed(format!("Not an address: {e}")))?;
|
||||||
|
let host = uri
|
||||||
|
.host()
|
||||||
|
.ok_or_else(|| End::failed("The address has no host."))?
|
||||||
|
.trim_start_matches('[')
|
||||||
|
.trim_end_matches(']')
|
||||||
|
.to_string();
|
||||||
|
let port = uri
|
||||||
|
.port_u16()
|
||||||
|
.unwrap_or(if uri.scheme_str() == Some("wss") {
|
||||||
|
443
|
||||||
|
} else {
|
||||||
|
80
|
||||||
|
});
|
||||||
|
let addrs = (host.as_str(), port)
|
||||||
|
.to_socket_addrs()
|
||||||
|
.map_err(|e| End::failed(format!("Could not reach the server: {e}")))?;
|
||||||
|
let mut last = String::from("no address");
|
||||||
|
let mut stream = None;
|
||||||
|
for addr in addrs {
|
||||||
|
match TcpStream::connect_timeout(&addr, Duration::from_secs(10)) {
|
||||||
|
Ok(s) => {
|
||||||
|
stream = Some(s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => last = e.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stream =
|
||||||
|
stream.ok_or_else(|| End::failed(format!("Could not reach the server: {last}")))?;
|
||||||
|
// The handshake may take its time; reads only poll once it is done.
|
||||||
|
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
|
||||||
|
let _ = stream.set_nodelay(true);
|
||||||
|
let request = ClientRequestBuilder::new(uri)
|
||||||
|
.with_header("Authorization", format!("Bearer {token}"))
|
||||||
|
.with_header(
|
||||||
|
"User-Agent",
|
||||||
|
format!("llembas-desktop/{}", env!("CARGO_PKG_VERSION")),
|
||||||
|
);
|
||||||
|
match tungstenite::client_tls(request, stream) {
|
||||||
|
Ok((ws, _)) => Ok(ws),
|
||||||
|
Err(HandshakeError::Failure(WsError::Http(res))) => Err(End {
|
||||||
|
status: Some(res.status().as_u16()),
|
||||||
|
..End::default()
|
||||||
|
}),
|
||||||
|
Err(HandshakeError::Failure(e)) => Err(End::failed(format!("Could not connect: {e}"))),
|
||||||
|
Err(HandshakeError::Interrupted(_)) => Err(End::failed("The server did not answer.")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A read that only timed out: nothing came in this poll.
|
||||||
|
fn idle(e: &WsError) -> bool {
|
||||||
|
matches!(e, WsError::Io(io) if matches!(io.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(url: &str, token: &str, rx: &Receiver<Up>, send: &dyn Fn(&'static str, Value)) -> End {
|
||||||
|
let mut ws = match connect(url, token) {
|
||||||
|
Ok(ws) => ws,
|
||||||
|
Err(end) => return end,
|
||||||
|
};
|
||||||
|
match tcp(&ws) {
|
||||||
|
Some(s) => {
|
||||||
|
let _ = s.set_read_timeout(Some(POLL));
|
||||||
|
}
|
||||||
|
None => return End::failed("An unexpected kind of connection."),
|
||||||
|
}
|
||||||
|
let mut closed_by_server: Option<(u16, String)> = None;
|
||||||
|
let mut closing: Option<Instant> = None;
|
||||||
|
loop {
|
||||||
|
// What the page queued, until the server has said goodbye.
|
||||||
|
if closing.is_none() && closed_by_server.is_none() {
|
||||||
|
loop {
|
||||||
|
let sent = match rx.try_recv() {
|
||||||
|
Ok(Up::Keys(bytes)) if bytes.len() <= MAX_INPUT => {
|
||||||
|
ws.send(Message::binary(bytes))
|
||||||
|
}
|
||||||
|
Ok(Up::Keys(_)) => Ok(()),
|
||||||
|
Ok(Up::Resize(cols, rows)) => ws.send(Message::text(
|
||||||
|
json!({ "t": "resize", "cols": cols, "rows": rows }).to_string(),
|
||||||
|
)),
|
||||||
|
Ok(Up::Close) | Err(TryRecvError::Disconnected) => {
|
||||||
|
closing = Some(Instant::now());
|
||||||
|
let _ = ws.close(None);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => break,
|
||||||
|
};
|
||||||
|
if let Err(e) = sent {
|
||||||
|
if !idle(&e) {
|
||||||
|
return End::failed(format!("The connection broke off: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match ws.read() {
|
||||||
|
Ok(Message::Binary(bytes)) => send("output", Value::String(base64(&bytes))),
|
||||||
|
Ok(Message::Text(text)) => {
|
||||||
|
if let Ok(value) = serde_json::from_str::<Value>(text.as_str()) {
|
||||||
|
send("control", value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Message::Close(frame)) => {
|
||||||
|
let (code, reason) = frame
|
||||||
|
.map(|f| (u16::from(f.code), f.reason.as_str().to_string()))
|
||||||
|
.unwrap_or((1005, String::new()));
|
||||||
|
closed_by_server = Some((code, reason));
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) if idle(&e) => {
|
||||||
|
if closing.is_some_and(|at| at.elapsed() > CLOSE_WAIT) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(WsError::ConnectionClosed) | Err(WsError::AlreadyClosed) => break,
|
||||||
|
Err(e) => {
|
||||||
|
if closing.is_some() || closed_by_server.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return End::failed(format!("The connection broke off: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match closed_by_server {
|
||||||
|
Some((code, reason)) => End {
|
||||||
|
code: Some(code),
|
||||||
|
reason,
|
||||||
|
..End::default()
|
||||||
|
},
|
||||||
|
None => End::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_chats_terminal_is_on_the_app_door_with_its_size() {
|
||||||
|
let t = Target::Chat("chat_1-a".into());
|
||||||
|
assert_eq!(
|
||||||
|
url("https://ai.example/api/v2", &t, 80, 24).unwrap(),
|
||||||
|
"wss://ai.example/api/v2/chats/chat_1-a/terminal/ws?cols=80&rows=24"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url("http://ai.example:8080/sub/api/v2", &t, 0, 9999).unwrap(),
|
||||||
|
"ws://ai.example:8080/sub/api/v2/chats/chat_1-a/terminal/ws?cols=2&rows=500"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_terminal_before_the_chat_carries_its_folder_encoded() {
|
||||||
|
let t = Target::Device {
|
||||||
|
device: "token1".into(),
|
||||||
|
directory: " /home/dev/my project&x=1 ".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
url("https://ai.example/api/v2", &t, 100, 30).unwrap(),
|
||||||
|
"wss://ai.example/api/v2/devices/token1/terminal/ws?directory=%2Fhome%2Fdev%2Fmy%20project%26x%3D1&cols=100&rows=30"
|
||||||
|
);
|
||||||
|
assert_eq!(encode("C:\\Users\\Ádám"), "C%3A%5CUsers%5C%C3%81d%C3%A1m");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_but_an_id_and_a_folder_reaches_the_address() {
|
||||||
|
let door = "https://ai.example/api/v2";
|
||||||
|
for bad in ["", "../me", "a/b", "a?b", "a b", "x#y"] {
|
||||||
|
assert!(
|
||||||
|
url(door, &Target::Chat(bad.into()), 80, 24).is_err(),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
|
let dev = Target::Device {
|
||||||
|
device: bad.into(),
|
||||||
|
directory: "/tmp".into(),
|
||||||
|
};
|
||||||
|
assert!(url(door, &dev, 80, 24).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
for dir in ["", " ", "a\0b"] {
|
||||||
|
let dev = Target::Device {
|
||||||
|
device: "token1".into(),
|
||||||
|
directory: dir.into(),
|
||||||
|
};
|
||||||
|
assert!(url(door, &dev, 80, 24).is_err(), "{dir:?}");
|
||||||
|
}
|
||||||
|
assert!(url("ftp://ai.example", &Target::Chat("c".into()), 80, 24).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refused_token_is_told_apart_from_a_dropped_line() {
|
||||||
|
let refused = End {
|
||||||
|
code: Some(CLOSE_UNAUTHORIZED),
|
||||||
|
..End::default()
|
||||||
|
};
|
||||||
|
assert!(refused.unauthorized());
|
||||||
|
assert!(End {
|
||||||
|
status: Some(401),
|
||||||
|
..End::default()
|
||||||
|
}
|
||||||
|
.unauthorized());
|
||||||
|
assert!(!End::failed("reset").unauthorized());
|
||||||
|
assert!(!End {
|
||||||
|
code: Some(1000),
|
||||||
|
..End::default()
|
||||||
|
}
|
||||||
|
.unauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_for_a_closed_terminal_go_nowhere() {
|
||||||
|
let s = Sessions::default();
|
||||||
|
assert!(!s.send(7, Up::Keys(b"ls\n".to_vec())));
|
||||||
|
s.close(7);
|
||||||
|
s.close_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
{
|
||||||
|
"about": "Progress (progress.md, spec 3.4.0): the text and the line every client draws for a progress object. `progress`: the object; `now`: the client's clock (Unix ms). Expect `text` — the phase's text from loop.json progress.texts, its fields filled (numbers written plainly: integers as they are, a rate with one decimal) — and `line` — `{{text}} · {{elapsed}}` with elapsed = now − since in whole seconds (5s, 1m 05s, 1h 02m), or the text alone for stopped and failed. retrying's `in` is counted down: in − floor((now − since) / 1000), never below 0. LLeMbas: services/progress.py; CLI: session/progress.ts.",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"about": "Queued, nothing more known.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "queued",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Queued",
|
||||||
|
"line": "Queued · 5s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Queued behind two turns.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "queued",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"ahead": 2
|
||||||
|
},
|
||||||
|
"now": 1762790465000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Queued: 2 ahead",
|
||||||
|
"line": "Queued: 2 ahead · 1m 05s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A one-request server answering another request.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_model",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"model": "Qwen 27B",
|
||||||
|
"reason": "busy"
|
||||||
|
},
|
||||||
|
"now": 1762790412400,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for Qwen 27B: it serves one request at a time and is answering another",
|
||||||
|
"line": "Waiting for Qwen 27B: it serves one request at a time and is answering another · 12s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Every slot taken.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_model",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"model": "coder",
|
||||||
|
"reason": "slots"
|
||||||
|
},
|
||||||
|
"now": 1762790401000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for coder: all its slots are in use",
|
||||||
|
"line": "Waiting for coder: all its slots are in use · 1s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Another model on the one-model server.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_model",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"model": "coder",
|
||||||
|
"reason": "group"
|
||||||
|
},
|
||||||
|
"now": 1762790400000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for coder: its server holds another model now",
|
||||||
|
"line": "Waiting for coder: its server holds another model now · 0s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Loading.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "loading_model",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"model": "Gemma 12B"
|
||||||
|
},
|
||||||
|
"now": 1762794125000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Loading Gemma 12B",
|
||||||
|
"line": "Loading Gemma 12B · 1h 02m"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Reading the prompt: the percentage is floored.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "reading_prompt",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"done": 1999,
|
||||||
|
"total": 3000
|
||||||
|
},
|
||||||
|
"now": 1762790402000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Reading the prompt: 1999 / 3000 tokens (66%)",
|
||||||
|
"line": "Reading the prompt: 1999 / 3000 tokens (66%) · 2s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Reading the prompt with no counts from the server.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "reading_prompt",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790402000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Reading the prompt",
|
||||||
|
"line": "Reading the prompt · 2s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Thinking.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "thinking",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790459999,
|
||||||
|
"expect": {
|
||||||
|
"text": "Thinking",
|
||||||
|
"line": "Thinking · 59s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Writing, the rate with one decimal.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "writing",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"rate": 12.46
|
||||||
|
},
|
||||||
|
"now": 1762790404000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Writing: 12.5 tok/s",
|
||||||
|
"line": "Writing: 12.5 tok/s · 4s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Writing before a rate is measured.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "writing",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790400400,
|
||||||
|
"expect": {
|
||||||
|
"text": "Writing",
|
||||||
|
"line": "Writing · 0s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A tool with what it does.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "running_tool",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"tool": "bash",
|
||||||
|
"what": "bun test"
|
||||||
|
},
|
||||||
|
"now": 1762790461000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Running bash: bun test",
|
||||||
|
"line": "Running bash: bun test · 1m 01s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A tool without a title.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "running_tool",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"tool": "recall"
|
||||||
|
},
|
||||||
|
"now": 1762790401000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Running recall",
|
||||||
|
"line": "Running recall · 1s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An approval, naming the tool.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"what": "approval",
|
||||||
|
"tool": "bash"
|
||||||
|
},
|
||||||
|
"now": 1762790430000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for you: allow bash?",
|
||||||
|
"line": "Waiting for you: allow bash? · 30s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "An approval with no tool named.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"what": "approval"
|
||||||
|
},
|
||||||
|
"now": 1762790400000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for you: an approval",
|
||||||
|
"line": "Waiting for you: an approval · 0s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "A question.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"what": "question"
|
||||||
|
},
|
||||||
|
"now": 1762790403000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for you: a question",
|
||||||
|
"line": "Waiting for you: a question · 3s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "The plan.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"what": "plan"
|
||||||
|
},
|
||||||
|
"now": 1762790403000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for you: the plan",
|
||||||
|
"line": "Waiting for you: the plan · 3s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Subagents.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_subagents",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"running": 3
|
||||||
|
},
|
||||||
|
"now": 1762790490000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Waiting for subagents: 3 running",
|
||||||
|
"line": "Waiting for subagents: 3 running · 1m 30s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Compacting.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "compacting",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790407000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Compacting the conversation",
|
||||||
|
"line": "Compacting the conversation · 7s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Retrying: `in` counts down from since.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "retrying",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "502 Bad Gateway",
|
||||||
|
"in": 10,
|
||||||
|
"attempt": 2
|
||||||
|
},
|
||||||
|
"now": 1762790403000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Retrying in 7 s: 502 Bad Gateway",
|
||||||
|
"line": "Retrying in 7 s: 502 Bad Gateway · 3s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Retrying: never below 0.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "retrying",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "overloaded",
|
||||||
|
"in": 2
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Retrying in 0 s: overloaded",
|
||||||
|
"line": "Retrying in 0 s: overloaded · 5s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Stopped draws no elapsed time.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "stopped",
|
||||||
|
"since": 1762790400000
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Stopped",
|
||||||
|
"line": "Stopped"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Stopped by the person.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "stopped",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "person"
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Stopped: by you",
|
||||||
|
"line": "Stopped: by you"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Stopped at the step limit.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "stopped",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "steps"
|
||||||
|
},
|
||||||
|
"now": 1762790400000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Stopped: step limit",
|
||||||
|
"line": "Stopped: step limit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"about": "Failed.",
|
||||||
|
"progress": {
|
||||||
|
"phase": "failed",
|
||||||
|
"since": 1762790400000,
|
||||||
|
"reason": "connection refused"
|
||||||
|
},
|
||||||
|
"now": 1762790405000,
|
||||||
|
"expect": {
|
||||||
|
"text": "Failed: connection refused",
|
||||||
|
"line": "Failed: connection refused"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
LLeMbas v2.0.0-beta.8 (d06ca353b801916302f0ce0cc9ae3e5244ff184f) tests/fixtures/api_v2
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"id": "chat2",
|
||||||
|
"kind": "agent",
|
||||||
|
"title": "Waybread",
|
||||||
|
"model": "qwen",
|
||||||
|
"folder_id": null,
|
||||||
|
"pinned": false,
|
||||||
|
"archived": false,
|
||||||
|
"temporary": false,
|
||||||
|
"unread": true,
|
||||||
|
"running": true,
|
||||||
|
"mode": "edit",
|
||||||
|
"effort": "low",
|
||||||
|
"device": {
|
||||||
|
"token_id": "token1",
|
||||||
|
"directory": "/home/dev/project"
|
||||||
|
},
|
||||||
|
"device_session_id": "ses_01waybread",
|
||||||
|
"parent_chat_id": null,
|
||||||
|
"agents": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 4
|
||||||
|
},
|
||||||
|
"subagents": {
|
||||||
|
"offered": true,
|
||||||
|
"named_only": false,
|
||||||
|
"cap": 32
|
||||||
|
},
|
||||||
|
"created_at": "2026-01-02T03:04:05+00:00",
|
||||||
|
"updated_at": "2026-01-02T03:04:05+00:00"
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
{
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": "card1",
|
||||||
|
"kind": "question",
|
||||||
|
"why": "",
|
||||||
|
"dismissable": false,
|
||||||
|
"expires_in": null,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"key": "q0",
|
||||||
|
"kind": "question",
|
||||||
|
"tool": "ask_user",
|
||||||
|
"title": "Honey or not?",
|
||||||
|
"header": "Taste",
|
||||||
|
"detail": "",
|
||||||
|
"reason": "",
|
||||||
|
"purpose": "",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"label": "Honey",
|
||||||
|
"description": "",
|
||||||
|
"recommended": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Plain",
|
||||||
|
"description": "As the elves have it",
|
||||||
|
"recommended": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multiple": false,
|
||||||
|
"free_text": true,
|
||||||
|
"editable": false,
|
||||||
|
"plan": null,
|
||||||
|
"mode_choice": true,
|
||||||
|
"job": "",
|
||||||
|
"subagent": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card2",
|
||||||
|
"kind": "approval",
|
||||||
|
"why": "",
|
||||||
|
"dismissable": false,
|
||||||
|
"expires_in": null,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"key": "0",
|
||||||
|
"kind": "approval",
|
||||||
|
"tool": "bash",
|
||||||
|
"title": "Run a command",
|
||||||
|
"header": "",
|
||||||
|
"detail": "rm -rf build",
|
||||||
|
"reason": "Edit mode asks before anything that runs a command.",
|
||||||
|
"purpose": "Start clean",
|
||||||
|
"options": [],
|
||||||
|
"multiple": false,
|
||||||
|
"free_text": false,
|
||||||
|
"editable": true,
|
||||||
|
"plan": null,
|
||||||
|
"mode_choice": true,
|
||||||
|
"job": "explore (job1)",
|
||||||
|
"subagent": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card3",
|
||||||
|
"kind": "plan",
|
||||||
|
"why": "",
|
||||||
|
"dismissable": true,
|
||||||
|
"expires_in": null,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"key": "p0",
|
||||||
|
"kind": "plan",
|
||||||
|
"tool": "plan_submit",
|
||||||
|
"title": "Bake lembas",
|
||||||
|
"header": "",
|
||||||
|
"detail": "",
|
||||||
|
"reason": "",
|
||||||
|
"purpose": "",
|
||||||
|
"options": [],
|
||||||
|
"multiple": false,
|
||||||
|
"free_text": true,
|
||||||
|
"editable": false,
|
||||||
|
"plan": {
|
||||||
|
"version": 2,
|
||||||
|
"title": "Bake lembas",
|
||||||
|
"summary": "",
|
||||||
|
"findings": [],
|
||||||
|
"objectives": [],
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"id": "p1",
|
||||||
|
"title": "Plan",
|
||||||
|
"status": "pending",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "t1",
|
||||||
|
"text": "Gather mallorn leaves",
|
||||||
|
"status": "todo",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t2",
|
||||||
|
"text": "Bake slowly",
|
||||||
|
"status": "todo",
|
||||||
|
"note": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Gather mallorn leaves",
|
||||||
|
"Bake slowly"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mode_choice": true,
|
||||||
|
"job": "",
|
||||||
|
"subagent": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card4",
|
||||||
|
"kind": "approval",
|
||||||
|
"why": "",
|
||||||
|
"dismissable": false,
|
||||||
|
"expires_in": null,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"key": "0",
|
||||||
|
"kind": "approval",
|
||||||
|
"tool": "bash",
|
||||||
|
"title": "Run a command",
|
||||||
|
"header": "",
|
||||||
|
"detail": "git push",
|
||||||
|
"reason": "Edit mode asks before anything that runs a command.",
|
||||||
|
"purpose": "",
|
||||||
|
"options": [],
|
||||||
|
"multiple": false,
|
||||||
|
"free_text": false,
|
||||||
|
"editable": true,
|
||||||
|
"plan": null,
|
||||||
|
"mode_choice": true,
|
||||||
|
"job": "",
|
||||||
|
"subagent": {
|
||||||
|
"id": "s1",
|
||||||
|
"name": "parser",
|
||||||
|
"position": "reviews the parser"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"text": "Running bake --slow",
|
||||||
|
"progress": {
|
||||||
|
"phase": "running_tool",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"tool": "bash",
|
||||||
|
"what": "bake --slow"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"text": "",
|
||||||
|
"progress": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"text": "Waiting for you to allow bash…",
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_person",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"what": "question"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"text": "",
|
||||||
|
"progress": {
|
||||||
|
"phase": "stopped",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"reason": "person"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"tree": {
|
||||||
|
"min": 1,
|
||||||
|
"max": 4,
|
||||||
|
"size": 2,
|
||||||
|
"open_slots": 1,
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"id": "lead",
|
||||||
|
"name": "lead",
|
||||||
|
"position": "the main agent",
|
||||||
|
"parent": null,
|
||||||
|
"state": "working",
|
||||||
|
"model": "qwen-27b",
|
||||||
|
"mode": "edit",
|
||||||
|
"request": [],
|
||||||
|
"manager": true,
|
||||||
|
"context": {
|
||||||
|
"used": 41200,
|
||||||
|
"window": 131072
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"phase": "thinking",
|
||||||
|
"since": 1767323045000
|
||||||
|
},
|
||||||
|
"findings": 0,
|
||||||
|
"messages": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s2",
|
||||||
|
"name": "parser",
|
||||||
|
"position": "reviews the parser",
|
||||||
|
"parent": "lead",
|
||||||
|
"state": "working",
|
||||||
|
"model": "qwen-27b",
|
||||||
|
"mode": "manual",
|
||||||
|
"task": "Review src/parse.ts.",
|
||||||
|
"request": [
|
||||||
|
"write"
|
||||||
|
],
|
||||||
|
"manager": false,
|
||||||
|
"context": {
|
||||||
|
"used": 9100,
|
||||||
|
"window": 131072
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"phase": "running_tool",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"tool": "read",
|
||||||
|
"what": "src/parse.ts"
|
||||||
|
},
|
||||||
|
"findings": 2,
|
||||||
|
"messages": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ended": [
|
||||||
|
{
|
||||||
|
"id": "s1",
|
||||||
|
"name": "scout",
|
||||||
|
"position": "maps the code",
|
||||||
|
"parent": "lead",
|
||||||
|
"state": "done",
|
||||||
|
"model": "qwen-27b",
|
||||||
|
"mode": "edit",
|
||||||
|
"task": "Map it.",
|
||||||
|
"request": [],
|
||||||
|
"manager": false,
|
||||||
|
"progress": null,
|
||||||
|
"findings": 1,
|
||||||
|
"messages": 1,
|
||||||
|
"ended": 1767323045000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "qwen",
|
||||||
|
"name": "Qwen",
|
||||||
|
"vision": false,
|
||||||
|
"tools": true,
|
||||||
|
"efforts": [
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
|
"thinking_switch": true,
|
||||||
|
"levels": [
|
||||||
|
"off",
|
||||||
|
"medium"
|
||||||
|
],
|
||||||
|
"effort": "medium",
|
||||||
|
"control": "switch",
|
||||||
|
"parallel": 2,
|
||||||
|
"context": 131072,
|
||||||
|
"image": "/api/v2/models/images/0a1b2c3d4e5f.png",
|
||||||
|
"default": true,
|
||||||
|
"subagents": {
|
||||||
|
"offered": true,
|
||||||
|
"named_only": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-oss",
|
||||||
|
"name": "gpt-oss",
|
||||||
|
"vision": false,
|
||||||
|
"tools": true,
|
||||||
|
"efforts": [
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high"
|
||||||
|
],
|
||||||
|
"thinking_switch": false,
|
||||||
|
"levels": [
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high"
|
||||||
|
],
|
||||||
|
"effort": "high",
|
||||||
|
"control": "slider",
|
||||||
|
"parallel": 1,
|
||||||
|
"context": 0,
|
||||||
|
"image": null,
|
||||||
|
"default": false,
|
||||||
|
"subagents": {
|
||||||
|
"offered": true,
|
||||||
|
"named_only": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vision-small",
|
||||||
|
"name": "Vision",
|
||||||
|
"vision": true,
|
||||||
|
"tools": false,
|
||||||
|
"efforts": [],
|
||||||
|
"thinking_switch": false,
|
||||||
|
"levels": [
|
||||||
|
"default"
|
||||||
|
],
|
||||||
|
"effort": "default",
|
||||||
|
"control": "none",
|
||||||
|
"parallel": null,
|
||||||
|
"context": 8192,
|
||||||
|
"image": null,
|
||||||
|
"default": false,
|
||||||
|
"subagents": {
|
||||||
|
"offered": true,
|
||||||
|
"named_only": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subagents_cap": 32,
|
||||||
|
"agent_subagents": {
|
||||||
|
"offered": true,
|
||||||
|
"device_runs": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "s2",
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"n": 1,
|
||||||
|
"text": "Login is POST /session.",
|
||||||
|
"time": 1767323045000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"id": "s2",
|
||||||
|
"state": "working",
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"text": "Write the routes.",
|
||||||
|
"machine": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"text": "Reading the API first.",
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "read",
|
||||||
|
"title": "src/api.py",
|
||||||
|
"isError": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"error": "",
|
||||||
|
"running": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"tree": {
|
||||||
|
"min": 2,
|
||||||
|
"max": 4,
|
||||||
|
"size": 3,
|
||||||
|
"open_slots": 0,
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"id": "lead",
|
||||||
|
"name": "lead",
|
||||||
|
"position": "the main agent",
|
||||||
|
"parent": null,
|
||||||
|
"state": "working",
|
||||||
|
"model": "local/qwen",
|
||||||
|
"mode": "auto",
|
||||||
|
"request": [],
|
||||||
|
"manager": true,
|
||||||
|
"progress": {
|
||||||
|
"phase": "waiting_for_subagents",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"running": 2
|
||||||
|
},
|
||||||
|
"findings": 0,
|
||||||
|
"messages": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s1",
|
||||||
|
"name": "area",
|
||||||
|
"position": "owns the API",
|
||||||
|
"parent": "lead",
|
||||||
|
"state": "working",
|
||||||
|
"model": "local/qwen",
|
||||||
|
"mode": "auto",
|
||||||
|
"request": [],
|
||||||
|
"manager": true,
|
||||||
|
"task": "Direct it.",
|
||||||
|
"progress": null,
|
||||||
|
"findings": 0,
|
||||||
|
"messages": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "s2",
|
||||||
|
"name": "routes",
|
||||||
|
"position": "writes the routes",
|
||||||
|
"parent": "area",
|
||||||
|
"state": "working",
|
||||||
|
"model": "local/qwen",
|
||||||
|
"mode": "edit",
|
||||||
|
"request": [
|
||||||
|
"execute"
|
||||||
|
],
|
||||||
|
"manager": false,
|
||||||
|
"task": "Write the routes.",
|
||||||
|
"context": {
|
||||||
|
"used": 9100,
|
||||||
|
"window": 131072
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"phase": "running_tool",
|
||||||
|
"since": 1767323045000,
|
||||||
|
"tool": "read",
|
||||||
|
"what": "src/api.py"
|
||||||
|
},
|
||||||
|
"findings": 1,
|
||||||
|
"messages": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ended": [
|
||||||
|
{
|
||||||
|
"id": "s3",
|
||||||
|
"name": "scout",
|
||||||
|
"position": "maps the code",
|
||||||
|
"parent": "lead",
|
||||||
|
"state": "done",
|
||||||
|
"model": "local/qwen",
|
||||||
|
"mode": "auto",
|
||||||
|
"request": [],
|
||||||
|
"manager": false,
|
||||||
|
"task": "Map it.",
|
||||||
|
"progress": null,
|
||||||
|
"findings": 0,
|
||||||
|
"messages": 1,
|
||||||
|
"ended": 1767323045000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"room": [
|
||||||
|
{
|
||||||
|
"n": 1,
|
||||||
|
"from": "routes",
|
||||||
|
"to": [
|
||||||
|
"area"
|
||||||
|
],
|
||||||
|
"text": "Routes are in.",
|
||||||
|
"report": false,
|
||||||
|
"kind": "message",
|
||||||
|
"time": 1767323045000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n": 2,
|
||||||
|
"from": "scout",
|
||||||
|
"to": [
|
||||||
|
"lead"
|
||||||
|
],
|
||||||
|
"text": "Mapped: see map.md.",
|
||||||
|
"report": true,
|
||||||
|
"kind": "report",
|
||||||
|
"time": 1767323045000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"live": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// A stand-in for Tauri's bridge, for the page under a headless browser (tests/ui/run.mjs). Runs
|
||||||
|
// before the page's own scripts: `window.__TAURI__` answers the commands the page invokes from a
|
||||||
|
// table the test hands in, and records every call, so a test can say what the page asked for.
|
||||||
|
//
|
||||||
|
// The table (`config`):
|
||||||
|
// status what `get_status` answers (the app, the agent, the server)
|
||||||
|
// routes { "GET /me": value, "POST /chats/c1/interactions/k1": value, ... } -- the server's
|
||||||
|
// /api/v2 as the page reaches it through `api`. A path is looked up with its query,
|
||||||
|
// then without it. A value `{ __error: { status, detail } }` is a refusal. Anything
|
||||||
|
// not in the table is a 404, as an older server would answer.
|
||||||
|
// streams { "/chats/c1/messages/m1/stream": [[event, data], ...] } -- the frames a followed
|
||||||
|
// reply sends as soon as it is opened. More can be sent later with `__mock.frame()`.
|
||||||
|
// terminal [[kind, data], ...] -- what every terminal's socket sends as soon as it is opened
|
||||||
|
// (`control` frames as the server's JSON, `output` as text, sent base64 as Rust does).
|
||||||
|
// More with `__mock.shell(kind, data)`; `{ refuse: {status, detail} }` makes
|
||||||
|
// `terminal_open` fail.
|
||||||
|
//
|
||||||
|
// Nothing here talks to a network: the page's policy allows it none, and this keeps it so.
|
||||||
|
;(function () {
|
||||||
|
const config = window.__mockConfig || {}
|
||||||
|
const listeners = new Map() // event name -> [callback]
|
||||||
|
const streams = new Map() // stream id -> path
|
||||||
|
const terminals = new Map() // terminal id -> { target, open }
|
||||||
|
let nextTerminal = 0
|
||||||
|
let nextStream = 0
|
||||||
|
const calls = []
|
||||||
|
|
||||||
|
function dispatch(name, payload) {
|
||||||
|
for (const cb of listeners.get(name) || []) cb({ event: name, payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
function route(method, path) {
|
||||||
|
const routes = config.routes || {}
|
||||||
|
const bare = path.split("?")[0]
|
||||||
|
for (const key of [`${method} ${path}`, `${method} ${bare}`]) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(routes, key)) {
|
||||||
|
const value = routes[key]
|
||||||
|
if (value && value.__error) return Promise.reject(value.__error)
|
||||||
|
return Promise.resolve(JSON.parse(JSON.stringify(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject({ status: 404, detail: `Not in the test's table: ${method} ${path}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shell's bytes, base64 as Rust sends them (`terminal.rs`): UTF-8 first.
|
||||||
|
const b64 = (text) => btoa(String.fromCharCode(...new TextEncoder().encode(text)))
|
||||||
|
|
||||||
|
function shell(kind, data, id) {
|
||||||
|
const ids = id ? [id] : [...terminals.keys()].filter((k) => terminals.get(k).open)
|
||||||
|
for (const tid of ids) {
|
||||||
|
if (kind === "end") terminals.get(tid).open = false
|
||||||
|
dispatch("terminal", { id: tid, kind, data: kind === "output" ? b64(data) : data })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(path, event, data) {
|
||||||
|
for (const [id, p] of streams) if (p === path) dispatch("stream", { id, event, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invoke(cmd, args) {
|
||||||
|
calls.push({ cmd, args: args === undefined ? null : JSON.parse(JSON.stringify(args)) })
|
||||||
|
switch (cmd) {
|
||||||
|
case "get_status":
|
||||||
|
return JSON.parse(JSON.stringify(config.status))
|
||||||
|
case "api":
|
||||||
|
return route(args.method, args.path)
|
||||||
|
case "api_text":
|
||||||
|
return route("GET", args.path)
|
||||||
|
case "stream_open": {
|
||||||
|
const id = ++nextStream
|
||||||
|
streams.set(id, args.path)
|
||||||
|
const frames = (config.streams || {})[args.path] || []
|
||||||
|
setTimeout(() => {
|
||||||
|
for (const [event, data] of frames) dispatch("stream", { id, event, data })
|
||||||
|
}, 0)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
case "stream_close":
|
||||||
|
streams.delete(args.id)
|
||||||
|
return null
|
||||||
|
case "terminal_open": {
|
||||||
|
const t = config.terminal || []
|
||||||
|
if (t.refuse) return Promise.reject(t.refuse)
|
||||||
|
const id = ++nextTerminal
|
||||||
|
terminals.set(id, { target: { chat: args.chat, device: args.device, directory: args.directory }, open: true })
|
||||||
|
setTimeout(() => {
|
||||||
|
for (const [kind, data] of t) shell(kind, data, id)
|
||||||
|
}, 0)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
case "terminal_input":
|
||||||
|
case "terminal_resize":
|
||||||
|
return !!terminals.get(args.id)?.open
|
||||||
|
case "terminal_close": {
|
||||||
|
const t = terminals.get(args.id)
|
||||||
|
if (t?.open) setTimeout(() => shell("end", { code: 1000, reason: "", status: null, error: null }, args.id), 0)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
case "model_image":
|
||||||
|
return ""
|
||||||
|
case "terminal_word":
|
||||||
|
return "lembas"
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = {
|
||||||
|
minimize: async () => {},
|
||||||
|
toggleMaximize: async () => {},
|
||||||
|
close: async () => {},
|
||||||
|
isMaximized: async () => false,
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__TAURI__ = {
|
||||||
|
core: { invoke },
|
||||||
|
event: {
|
||||||
|
listen: async (name, cb) => {
|
||||||
|
if (!listeners.has(name)) listeners.set(name, [])
|
||||||
|
listeners.get(name).push(cb)
|
||||||
|
return () => listeners.set(name, (listeners.get(name) || []).filter((x) => x !== cb))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
window: { getCurrentWindow: () => win },
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__mock = {
|
||||||
|
calls,
|
||||||
|
frame,
|
||||||
|
emit: dispatch,
|
||||||
|
/** The calls of one command, or of `api` with one method and path prefix. */
|
||||||
|
api: (method, prefix) => calls.filter((c) => c.cmd === "api" && c.args.method === method && c.args.path.startsWith(prefix)),
|
||||||
|
streams: () => [...streams.values()],
|
||||||
|
shell,
|
||||||
|
/** The terminals opened so far: [{ id, target, open }]. */
|
||||||
|
terminals: () => [...terminals].map(([id, t]) => ({ id, ...t })),
|
||||||
|
/** What was typed into a terminal, joined. */
|
||||||
|
typed: () => calls.filter((c) => c.cmd === "terminal_input").map((c) => c.args.data).join(""),
|
||||||
|
}
|
||||||
|
})()
|
||||||
@@ -0,0 +1,842 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// The page under a headless browser, against a mocked bridge (tests/ui/mock-tauri.js): what the
|
||||||
|
// person sees for a given answer of the server's /api/v2, and what the page sends back.
|
||||||
|
//
|
||||||
|
// node tests/ui/run.mjs [name-filter]
|
||||||
|
//
|
||||||
|
// Needs Playwright (`playwright` or `playwright-core`, from NODE_PATH or a node_modules beside
|
||||||
|
// the repository) and a Chromium: Playwright's own, or the one in $CHROMIUM (/usr/bin/chromium
|
||||||
|
// is tried when Playwright has none). Nothing is built and no server is started: the page's
|
||||||
|
// files are handed to the browser from ui/ as they are in the repository. With $SHOTS set to a
|
||||||
|
// directory, some tests leave a screenshot there.
|
||||||
|
//
|
||||||
|
// The shapes are the server's (`api/v2/serialise.py`, its fixtures in tests/fixtures/api_v2),
|
||||||
|
// with made-up names. Where the server's fixture differs from what its code sends, the test
|
||||||
|
// says so and follows the code.
|
||||||
|
|
||||||
|
import { createRequire } from "node:module"
|
||||||
|
import { readFileSync, existsSync } from "node:fs"
|
||||||
|
import { dirname, join, extname } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const UI = join(HERE, "..", "..", "ui")
|
||||||
|
const ORIGIN = "http://app.test"
|
||||||
|
const require = createRequire(join(process.cwd(), "noop.js"))
|
||||||
|
|
||||||
|
function playwright() {
|
||||||
|
for (const name of ["playwright", "playwright-core"]) {
|
||||||
|
try {
|
||||||
|
return require(name)
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
return createRequire(import.meta.url)(name)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
console.error("tests/ui: Playwright is not installed (npm install playwright-core, and put it on NODE_PATH)")
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launch() {
|
||||||
|
const { chromium } = playwright()
|
||||||
|
const paths = [process.env.CHROMIUM, "/usr/bin/chromium", "/usr/bin/chromium-browser"].filter(Boolean)
|
||||||
|
if (!process.env.CHROMIUM) {
|
||||||
|
try {
|
||||||
|
return await chromium.launch()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
for (const executablePath of paths) if (existsSync(executablePath)) return chromium.launch({ executablePath })
|
||||||
|
throw new Error("tests/ui: no Chromium found (set CHROMIUM)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of the server's /api/v2 contract fixtures, as copied by scripts/fetch-fixtures.sh. */
|
||||||
|
const fixture = (name) => JSON.parse(readFileSync(join(HERE, "fixtures", `${name}.json`), "utf8"))
|
||||||
|
|
||||||
|
const TYPES = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".mjs": "text/javascript", ".json": "application/json" }
|
||||||
|
|
||||||
|
// --- The server, as the tests' table -----------------------------------------------------------
|
||||||
|
|
||||||
|
const STAMP = "2026-01-02T03:04:05+00:00"
|
||||||
|
|
||||||
|
function status(over = {}) {
|
||||||
|
return {
|
||||||
|
version: "1.0.0-test",
|
||||||
|
server: "https://ai.example",
|
||||||
|
discovery: { name: "Example", app: "https://ai.example/api/v2", version: "2.0.0" },
|
||||||
|
refused: null,
|
||||||
|
checked: true,
|
||||||
|
account: { name: "Ada Example", email: "ada@example.org" },
|
||||||
|
signing_in: false,
|
||||||
|
linked: true,
|
||||||
|
agent: { state: "running", detail: null },
|
||||||
|
agent_wanted: true,
|
||||||
|
update: null,
|
||||||
|
remote: { enabled: true, roots: ["/home/dev/project"] },
|
||||||
|
theme: "moria",
|
||||||
|
host: "workbench",
|
||||||
|
notify: false,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUB = (offered, named_only = false) => ({ offered, named_only })
|
||||||
|
|
||||||
|
/** The models as `/api/v2/models` lists them; `subagents` per model as the server works it out. */
|
||||||
|
function models({ qwen = SUB(true), solo = SUB(true, true), plain = SUB(false) } = {}) {
|
||||||
|
const base = { vision: false, tools: true, efforts: ["low", "medium", "high"], thinking_switch: false, levels: ["low", "medium", "high"], effort: "medium", control: "slider", context: 131072, image: null }
|
||||||
|
return {
|
||||||
|
models: [
|
||||||
|
{ ...base, id: "qwen", name: "Qwen", parallel: 2, default: true, subagents: qwen },
|
||||||
|
{ ...base, id: "solo", name: "Solo", parallel: 1, default: false, subagents: solo },
|
||||||
|
{ ...base, id: "plain", name: "Plain", parallel: 1, default: false, subagents: plain },
|
||||||
|
],
|
||||||
|
subagents_cap: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatRow(over = {}) {
|
||||||
|
return {
|
||||||
|
id: "chat1",
|
||||||
|
kind: "chat",
|
||||||
|
title: "Waybread",
|
||||||
|
model: "qwen",
|
||||||
|
folder_id: null,
|
||||||
|
pinned: false,
|
||||||
|
archived: false,
|
||||||
|
temporary: false,
|
||||||
|
unread: false,
|
||||||
|
running: false,
|
||||||
|
mode: "",
|
||||||
|
effort: "medium",
|
||||||
|
device: null,
|
||||||
|
device_session_id: null,
|
||||||
|
parent_chat_id: null,
|
||||||
|
agents: { min: 1, max: 4 },
|
||||||
|
subagents: { offered: true, named_only: false, cap: 8 },
|
||||||
|
created_at: STAMP,
|
||||||
|
updated_at: STAMP,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentChat(over = {}) {
|
||||||
|
return chatRow({
|
||||||
|
id: "chat2",
|
||||||
|
kind: "agent",
|
||||||
|
title: "Tidy the build",
|
||||||
|
running: true,
|
||||||
|
mode: "edit",
|
||||||
|
device: { token_id: "token1", directory: "/home/dev/project" },
|
||||||
|
device_session_id: "ses_01",
|
||||||
|
// An agent chat's team runs on its device: the server offers no setting (team_run.may_have_team).
|
||||||
|
subagents: { offered: false, named_only: false, cap: 8 },
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMessage = (id, text) => ({ id, role: "user", created_at: STAMP, complete: true, stopped: false, error: null, queued: false, machine: false, origin: "", model: null, attachments: [], text })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A device's bash approval as `services/device_link.py:_permission` builds it and
|
||||||
|
* `api/v2/serialise.py:interruption` sends it: the title is the fixed "Run a command", the command
|
||||||
|
* is in `detail`, and it is `editable`. (The server's fixture `event_ask.json` puts the command in
|
||||||
|
* the title; its code does not.)
|
||||||
|
*/
|
||||||
|
function deviceBashCard(over = {}) {
|
||||||
|
return {
|
||||||
|
id: "card7",
|
||||||
|
kind: "approval",
|
||||||
|
why: "",
|
||||||
|
dismissable: false,
|
||||||
|
expires_in: 1800,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
key: "0",
|
||||||
|
kind: "approval",
|
||||||
|
tool: "bash",
|
||||||
|
title: "Run a command",
|
||||||
|
header: "",
|
||||||
|
detail: "ls -la build",
|
||||||
|
reason: "Edit mode asks before anything that runs a command.",
|
||||||
|
purpose: "See what is in the build directory",
|
||||||
|
options: [],
|
||||||
|
multiple: false,
|
||||||
|
free_text: true,
|
||||||
|
editable: true,
|
||||||
|
plan: null,
|
||||||
|
mode_choice: false,
|
||||||
|
job: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseRoutes(over = {}) {
|
||||||
|
return {
|
||||||
|
"GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "moria", language: "en", permissions: {} },
|
||||||
|
"GET /models": models(),
|
||||||
|
"GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", platform: "linux", terminal: true }] },
|
||||||
|
"GET /chats": { chats: [chatRow(), agentChat()], more: false },
|
||||||
|
"GET /schedules": { schedules: [] },
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The browser --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let browser
|
||||||
|
|
||||||
|
async function page(config, { width = 1280, height = 860 } = {}) {
|
||||||
|
const context = await browser.newContext({ viewport: { width, height } })
|
||||||
|
const p = await context.newPage()
|
||||||
|
const errors = []
|
||||||
|
p.on("pageerror", (e) => errors.push(String(e)))
|
||||||
|
await context.route(`${ORIGIN}/**`, (route) => {
|
||||||
|
const path = new URL(route.request().url()).pathname
|
||||||
|
const file = join(UI, path === "/" ? "index.html" : decodeURIComponent(path))
|
||||||
|
if (!file.startsWith(UI) || !existsSync(file)) return route.fulfill({ status: 404, body: "" })
|
||||||
|
return route.fulfill({ status: 200, contentType: TYPES[extname(file)] || "application/octet-stream", body: readFileSync(file) })
|
||||||
|
})
|
||||||
|
await p.addInitScript((c) => {
|
||||||
|
window.__mockConfig = c
|
||||||
|
}, config)
|
||||||
|
await p.addInitScript({ path: join(HERE, "mock-tauri.js") })
|
||||||
|
await p.goto(`${ORIGIN}/index.html`)
|
||||||
|
await p.waitForSelector("#shell:not([hidden])")
|
||||||
|
p.errors = errors
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openChatRow(p, id) {
|
||||||
|
await p.click(`.chatrow[data-id="${id}"]`)
|
||||||
|
await p.waitForSelector(`#messages .msg`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The tests ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const tests = []
|
||||||
|
const test = (name, fn) => tests.push({ name, fn })
|
||||||
|
|
||||||
|
const STREAM2 = "/chats/chat2/messages/m2/stream"
|
||||||
|
|
||||||
|
function agentChatOpen(frames, over = {}) {
|
||||||
|
return {
|
||||||
|
status: status(),
|
||||||
|
routes: baseRoutes({
|
||||||
|
"GET /chats/chat2": { chat: agentChat(), messages: [userMessage("m1", "What is in build?")], more: false, live: "m2" },
|
||||||
|
"GET /chats/chat1": { chat: chatRow(), messages: [userMessage("m5", "How is lembas made?")], more: false, live: null },
|
||||||
|
"POST /chats/chat2/interactions/card8": { answered: true, remembered: false, not_remembered: [] },
|
||||||
|
"GET /models?chat=chat2": models(),
|
||||||
|
"POST /chats/chat2/interactions/card7": { answered: true, remembered: false, not_remembered: [] },
|
||||||
|
...over,
|
||||||
|
}),
|
||||||
|
streams: { [STREAM2]: frames },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("A1: an agent chat's device bash approval shows as a card, the command editable, and Allow answers it", async () => {
|
||||||
|
const card = deviceBashCard()
|
||||||
|
const p = await page(
|
||||||
|
agentChatOpen([
|
||||||
|
["steps", { steps: [], tail: { thinking: false, text: "", label: "" } }],
|
||||||
|
["status", { text: "Waiting for you to allow bash…" }],
|
||||||
|
["ask", { cards: [card] }],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#cards:not([hidden]) .ask")
|
||||||
|
assert.equal(await p.textContent(".msg.is-live .msg__status"), "Waiting for you to allow bash…")
|
||||||
|
const cardBox = await p.locator("#cards .ask").boundingBox()
|
||||||
|
const view = p.viewportSize()
|
||||||
|
assert.ok(cardBox && cardBox.height > 40, "the card has a size")
|
||||||
|
assert.ok(cardBox.y >= 0 && cardBox.y + 20 <= view.height, `the card is on the screen (y=${cardBox.y})`)
|
||||||
|
assert.ok(await p.locator("#cards .ask").isVisible(), "the card is visible")
|
||||||
|
// The command is the item's `detail`, in the box to correct it; the title is not the command.
|
||||||
|
assert.equal(await p.inputValue("#cards .ask__edit"), "ls -la build")
|
||||||
|
const text = await p.textContent("#cards .ask")
|
||||||
|
assert.match(text, /bash/)
|
||||||
|
assert.match(text, /See what is in the build directory/, "the model's purpose is said")
|
||||||
|
assert.match(text, /Edit mode asks before anything that runs a command\./, "the server's reason is said")
|
||||||
|
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "a1-card.png") })
|
||||||
|
await p.click("#cards .ask__actions .btn--primary")
|
||||||
|
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/interactions/").length === 1)
|
||||||
|
const [post] = await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/interactions/"))
|
||||||
|
assert.equal(post.args.path, "/chats/chat2/interactions/card7")
|
||||||
|
assert.deepEqual(post.args.body, { verdict: "allow", choices: {}, texts: {} }, "an untouched command is not sent as an edit")
|
||||||
|
await p.waitForSelector("#cards", { state: "hidden" })
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A1: a corrected command is sent as the item's text; Deny sends deny", async () => {
|
||||||
|
const p = await page(agentChatOpen([["ask", { cards: [deviceBashCard()] }]]))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#cards .ask__edit")
|
||||||
|
await p.fill("#cards .ask__edit", "ls -la build/out")
|
||||||
|
await p.click("#cards .ask__actions .btn--primary")
|
||||||
|
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/interactions/").length === 1)
|
||||||
|
const [post] = await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/interactions/"))
|
||||||
|
assert.deepEqual(post.args.body, { verdict: "allow", choices: {}, texts: { 0: "ls -la build/out" } })
|
||||||
|
// The next one, refused.
|
||||||
|
await p.evaluate(([path, c]) => window.__mock.frame(path, "ask", { cards: [c] }), [STREAM2, deviceBashCard({ id: "card8" })])
|
||||||
|
await p.waitForSelector("#cards .ask__edit")
|
||||||
|
await p.click("#cards .ask__actions .btn >> text=Deny")
|
||||||
|
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/interactions/card8").length === 1)
|
||||||
|
const [deny] = await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/interactions/card8"))
|
||||||
|
assert.deepEqual(deny.args.body, { verdict: "deny", choices: {}, texts: {} })
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A1: a card that arrives while the person is typing keeps what they typed and is not drawn again by an unchanged frame", async () => {
|
||||||
|
const card = deviceBashCard()
|
||||||
|
const p = await page(agentChatOpen([["ask", { cards: [card] }]]))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#cards .ask__edit")
|
||||||
|
await p.fill("#cards .ask__edit", "ls build")
|
||||||
|
// The server sends the frame again when anything in it changes -- here only the countdown.
|
||||||
|
await p.evaluate(([path, c]) => window.__mock.frame(path, "ask", { cards: [{ ...c, expires_in: 1790 }] }), [STREAM2, card])
|
||||||
|
await p.evaluate(([path]) => window.__mock.frame(path, "status", { text: "Waiting for you to allow bash…" }), [STREAM2])
|
||||||
|
assert.equal(await p.inputValue("#cards .ask__edit"), "ls build")
|
||||||
|
assert.equal(await p.locator("#cards .ask").count(), 1)
|
||||||
|
// A second card joins the first without wiping it; the answered one goes.
|
||||||
|
await p.evaluate(([path, c]) => window.__mock.frame(path, "ask", { cards: [c, { ...c, id: "card8", items: [{ ...c.items[0], detail: "make clean" }] }] }), [STREAM2, card])
|
||||||
|
assert.equal(await p.locator("#cards .ask").count(), 2)
|
||||||
|
assert.equal(await p.inputValue("#cards .ask >> nth=0 >> .ask__edit"), "ls build")
|
||||||
|
await p.evaluate(([path]) => window.__mock.frame(path, "ask", { cards: [] }), [STREAM2])
|
||||||
|
await p.waitForSelector("#cards", { state: "hidden" })
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A1: reopening a chat whose reply waits shows the card from the stream's first pass", async () => {
|
||||||
|
const p = await page(agentChatOpen([["status", { text: "Waiting for you to allow bash…" }], ["ask", { cards: [deviceBashCard()] }]]))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#cards .ask")
|
||||||
|
// Away to another chat and back: the stream is opened again and its first pass carries the card.
|
||||||
|
await p.click('.chatrow[data-id="chat1"]').catch(() => {})
|
||||||
|
await p.waitForFunction(() => !document.querySelector("#cards .ask"))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#cards:not([hidden]) .ask")
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "stream_open" && c.args.path.includes("/messages/")).length), 2)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A1: a card at 390 px wide is on the screen and its buttons can be reached", async () => {
|
||||||
|
const p = await page(agentChatOpen([["ask", { cards: [deviceBashCard()] }]]), { width: 390, height: 844 })
|
||||||
|
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
|
||||||
|
await p.waitForSelector("#cards:not([hidden]) .ask")
|
||||||
|
const allow = await p.locator("#cards .ask__actions .btn--primary").boundingBox()
|
||||||
|
assert.ok(allow && allow.y + allow.height <= 844 && allow.x >= 0 && allow.x + allow.width <= 390, `Allow is on the screen: ${JSON.stringify(allow)}`)
|
||||||
|
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- A5: the Subagents setting ----------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The server decides (`services/team_run.py:offered`, `services/subagents.py:view`): subagents
|
||||||
|
// enabled on the instance, the person's permission, a model that calls tools, and some model that
|
||||||
|
// could serve one -- the chat's own serving two requests at once, or another off its one-model
|
||||||
|
// server (`named_only`: then the setting says a subagent needs a model named in the chat). It says
|
||||||
|
// so per model in `/models` (a new chat) and per chat in `/chats/{id}` (`subagents`). The web page
|
||||||
|
// reads the same function (`api/pages.py:_team_context`). The app shows what it is told.
|
||||||
|
|
||||||
|
async function newChatOn(p, kind, modelName) {
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.click(`.kind__tab >> text=${kind}`)
|
||||||
|
if (modelName) {
|
||||||
|
await p.click(".cchip--model")
|
||||||
|
await p.locator(".menu .menu__item", { hasText: modelName }).click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subagentsChip(p) {
|
||||||
|
// The chip comes after the others (an open chat's waits for the server's word).
|
||||||
|
await p.waitForTimeout(150)
|
||||||
|
return p.locator("#composer-chips .cchip--subagents")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSlider(p) {
|
||||||
|
await p.click("#composer-chips .cchip--subagents")
|
||||||
|
await p.waitForSelector(".range2")
|
||||||
|
return {
|
||||||
|
min: await p.getAttribute(".range2__input--min", "max"),
|
||||||
|
text: await p.textContent(".pop__body"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWN = "This model serves one request at a time: subagents need another model, on another server, named in the chat."
|
||||||
|
|
||||||
|
function a5(routes = {}) {
|
||||||
|
return { status: status(), routes: baseRoutes(routes), streams: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("A5: a new chat on a model serving two at once offers Subagents, Min and Max to the server's cap", async () => {
|
||||||
|
const p = await page(a5())
|
||||||
|
await newChatOn(p, "Chat")
|
||||||
|
const chip = await subagentsChip(p)
|
||||||
|
assert.equal(await chip.count(), 1, "the chip is there")
|
||||||
|
const s = await openSlider(p)
|
||||||
|
assert.equal(s.min, "8", "up to the server's cap")
|
||||||
|
assert.ok(!s.text.includes(OWN), "no one-request note")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A5: a new chat on a one-request model the server still offers: Subagents with the note", async () => {
|
||||||
|
const p = await page(a5())
|
||||||
|
await newChatOn(p, "Chat", "Solo")
|
||||||
|
const chip = await subagentsChip(p)
|
||||||
|
assert.equal(await chip.count(), 1)
|
||||||
|
const s = await openSlider(p)
|
||||||
|
assert.ok(s.text.includes(OWN), "the one-request note")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A5: subagents off on the instance (offered false for every model): no Subagents", async () => {
|
||||||
|
const off = SUB(false)
|
||||||
|
const p = await page(a5({ "GET /models": models({ qwen: off, solo: off, plain: off }) }))
|
||||||
|
await newChatOn(p, "Chat")
|
||||||
|
assert.equal(await (await subagentsChip(p)).count(), 0)
|
||||||
|
await newChatOn(p, "Chat", "Solo")
|
||||||
|
assert.equal(await (await subagentsChip(p)).count(), 0)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A5: subagents switched on while the app stays open: the next new chat offers them", async () => {
|
||||||
|
const off = SUB(false)
|
||||||
|
const p = await page(a5({ "GET /models": models({ qwen: off, solo: off, plain: off }) }))
|
||||||
|
await newChatOn(p, "Chat")
|
||||||
|
assert.equal(await (await subagentsChip(p)).count(), 0, "off at sign-in")
|
||||||
|
// The administrator switches subagents on; the app read /models at sign-in.
|
||||||
|
await p.evaluate((m) => (window.__mockConfig.routes["GET /models"] = m), models())
|
||||||
|
await p.waitForTimeout(5200)
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip--subagents")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("A5: an open chat follows its own `subagents`: offered, offered with the note, not offered", async () => {
|
||||||
|
for (const [said, chip, note] of [
|
||||||
|
[{ offered: true, named_only: false, cap: 8 }, 1, false],
|
||||||
|
[{ offered: true, named_only: true, cap: 8 }, 1, true],
|
||||||
|
[{ offered: false, named_only: false, cap: 8 }, 0, false],
|
||||||
|
]) {
|
||||||
|
const p = await page(
|
||||||
|
a5({
|
||||||
|
"GET /chats/chat1": { chat: chatRow({ subagents: said }), messages: [userMessage("m5", "How is lembas made?")], more: false, live: null },
|
||||||
|
"GET /models?chat=chat1": models(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await openChatRow(p, "chat1")
|
||||||
|
const c = await subagentsChip(p)
|
||||||
|
assert.equal(await c.count(), chip, `chip for ${JSON.stringify(said)}`)
|
||||||
|
if (chip) assert.equal((await openSlider(p)).text.includes(OWN), note)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
await p.context().close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- B3: the Terminal panel -------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The server's two sockets for an app (`api/v2/terminal.py`): an agent chat's
|
||||||
|
// (`/chats/{chat}/terminal/ws`) and one before the chat exists, on the device and folder chosen
|
||||||
|
// (`/devices/{device}/terminal/ws?directory=`), which the chat's first message takes over.
|
||||||
|
// `GET /devices` says per device whether it opens a terminal. The socket is Rust's: the page asks
|
||||||
|
// `terminal_open` and gets the frames as `terminal` events (mock-tauri.js plays the server).
|
||||||
|
|
||||||
|
const READY = ["control", { t: "ready", label: "workbench", dir: "/home/dev/project", cols: 80, rows: 24, shared: false, osc: false }]
|
||||||
|
|
||||||
|
function b3(over = {}, terminal = [READY, ["output", "dev@workbench:~/project$ "]]) {
|
||||||
|
return {
|
||||||
|
status: status(),
|
||||||
|
routes: baseRoutes({
|
||||||
|
"GET /chats/chat2": { chat: agentChat({ running: false }), messages: [userMessage("m1", "What is in build?")], more: false, live: null },
|
||||||
|
"GET /models?chat=chat2": models(),
|
||||||
|
"POST /chats": { chat: agentChat({ running: false }), message: userMessage("m9", "Build it"), reply: null },
|
||||||
|
...over,
|
||||||
|
}),
|
||||||
|
streams: {},
|
||||||
|
terminal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function screenText(p) {
|
||||||
|
return p.evaluate(() => document.querySelector("#terminal .xterm-rows")?.textContent || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("B3: a new agent chat offers the Terminal once its folder is chosen, and opens a shell there before the first message", async () => {
|
||||||
|
const p = await page(b3())
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.click(".kind__tab >> text=Agent")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip--terminal")
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 0, "nothing is opened until asked")
|
||||||
|
await p.click("#composer-chips .cchip--terminal")
|
||||||
|
await p.waitForSelector("#terminal:not([hidden]) .xterm")
|
||||||
|
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
||||||
|
const [shell] = await p.evaluate(() => window.__mock.terminals())
|
||||||
|
assert.deepEqual(shell.target, { chat: null, device: "token1", directory: "/home/dev/project" })
|
||||||
|
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
|
||||||
|
assert.equal(await p.textContent("#terminal-message"), "Connected.")
|
||||||
|
assert.equal(await p.textContent("#terminal-title"), "workbench")
|
||||||
|
assert.equal(await p.getAttribute("#composer-chips .cchip--terminal", "aria-pressed"), "true")
|
||||||
|
// Keys go up through the bridge, never a socket of the page's own.
|
||||||
|
await p.click("#terminal .xterm")
|
||||||
|
await p.keyboard.type("ls")
|
||||||
|
await p.keyboard.press("Enter")
|
||||||
|
await p.waitForFunction(() => window.__mock.typed().includes("ls\r"))
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.calls.filter((c) => c.cmd === "terminal_open").length), 1)
|
||||||
|
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "b3-new-1280.png") })
|
||||||
|
// The first message makes the chat, which takes the shell over: the same socket goes on.
|
||||||
|
await p.click("#composer-text")
|
||||||
|
await p.fill("#composer-text", "Build it")
|
||||||
|
await p.click("#send")
|
||||||
|
await p.waitForFunction(() => window.__mock.api("POST", "/chats").length === 1)
|
||||||
|
const [made] = await p.evaluate(() => window.__mock.api("POST", "/chats"))
|
||||||
|
assert.equal(made.args.body.device, "token1")
|
||||||
|
assert.equal(made.args.body.directory, "/home/dev/project")
|
||||||
|
await p.waitForSelector('#chat-tools [data-terminal-toggle][aria-pressed="true"]')
|
||||||
|
assert.ok(await p.isVisible("#terminal"), "the panel stays open")
|
||||||
|
const shells = await p.evaluate(() => window.__mock.terminals())
|
||||||
|
assert.equal(shells.length, 1, "no second shell: the chat took the first one over")
|
||||||
|
assert.ok(shells[0].open, "and its socket is still open")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B3: an open agent chat opens its own shell from the header; Send to chat fills the composer and sends nothing", async () => {
|
||||||
|
const p = await page(b3())
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
||||||
|
assert.deepEqual((await p.evaluate(() => window.__mock.terminals()))[0].target, { chat: "chat2", device: null, directory: null })
|
||||||
|
await p.evaluate(() => window.__mock.shell("output", "\r\ntotal 4\r\n-rw-r--r-- 1 dev dev 12 build.log\r\n"))
|
||||||
|
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("build.log"))
|
||||||
|
await p.fill("#composer-text", "Look at this:")
|
||||||
|
await p.click("#terminal-send")
|
||||||
|
const text = await p.inputValue("#composer-text")
|
||||||
|
assert.ok(text.startsWith("Look at this:\n\n```\n"), `after what was typed, as a block: ${JSON.stringify(text.slice(0, 40))}`)
|
||||||
|
assert.match(text, /build\.log/)
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.api("POST", "/chats/chat2/messages").length), 0, "nothing is sent")
|
||||||
|
// Closing the panel keeps the shell; opening it again does not open another.
|
||||||
|
await p.click("#terminal-close")
|
||||||
|
assert.ok(await p.isHidden("#terminal"))
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForSelector("#terminal:not([hidden])")
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1)
|
||||||
|
// The server ends it: said, and no new shell in its place.
|
||||||
|
await p.evaluate(() => window.__mock.shell("control", { t: "closed", message: "The shell exited." }))
|
||||||
|
await p.waitForFunction(() => document.querySelector("#terminal-message").textContent === "The shell exited.")
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B3: the terminal and the activity panel take turns in the panel column", async () => {
|
||||||
|
const p = await page(b3({ "GET /chats/chat2/activity": { activity: {} } }))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForSelector("#terminal:not([hidden])")
|
||||||
|
assert.equal(await p.getAttribute("#shell", "data-terminal"), "true")
|
||||||
|
await p.click("#activity-open")
|
||||||
|
await p.waitForSelector("#activity:not([hidden])")
|
||||||
|
assert.ok(await p.isHidden("#terminal"), "the terminal gave way")
|
||||||
|
assert.equal(await p.getAttribute("#shell", "data-terminal"), "false")
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForSelector("#terminal:not([hidden])")
|
||||||
|
assert.ok(await p.isHidden("#activity"), "and the activity panel to it")
|
||||||
|
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 1, "the shell lived on behind it")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B3: no Terminal where the device opens none, for a chat on the server, or before a folder", async () => {
|
||||||
|
const off = { "GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", platform: "linux", terminal: false }] } }
|
||||||
|
let p = await page(b3(off))
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.click(".kind__tab >> text=Agent")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip")
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "the device opens no terminal")
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0)
|
||||||
|
await p.context().close()
|
||||||
|
p = await page(b3({}))
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.click(".kind__tab >> text=Chat")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip")
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "a chat on the server has no shell")
|
||||||
|
await p.context().close()
|
||||||
|
p = await page({ ...b3({}), status: status({ remote: { enabled: true, roots: [] } }) })
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.click(".kind__tab >> text=Agent")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip")
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "no folder yet")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B3: a refused sign-in and an older server are said in words", async () => {
|
||||||
|
let p = await page(b3({}, [["end", { code: 4401, reason: "not allowed", status: null, error: null }]]))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForFunction(() => /Sign in again/.test(document.querySelector("#terminal-message").textContent))
|
||||||
|
assert.ok(await p.evaluate(() => document.querySelector("#terminal-message").classList.contains("is-error")))
|
||||||
|
await p.context().close()
|
||||||
|
p = await page(b3({}, [["end", { code: null, reason: "", status: 404, error: null }]]))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForFunction(() => /needs a newer LLeMbas server/.test(document.querySelector("#terminal-message").textContent))
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B3: at 390 px the terminal is a sheet over the chat, inside the window", async () => {
|
||||||
|
const p = await page(b3(), { width: 390, height: 844 })
|
||||||
|
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
|
||||||
|
await p.waitForSelector('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
|
||||||
|
const box = await p.locator("#terminal").boundingBox()
|
||||||
|
assert.ok(box.x >= 0 && box.x + box.width <= 390 && box.y + box.height <= 844, `inside: ${JSON.stringify(box)}`)
|
||||||
|
const send = await p.locator("#terminal-send").boundingBox()
|
||||||
|
assert.ok(send.x + send.width <= 390 && send.y + send.height <= 844, `Send is reachable: ${JSON.stringify(send)}`)
|
||||||
|
const close = await p.locator("#terminal-close").boundingBox()
|
||||||
|
assert.ok(close.x + close.width <= 390, "Close is reachable")
|
||||||
|
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
|
||||||
|
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "b3-chat-390.png") })
|
||||||
|
// Send to chat: the sheet steps aside, and the message box has the block.
|
||||||
|
await p.click("#terminal-send")
|
||||||
|
assert.ok(await p.isHidden("#terminal"))
|
||||||
|
assert.match(await p.inputValue("#composer-text"), /dev@workbench/)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B3: at 1280 px the terminal sits beside the chat, which keeps its column", async () => {
|
||||||
|
const p = await page(b3())
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.click('#chat-tools [data-terminal-toggle]')
|
||||||
|
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
|
||||||
|
const term = await p.locator("#terminal").boundingBox()
|
||||||
|
const main = await p.locator("#main").boundingBox()
|
||||||
|
assert.ok(term.x >= main.x + main.width - 1, "beside, not over")
|
||||||
|
assert.ok(main.width >= 400, `the chat keeps room: ${main.width}`)
|
||||||
|
assert.ok(term.width >= 380, `the terminal has its width: ${term.width}`)
|
||||||
|
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "b3-chat-1280.png") })
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- B1: the progress line ---------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Harness spec 3.4.0 (harness/progress.md in the LLeMbas CLI): one `progress` object, drawn by
|
||||||
|
// every client as `{{text}} · {{elapsed}}`. tests/ui/conformance/progress.json is a copy of the
|
||||||
|
// spec's conformance/progress.json (3.4.0); every case must come out word for word.
|
||||||
|
|
||||||
|
test("B1: every conformance case of the spec draws the same text and line", async () => {
|
||||||
|
const { cases } = JSON.parse(readFileSync(join(HERE, "conformance", "progress.json"), "utf8"))
|
||||||
|
const p = await page(b3())
|
||||||
|
const got = await p.evaluate(async (cs) => {
|
||||||
|
const m = await import("/js/progress.js")
|
||||||
|
return cs.map((c) => ({ text: m.progressText(c.progress, c.now), line: m.progressLine(c.progress, c.now) }))
|
||||||
|
}, cases)
|
||||||
|
cases.forEach((c, i) => assert.deepEqual(got[i], c.expect, c.about))
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("B1: a reply being written draws the stream's progress, its clock moving, and the status text without one", async () => {
|
||||||
|
const p = await page(agentChatOpen([["status", { text: "Writing…" }]]))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector(".msg.is-live .msg__status")
|
||||||
|
// An older server sends no progress at all: its status text, as before.
|
||||||
|
await p.evaluate((path) => window.__mock.frame(path, "status", { text: "Running bake --slow" }), STREAM2)
|
||||||
|
assert.equal(await p.textContent(".msg.is-live .msg__status"), "Running bake --slow")
|
||||||
|
// The server's status frame (event_status.json), its clock set 65 s back from now.
|
||||||
|
const status = fixture("event_status")
|
||||||
|
await p.evaluate(([path, f]) => {
|
||||||
|
window.__mock.frame(path, "status", { ...f, progress: { ...f.progress, since: Date.now() - 65000 } })
|
||||||
|
}, [STREAM2, status])
|
||||||
|
const line = () => p.textContent(".msg.is-live .msg__status")
|
||||||
|
assert.equal(await line(), "Running bash: bake --slow · 1m 05s")
|
||||||
|
assert.equal(await p.getAttribute(".msg.is-live .msg__status", "data-phase"), "running_tool")
|
||||||
|
await p.waitForFunction(() => document.querySelector(".msg.is-live .msg__status").textContent.endsWith("1m 06s"), null, { timeout: 2500 })
|
||||||
|
// A data-only frame with progress (the ask frame carries it too): the line follows.
|
||||||
|
await p.evaluate((path) => window.__mock.frame(path, "ask", { cards: [], progress: { phase: "waiting_for_person", since: Date.now(), what: "approval", tool: "bash" } }), STREAM2)
|
||||||
|
assert.equal(await line(), "Waiting for you: allow bash? · 0s")
|
||||||
|
// Nothing running (event_status_idle.json: no text, `progress: null`): the plain words again.
|
||||||
|
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", f), [STREAM2, fixture("event_status_idle")])
|
||||||
|
assert.equal(await line(), "Writing…")
|
||||||
|
// Waiting for a person's answer (event_status_person.json): the phase, not the text.
|
||||||
|
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", { ...f, progress: { ...f.progress, since: Date.now() } }), [STREAM2, fixture("event_status_person")])
|
||||||
|
assert.equal(await line(), "Waiting for you: a question · 0s")
|
||||||
|
// Stopped and failed have no clock.
|
||||||
|
await p.evaluate(([path, f]) => window.__mock.frame(path, "status", f), [STREAM2, fixture("event_status_stopped")])
|
||||||
|
assert.equal(await line(), "Stopped: by you")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- C4: subagents (spec 3.4.0) ---------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The server's /api/v2 (its fixtures, copied into tests/ui/fixtures): GET /chats/{chat}/tree ->
|
||||||
|
// {tree, room, live}, the stream's `tree` frame, a subagent's transcript and findings, POST
|
||||||
|
// .../stop; an agent chat's device away -> the kept tree (`live` false) and 409 for the rest;
|
||||||
|
// /models?kind=agent&device= for a new agent chat's Subagents; `subagent` on an approval item.
|
||||||
|
|
||||||
|
const ACTIVITY = { activity: { version: "v1", summary: { active: 0 }, agents: [], context: null, outputs: [], team: null, plan: null } }
|
||||||
|
|
||||||
|
function c4(over = {}) {
|
||||||
|
return b3({
|
||||||
|
"GET /chats/chat2/activity": ACTIVITY,
|
||||||
|
"GET /chats/chat2/tree": fixture("tree"),
|
||||||
|
"GET /chats/chat2/subagents/s2/transcript": fixture("subagent_transcript"),
|
||||||
|
"GET /chats/chat2/subagents/s2/findings": fixture("subagent_findings"),
|
||||||
|
"POST /chats/chat2/subagents/s2/stop": { stopped: ["s2"] },
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTree(p) {
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.click("#activity-open")
|
||||||
|
await p.waitForSelector("#activity .subagents .subagent")
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = (p) => p.evaluate(() => [...document.querySelectorAll("#activity .subagents__list > .subagent")].map((r) => [r.dataset.id, r.style.getPropertyValue("--depth")]))
|
||||||
|
|
||||||
|
test("C4: the activity panel draws the server's tree: nested by parent, slots, the ended ones apart", async () => {
|
||||||
|
const p = await page(c4())
|
||||||
|
await openTree(p)
|
||||||
|
assert.deepEqual(await rows(p), [["lead", "0"], ["s1", "1"], ["s2", "2"]])
|
||||||
|
const routes = await p.textContent('#activity .subagent[data-id="s2"]')
|
||||||
|
assert.match(routes, /routes/)
|
||||||
|
assert.match(routes, /writes the routes/)
|
||||||
|
assert.match(routes, /Running read: src\/api\.py/)
|
||||||
|
assert.match(routes, /6%/, "context: floor(9100 × 100 / 131072)")
|
||||||
|
assert.match(routes, /edit \+ execute/)
|
||||||
|
assert.match(routes, /messages: 1/)
|
||||||
|
assert.match(routes, /routes → area: Routes are in\./, "the last room message to or from it")
|
||||||
|
assert.match(await p.textContent('#activity .subagent[data-id="lead"]'), /the main agent/)
|
||||||
|
assert.match(await p.textContent("#activity .subagents"), /3 of 4/)
|
||||||
|
assert.match(await p.textContent("#activity .subagents"), /They run on the computer this chat runs on\./)
|
||||||
|
// The ended one, folded apart, with how it ended, and no stop square.
|
||||||
|
await p.click("#activity .subagents__ended > summary")
|
||||||
|
const scout = await p.textContent('#activity .subagents__ended .subagent[data-id="s3"]')
|
||||||
|
assert.match(scout, /done/)
|
||||||
|
assert.equal(await p.locator('#activity .subagent[data-id="s3"] button.agent__stop').count(), 0)
|
||||||
|
assert.equal(await p.locator('#activity .subagent[data-id="lead"] button.agent__stop').count(), 0, "the main agent is not stopped from here")
|
||||||
|
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "c4-tree-1280.png") })
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("C4: a subagent's conversation and findings open read-only; Stop asks, then stops it", async () => {
|
||||||
|
const p = await page(c4())
|
||||||
|
await openTree(p)
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] > summary')
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Open its conversation')
|
||||||
|
await p.waitForSelector("#activity .convo .convo__turn")
|
||||||
|
const convo = await p.textContent("#activity-body")
|
||||||
|
assert.match(convo, /Write the routes\./)
|
||||||
|
assert.match(convo, /Reading the API first\./)
|
||||||
|
assert.match(convo, /src\/api\.py/)
|
||||||
|
assert.equal(await p.locator("#activity-body textarea, #activity-body input").count(), 0, "nothing to type into")
|
||||||
|
await p.click("#activity-back")
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Findings')
|
||||||
|
await p.waitForSelector("#activity .findings")
|
||||||
|
assert.match(await p.textContent("#activity .findings"), /Login is POST \/session\./)
|
||||||
|
await p.click("#activity-back")
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] button.agent__stop')
|
||||||
|
await p.waitForSelector("dialog[open]")
|
||||||
|
assert.match(await p.textContent("dialog[open]"), /Stop routes and the subagents under it\?/)
|
||||||
|
await p.click("dialog[open] .btn--danger")
|
||||||
|
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat2/subagents/s2/stop").length === 1)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("C4: the stream's tree frame redraws the panel; with the device away the kept tree shows and the rest says why", async () => {
|
||||||
|
const away = { ...fixture("tree"), live: false }
|
||||||
|
const p = await page(c4({ "GET /chats/chat2/tree": away, "GET /chats/chat2/subagents/s2/transcript": { __error: { status: 409, detail: "That is kept on the computer this chat runs on, which is not linked right now." } } }))
|
||||||
|
await openTree(p)
|
||||||
|
assert.match(await p.textContent("#activity .subagents"), /This device is not linked right now\./)
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] > summary')
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] .subagent__buttons .btn >> text=Open its conversation')
|
||||||
|
await p.waitForSelector("#activity-body .is-error")
|
||||||
|
assert.match(await p.textContent("#activity-body"), /not linked right now/)
|
||||||
|
await p.click("#activity-back")
|
||||||
|
// A reply streams; its `tree` frame (event_tree.json) is the tree now.
|
||||||
|
await p.evaluate(() => window.__mock.frame("/chats/chat2/messages/m2/stream", "tree", null))
|
||||||
|
await p.context().close()
|
||||||
|
const live = await page({ ...c4(), routes: { ...c4().routes, "GET /chats/chat2": { chat: agentChat(), messages: [userMessage("m1", "Go")], more: false, live: "m2" } }, streams: { [STREAM2]: [] } })
|
||||||
|
await openTree(live)
|
||||||
|
await live.evaluate(([path, f]) => window.__mock.frame(path, "tree", f), [STREAM2, fixture("event_tree")])
|
||||||
|
await live.waitForFunction(() => !!document.querySelector('#activity .subagent[data-id="s2"]') && /parser/.test(document.querySelector('#activity .subagent[data-id="s2"]').textContent))
|
||||||
|
assert.match(await live.textContent('#activity .subagent[data-id="s2"]'), /reviews the parser/)
|
||||||
|
assert.match(await live.textContent('#activity .subagent[data-id="s2"]'), /manual \+ write/)
|
||||||
|
assert.deepEqual(live.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("C4: an approval a subagent asks for names it", async () => {
|
||||||
|
const card = fixture("event_ask").cards.find((c) => c.id === "card4")
|
||||||
|
const p = await page(agentChatOpen([["ask", { cards: [card] }]], { "POST /chats/chat2/interactions/card4": { answered: true } }))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#cards .ask__subagent")
|
||||||
|
assert.equal(await p.textContent("#cards .ask__subagent"), "Subagent parser (s1), reviews the parser, asks:")
|
||||||
|
assert.equal(await p.inputValue("#cards .ask__edit"), "git push", "the command, correctable, as for the main agent's")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("C4: Subagents for a new agent chat as /models?kind=agent says for this device; for an open one as the chat says", async () => {
|
||||||
|
const facts = fixture("models_agent")
|
||||||
|
const newAgent = async (models) => {
|
||||||
|
const p = await page(c4({ "GET /models?kind=agent&device=token1": models }))
|
||||||
|
await p.click("#new-chat-btn")
|
||||||
|
await p.click(".kind__tab >> text=Agent")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip")
|
||||||
|
await p.waitForTimeout(200)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
let p = await newAgent(facts)
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 1, "offered, and the device runs them")
|
||||||
|
await p.click("#composer-chips .cchip--subagents")
|
||||||
|
await p.waitForSelector(".range2")
|
||||||
|
assert.equal(await p.getAttribute(".range2__input--min", "max"), "32", "the server's cap")
|
||||||
|
await p.context().close()
|
||||||
|
p = await newAgent({ ...facts, agent_subagents: { offered: true, device_runs: false } })
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "this computer's CLI cannot run them")
|
||||||
|
await p.context().close()
|
||||||
|
p = await newAgent({ ...facts, agent_subagents: { offered: false, device_runs: true } })
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "not offered")
|
||||||
|
await p.context().close()
|
||||||
|
p = await newAgent({ __error: { status: 400, detail: "older server" } })
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "a server before 3.4.0: none, as before")
|
||||||
|
await p.context().close()
|
||||||
|
// An open agent chat: its own `subagents` (chat_agent.json offers them).
|
||||||
|
const chat = fixture("chat_agent")
|
||||||
|
p = await page(c4({ "GET /chats/chat2": { chat: { ...agentChat({ running: false }), subagents: chat.subagents, agents: chat.agents }, messages: [userMessage("m1", "Go")], more: false, live: null } }))
|
||||||
|
await openChatRow(p, "chat2")
|
||||||
|
await p.waitForSelector("#composer-chips .cchip--subagents")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("C4: the tree at 390 px stays inside the window", async () => {
|
||||||
|
const p = await page(c4(), { width: 390, height: 844 })
|
||||||
|
await p.evaluate(() => document.querySelector('.chatrow[data-id="chat2"]').click())
|
||||||
|
await p.waitForSelector("#activity-open")
|
||||||
|
await p.click("#activity-open")
|
||||||
|
await p.waitForSelector("#activity .subagents .subagent")
|
||||||
|
await p.click('#activity .subagent[data-id="s2"] > summary')
|
||||||
|
assert.ok(await p.evaluate(() => document.documentElement.scrollWidth <= 390), "nothing scrolls sideways")
|
||||||
|
const panel = await p.evaluate(() => { const b = document.querySelector("#activity-body"); return b.scrollWidth <= b.clientWidth })
|
||||||
|
assert.ok(panel, "the panel does not scroll sideways")
|
||||||
|
if (process.env.SHOTS) await p.screenshot({ path: join(process.env.SHOTS, "c4-tree-390.png") })
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Run ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const only = process.argv[2] || ""
|
||||||
|
browser = await launch()
|
||||||
|
let failed = 0
|
||||||
|
for (const { name, fn } of tests) {
|
||||||
|
if (only && !name.includes(only)) continue
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
console.log(`ok ${name}`)
|
||||||
|
} catch (err) {
|
||||||
|
failed++
|
||||||
|
console.log(`FAIL ${name}\n ${String(err?.stack || err).split("\n").slice(0, 6).join("\n ")}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await browser.close()
|
||||||
|
console.log(failed ? `tests/ui: ${failed} failed` : "tests/ui: all passed")
|
||||||
|
process.exit(failed ? 1 : 0)
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"tag": "v2.0.0-beta.6",
|
"tag": "v2.0.0-beta.8",
|
||||||
"commit": "a71e9b4bdb8acb6e9949e978253a72c347cc67f3",
|
"commit": "d06ca353b801916302f0ce0cc9ae3e5244ff184f",
|
||||||
"sha256": "e5f76f542ed5512702e06d69fa1dfa098c511f67dacbd7cc93f4a0618c6ae966"
|
"sha256": "6fd6d9d530c005dc52ad7dfcd61b3515b29955aa6d55365d35df3936fb7f29da"
|
||||||
}
|
}
|
||||||
+192
-33
@@ -122,6 +122,7 @@ textarea { padding: var(--sp-2) var(--control-px-sm); resize: none; line-height:
|
|||||||
|
|
||||||
label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); color: var(--ink-muted); }
|
label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); color: var(--ink-muted); }
|
||||||
.hint { font-size: var(--text-sm); color: var(--ink-muted); min-height: calc(var(--text-sm) * var(--leading-normal)); }
|
.hint { font-size: var(--text-sm); color: var(--ink-muted); min-height: calc(var(--text-sm) * var(--leading-normal)); }
|
||||||
|
.hint.is-warn { color: var(--warning); }
|
||||||
.status { font: var(--text-sm) / var(--leading-normal) var(--font-body); }
|
.status { font: var(--text-sm) / var(--leading-normal) var(--font-body); }
|
||||||
.status.is-ok { color: var(--leaf); }
|
.status.is-ok { color: var(--leaf); }
|
||||||
.check { display: grid; grid-template-columns: var(--check-size) minmax(0, 1fr); gap: var(--sp-2); align-items: center; color: var(--ink); }
|
.check { display: grid; grid-template-columns: var(--check-size) minmax(0, 1fr); gap: var(--sp-2); align-items: center; color: var(--ink); }
|
||||||
@@ -326,6 +327,8 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
|
|||||||
.shell[data-side="false"] { --side-col: 0px; }
|
.shell[data-side="false"] { --side-col: 0px; }
|
||||||
.shell[data-viewer="true"] { --panel-col: minmax(var(--inspector-width-min), 42%); }
|
.shell[data-viewer="true"] { --panel-col: minmax(var(--inspector-width-min), 42%); }
|
||||||
.shell[data-activity="true"] { --panel-col: var(--inspector-width); }
|
.shell[data-activity="true"] { --panel-col: var(--inspector-width); }
|
||||||
|
/* The terminal: the server's width for it, giving way down to its least before the chat does. */
|
||||||
|
.shell[data-terminal="true"] { --panel-col: minmax(var(--terminal-width-min), var(--terminal-width)); }
|
||||||
|
|
||||||
/* --- The sidebar --------------------------------------------------------------------------- */
|
/* --- The sidebar --------------------------------------------------------------------------- */
|
||||||
|
|
||||||
@@ -708,26 +711,75 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
}
|
}
|
||||||
.toggle input:checked { background: var(--accent); border-color: var(--accent); }
|
.toggle input:checked { background: var(--accent); border-color: var(--accent); }
|
||||||
.toggle input:checked::after { background: var(--accent-ink); transform: translateX(var(--check-size)); }
|
.toggle input:checked::after { background: var(--accent-ink); transform: translateX(var(--check-size)); }
|
||||||
/* The team's size: two number fields of one width with a dash between them, and in the header
|
/* The subagents, Min and Max: one range with two handles. Three rows of known height -- the
|
||||||
its word before them -- fixed tracks, so nothing moves when a number grows a digit. */
|
values said on one line, the track box (one control high), the two ends -- and in the box two
|
||||||
.agents {
|
range inputs laid over one track, the span between the handles filled. The inputs let only
|
||||||
display: inline-grid;
|
their handles take the pointer, so either can be grabbed wherever the other is. */
|
||||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
.range2 { display: grid; grid-template-rows: auto var(--control-h) auto; gap: var(--sp-1); width: 100%; }
|
||||||
gap: var(--sp-2);
|
.range2__said { font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui); color: var(--ink); font-variant-numeric: tabular-nums; }
|
||||||
align-items: center;
|
.range2__box { position: relative; display: block; height: var(--control-h); --lo: 0%; --hi: 100%; }
|
||||||
|
.range2__track {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: calc(var(--check-size) / 2);
|
||||||
|
right: calc(var(--check-size) / 2);
|
||||||
|
height: var(--sp-1);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
.range2__fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: var(--lo);
|
||||||
|
right: calc(100% - var(--hi));
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
.range2__input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: var(--control-h);
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: none;
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
.agents__dash { color: var(--ink-muted); }
|
.range2__input.is-top { z-index: 1; }
|
||||||
.agents__input { width: 100%; min-width: 0; height: var(--control-h); padding: 0 var(--control-px-sm); }
|
.range2__input::-webkit-slider-runnable-track { height: 100%; background: none; border: 0; }
|
||||||
.agents--compact {
|
.range2__input::-moz-range-track { height: 100%; background: none; border: 0; }
|
||||||
grid-template-columns: auto calc(var(--w-field-xs) / 2) auto calc(var(--w-field-xs) / 2);
|
.range2__input::-webkit-slider-thumb {
|
||||||
width: auto;
|
-webkit-appearance: none;
|
||||||
height: var(--control-h-sm);
|
appearance: none;
|
||||||
font: var(--text-sm) var(--font-ui);
|
width: var(--check-size);
|
||||||
|
height: var(--check-size);
|
||||||
|
margin-top: calc((var(--control-h) - var(--check-size)) / 2);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: var(--border-w-thick) solid var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
cursor: grab;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.agents--compact .agents__input { height: var(--control-h-sm); }
|
.range2__input::-moz-range-thumb {
|
||||||
.agents__label { color: var(--ink-muted); white-space: nowrap; }
|
width: var(--check-size);
|
||||||
|
height: var(--check-size);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: var(--border-w-thick) solid var(--accent);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
cursor: grab;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.range2__input:focus-visible { outline: none; }
|
||||||
|
.range2__input:focus-visible::-webkit-slider-thumb { box-shadow: var(--ring); }
|
||||||
|
.range2__input:focus-visible::-moz-range-thumb { box-shadow: var(--ring); }
|
||||||
|
/* The two ends, each one handle wide, so each number sits centred under its handle at that end. */
|
||||||
|
.range2__ends { display: flex; justify-content: space-between; font-size: var(--text-xs); color: var(--ink-muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.range2__ends > span { width: var(--check-size); text-align: center; white-space: nowrap; }
|
||||||
/* Markdown, drawn by markdown.js from model output (never as HTML). */
|
/* Markdown, drawn by markdown.js from model output (never as HTML). */
|
||||||
.md { overflow-wrap: anywhere; min-width: 0; }
|
.md { overflow-wrap: anywhere; min-width: 0; }
|
||||||
.md > * + * { margin-top: var(--sp-3); }
|
.md > * + * { margin-top: var(--sp-3); }
|
||||||
@@ -920,6 +972,14 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
.ctx__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink-muted); }
|
.ctx__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink-muted); }
|
||||||
.ctx__num { font: var(--text-xs) var(--font-mono); text-align: right; color: var(--ink-muted); }
|
.ctx__num { font: var(--text-xs) var(--font-mono); text-align: right; color: var(--ink-muted); }
|
||||||
.ctx__note { margin: 0; font-size: var(--text-xs); color: var(--ink-faint); }
|
.ctx__note { margin: 0; font-size: var(--text-xs); color: var(--ink-faint); }
|
||||||
|
/* "41k until auto-compact" and the Compact button: the words give, the button keeps its size. */
|
||||||
|
.ctx__compact { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-1) var(--sp-2); align-items: center; padding-top: var(--sp-2); border-top: var(--border-w) solid var(--border); }
|
||||||
|
.ctx__until { color: var(--ink-muted); }
|
||||||
|
.ctx__error { grid-column: 1 / -1; margin: 0; font-size: var(--text-xs); color: var(--warning); }
|
||||||
|
/* Environment: an agent chat's folder and where it is. */
|
||||||
|
.env { display: grid; grid-template-columns: var(--sp-4) minmax(0, auto) auto; justify-content: start; gap: var(--sp-2); align-items: center; margin: 0; }
|
||||||
|
.env__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink); }
|
||||||
|
.env__where { font: var(--text-xs) var(--font-mono); color: var(--ink-muted); }
|
||||||
.ctx--messages { --ctx: var(--accent); }
|
.ctx--messages { --ctx: var(--accent); }
|
||||||
.ctx--system { --ctx: var(--ink-muted); }
|
.ctx--system { --ctx: var(--ink-muted); }
|
||||||
.ctx--memory { --ctx: var(--warning); }
|
.ctx--memory { --ctx: var(--warning); }
|
||||||
@@ -943,15 +1003,41 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
track that gives, cut), elapsed time in a column of its own width, the stop button at its size
|
track that gives, cut), elapsed time in a column of its own width, the stop button at its size
|
||||||
(an empty box where there is none) -- so rows line up whatever each says. */
|
(an empty box where there is none) -- so rows line up whatever each says. */
|
||||||
.tasks { display: grid; gap: var(--sp-2); min-width: 0; }
|
.tasks { display: grid; gap: var(--sp-2); min-width: 0; }
|
||||||
.tasks__summary, .tasks__team { margin: 0; color: var(--ink-muted); }
|
.tasks__team { margin: 0; color: var(--ink-muted); }
|
||||||
.tasks__goal { margin: 0; color: var(--ink); }
|
.tasks__goal { margin: 0; color: var(--ink); }
|
||||||
.tasks__list { display: grid; gap: var(--sp-1); }
|
.tasks__group { display: grid; grid-template-columns: auto auto; justify-content: start; gap: var(--sp-2); align-items: baseline; margin: var(--sp-1) 0 0; color: var(--ink-muted); }
|
||||||
.tasks__more > summary { cursor: pointer; padding: var(--sp-1) 0; color: var(--ink-muted); }
|
.tasks__list { display: grid; gap: var(--sp-2); }
|
||||||
.tasks__more[open] > summary { margin-bottom: var(--sp-1); }
|
/* A fold ("Show 3 more", "Finished 12", a task's own "2 tasks"): a chevron that turns, then the
|
||||||
|
words; nested ones step in by the depth of what is under them. */
|
||||||
|
.tasks__more { margin-left: calc(var(--depth, 0) * var(--sp-4)); min-width: 0; }
|
||||||
|
.tasks__more > summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12px minmax(0, 1fr);
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--row-h);
|
||||||
|
padding: 0 var(--sp-3);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.tasks__more > summary::-webkit-details-marker { display: none; }
|
||||||
|
.tasks__more > summary:hover { color: var(--ink); }
|
||||||
|
.tasks__more[open] > summary { margin-bottom: var(--sp-2); }
|
||||||
|
.tasks__nested { margin-top: calc(-1 * var(--sp-1)); }
|
||||||
|
.tasks__finished { padding-top: var(--sp-1); border-top: var(--border-w) solid var(--border); }
|
||||||
|
.tasks__chev, .agent__chev { display: grid; place-items: center; width: 12px; height: 12px; color: var(--ink-faint); transition: transform var(--dur-1) var(--ease-out); }
|
||||||
|
.tasks__chev .icon, .agent__chev .icon { width: 12px; height: 12px; }
|
||||||
|
.tasks__more[open] > summary .tasks__chev, .agent[open] > .agent__row .agent__chev { transform: rotate(90deg); }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .tasks__chev, .agent__chev { transition: none; } }
|
||||||
|
|
||||||
|
/* A task: one card, one row of known tracks -- the chevron, what it does (the one track that
|
||||||
|
gives, cut), the elapsed time in a column of its own width, the stop square at its size (an
|
||||||
|
empty box where it cannot be stopped) -- so the rows line up whatever each says. */
|
||||||
.agent { margin-left: calc(var(--depth, 0) * var(--sp-4)); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-lg); min-width: 0; }
|
.agent { margin-left: calc(var(--depth, 0) * var(--sp-4)); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-lg); min-width: 0; }
|
||||||
.agent__row {
|
.agent__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--dot-size) minmax(0, 1fr) var(--time-col) var(--control-h-sm);
|
grid-template-columns: 12px minmax(0, 1fr) var(--time-col) var(--control-h-sm);
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: var(--control-h);
|
min-height: var(--control-h);
|
||||||
@@ -960,17 +1046,21 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
.agent__row::-webkit-details-marker { display: none; }
|
.agent__row::-webkit-details-marker { display: none; }
|
||||||
.agent__dot { width: var(--dot-size); height: var(--dot-size); border-radius: var(--radius-full); background: var(--ink-faint); }
|
.agent__row:hover { background: var(--surface-hover); border-radius: var(--radius-lg); }
|
||||||
.agent.is-working .agent__dot { background: var(--accent); }
|
|
||||||
.agent.is-queued .agent__dot, .agent.is-waiting .agent__dot { background: var(--warning); }
|
|
||||||
.agent.is-done .agent__dot { background: var(--leaf); }
|
|
||||||
.agent.is-error .agent__dot { background: var(--danger); }
|
|
||||||
.agent__what { display: grid; min-width: 0; }
|
.agent__what { display: grid; min-width: 0; }
|
||||||
.agent__name, .agent__role { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
.agent__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink); }
|
||||||
.agent__name { color: var(--ink); }
|
.agent.is-done .agent__name, .agent.is-stopped .agent__name { color: var(--ink-muted); }
|
||||||
.agent__role { font-size: var(--text-xs); color: var(--ink-muted); }
|
.agent.is-error .agent__name { color: var(--danger); }
|
||||||
|
.agent__role { margin: 0; color: var(--ink-muted); overflow-wrap: anywhere; }
|
||||||
.agent__time { font: var(--text-xs) var(--font-mono); text-align: right; color: var(--ink-muted); }
|
.agent__time { font: var(--text-xs) var(--font-mono); text-align: right; color: var(--ink-muted); }
|
||||||
.agent__stop { width: var(--control-h-sm); height: var(--control-h-sm); }
|
.agent.is-queued .agent__time, .agent.is-waiting .agent__time { color: var(--warning); }
|
||||||
|
/* The stop square: a small filled square in a button the size of the others. */
|
||||||
|
.agent__stop { display: grid; place-items: center; width: var(--control-h-sm); height: var(--control-h-sm); padding: 0; background: var(--surface-active); border: 0; border-radius: var(--radius-md); cursor: pointer; }
|
||||||
|
.agent__stop:hover { background: var(--surface-hover); }
|
||||||
|
.agent__stop.is-none { background: none; cursor: default; }
|
||||||
|
.agent__square { width: var(--sp-3); height: var(--sp-3); background: var(--ink); border-radius: var(--radius-sm); }
|
||||||
|
.agent__stop:hover .agent__square { background: var(--danger); }
|
||||||
|
.agent__id { font: var(--text-xs) var(--font-mono); color: var(--ink-faint); overflow-wrap: anywhere; }
|
||||||
.agent__body { display: grid; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3) var(--sp-3); border-top: var(--border-w) solid var(--border); min-width: 0; }
|
.agent__body { display: grid; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3) var(--sp-3); border-top: var(--border-w) solid var(--border); min-width: 0; }
|
||||||
.agent__body > .btn { justify-self: start; }
|
.agent__body > .btn { justify-self: start; }
|
||||||
.agent__facts { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); margin: 0; font-size: var(--text-xs); color: var(--ink-muted); }
|
.agent__facts { display: flex; flex-wrap: wrap; gap: var(--sp-1) var(--sp-3); margin: 0; font-size: var(--text-xs); color: var(--ink-muted); }
|
||||||
@@ -1203,6 +1293,7 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
}
|
}
|
||||||
.cchip:hover { color: var(--ink); background: var(--surface-hover); border-color: var(--border); }
|
.cchip:hover { color: var(--ink); background: var(--surface-hover); border-color: var(--border); }
|
||||||
.cchip:disabled { cursor: default; }
|
.cchip:disabled { cursor: default; }
|
||||||
|
.cchip[aria-pressed="true"], .tool[aria-pressed="true"] { color: var(--ink); background: var(--surface-active); }
|
||||||
.cchip:disabled:hover { background: transparent; border-color: transparent; }
|
.cchip:disabled:hover { background: transparent; border-color: transparent; }
|
||||||
.cchip__label { overflow: hidden; text-overflow: ellipsis; }
|
.cchip__label { overflow: hidden; text-overflow: ellipsis; }
|
||||||
.cchip__chev { display: grid; color: var(--ink-faint); }
|
.cchip__chev { display: grid; color: var(--ink-faint); }
|
||||||
@@ -1470,12 +1561,80 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
.folders .path { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-sm) var(--font-ui); }
|
.folders .path { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-sm) var(--font-ui); }
|
||||||
.folders .empty { display: block; padding: var(--sp-3); color: var(--warning); font-size: var(--text-sm); text-align: left; }
|
.folders .empty { display: block; padding: var(--sp-3); color: var(--warning); font-size: var(--text-sm); text-align: left; }
|
||||||
|
|
||||||
|
/* --- The terminal panel -------------------------------------------------------------------------
|
||||||
|
The panel column, as the activity panel has it, and never beside it (opening one closes the
|
||||||
|
other; the shell lives on). The viewer goes over the chat while it is open, as with the
|
||||||
|
activity panel. Not `.terminal`: xterm gives its own element that class. The head is the
|
||||||
|
viewer's: an icon, the device's name over the folder, then the buttons on known tracks (the
|
||||||
|
command ones only while the shell marks its commands). */
|
||||||
|
|
||||||
|
.shell[data-terminal="true"] .viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
|
||||||
|
.termpanel {
|
||||||
|
grid-area: panel;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
|
border-left: var(--border-w) solid var(--border);
|
||||||
|
background: var(--bg-sunken);
|
||||||
|
}
|
||||||
|
.termpanel__head {
|
||||||
|
grid-template-columns: 16px minmax(0, 1fr);
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: var(--control-h-sm);
|
||||||
|
}
|
||||||
|
.termpanel__icon { display: grid; place-items: center; color: var(--ink-muted); }
|
||||||
|
.termpanel__where { font-family: var(--font-mono); }
|
||||||
|
/* xterm measures this box: it has a size of its own, and the terminal scrolls inside it. */
|
||||||
|
.termpanel__screen { min-height: 0; min-width: 0; overflow: hidden; padding: var(--sp-2); background: var(--code-bg); }
|
||||||
|
.termpanel__screen .xterm { height: 100%; }
|
||||||
|
/* What happened, the last command, and Send: the words take what is left and wrap; the last
|
||||||
|
command is one clipped line; the button keeps its size. */
|
||||||
|
.termpanel__foot {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: auto;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-top: var(--border-w) solid var(--border);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.termpanel__message { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.termpanel__message.is-error { color: var(--danger); }
|
||||||
|
.termpanel__last {
|
||||||
|
max-width: var(--w-chip);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.termpanel__last:empty { display: none; }
|
||||||
|
|
||||||
|
/* --- The subagent tree (spec 3.4.0) ------------------------------------------------------------
|
||||||
|
Rows are the tasks' rows (.agent): name and id on one line, the position, then the phase (or how
|
||||||
|
it ended) clipped to one line -- its clock is in the opened row. Ended ones are dimmed. */
|
||||||
|
|
||||||
|
.subagents { display: grid; gap: var(--sp-2); min-width: 0; }
|
||||||
|
.subagents .tasks__team, .subagents .tasks__goal { margin: 0; }
|
||||||
|
.subagent__who { display: flex; gap: var(--sp-2); align-items: baseline; min-width: 0; }
|
||||||
|
.subagent__who .agent__id { flex: none; }
|
||||||
|
.subagent__phase { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: var(--text-xs); color: var(--ink-muted); }
|
||||||
|
.agent.is-ended { opacity: var(--opacity-dim); }
|
||||||
|
.subagent__buttons { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin: 0; }
|
||||||
|
.findings { margin: 0; padding: var(--sp-3) var(--sp-3) var(--sp-3) calc(var(--sp-3) + var(--sp-5)); display: grid; gap: var(--sp-3); }
|
||||||
|
.findings__item { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.ask__subagent { color: var(--ink); }
|
||||||
|
|
||||||
/* --- Narrower windows ---------------------------------------------------------------------- */
|
/* --- Narrower windows ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
/* 64rem (--bp-wide): a panel goes over the chat instead of beside it. */
|
/* 64rem (--bp-wide): a panel goes over the chat instead of beside it. */
|
||||||
@media (max-width: 64rem) {
|
@media (max-width: 64rem) {
|
||||||
.shell[data-viewer="true"], .shell[data-activity="true"] { --panel-col: 0px; }
|
.shell[data-viewer="true"], .shell[data-activity="true"], .shell[data-terminal="true"] { --panel-col: 0px; }
|
||||||
.viewer, .activity, .shell[data-activity="true"] .viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
|
.viewer, .activity, .termpanel, .shell[data-activity="true"] .viewer, .shell[data-terminal="true"] .viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
|
||||||
.task__open { grid-template-columns: var(--sp-8) minmax(0, 1fr) var(--w-field-sm); }
|
.task__open { grid-template-columns: var(--sp-8) minmax(0, 1fr) var(--w-field-sm); }
|
||||||
.task__next { display: none; }
|
.task__next { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
+752
-88
File diff suppressed because it is too large.
Load diff
@@ -135,6 +135,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<aside class="termpanel" id="terminal" hidden aria-labelledby="terminal-title">
|
||||||
|
<header class="viewer__head termpanel__head">
|
||||||
|
<span class="termpanel__icon" id="terminal-icon" aria-hidden="true"></span>
|
||||||
|
<div class="viewer__id">
|
||||||
|
<h2 class="viewer__name" id="terminal-title" data-i18n>Terminal</h2>
|
||||||
|
<p class="viewer__where termpanel__where" id="terminal-where"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="tool" id="terminal-prev" hidden aria-label="The previous command" title="The previous command (Ctrl+Shift+↑)" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
<button type="button" class="tool" id="terminal-next" hidden aria-label="The next command" title="The next command (Ctrl+Shift+↓)" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
<button type="button" class="tool" id="terminal-copy" aria-label="Copy the last command and its output" title="Copy the last command and its output" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
<button type="button" class="tool" id="terminal-close" aria-label="Close terminal" title="Close terminal" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
</header>
|
||||||
|
<div class="termpanel__screen" id="terminal-screen"></div>
|
||||||
|
<footer class="termpanel__foot">
|
||||||
|
<p class="termpanel__message" id="terminal-message" aria-live="polite"></p>
|
||||||
|
<span class="termpanel__last" id="terminal-last"></span>
|
||||||
|
<button type="button" class="btn btn--sm termpanel__send" id="terminal-send" title="Put the last command and its output into the message box" data-i18n-title><span data-i18n>Send to chat</span></button>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<aside class="activity" id="activity" hidden aria-labelledby="activity-title">
|
<aside class="activity" id="activity" hidden aria-labelledby="activity-title">
|
||||||
<header class="viewer__head activity__head">
|
<header class="viewer__head activity__head">
|
||||||
<button type="button" class="tool" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
|
<button type="button" class="tool" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
|
||||||
|
|||||||
+351
-82
@@ -1,14 +1,26 @@
|
|||||||
// The chat's activity panel, beside it: what is in its context, by category, against the model's
|
// The chat's activity panel, beside it, opened from the header's button at any time: where an
|
||||||
// window; the files the chat and its agents wrote; every background agent -- a team's members as
|
// agent chat works; what is in its context, by category, against the model's window, how far it
|
||||||
// a tree, helpers and commands -- with its state, how long it has run, its model, its latest step
|
// is to the automatic compaction and a button to compact now; the files the chat and its
|
||||||
// and what it is writing, its whole conversation read-only and a button to stop it; the team's
|
// subagents wrote; every background task -- subagents, a team's members as a tree under the one
|
||||||
// room; and the plan. The strip above the message box sums the agents up and opens it. The same
|
// that leads them, background commands -- with how long it has run and a stop square, unfolding
|
||||||
// facts as the server's own panel (`services/activity.py`), from /api/v2:
|
// to its latest step, what it is writing and its whole conversation, read-only; the finished ones
|
||||||
|
// folded; the team's room; and the plan. The line above the message box sums up what runs, while
|
||||||
|
// anything does, and opens it. The same facts as the server's own panel (`services/activity.py`),
|
||||||
|
// from /api/v2:
|
||||||
//
|
//
|
||||||
// GET /chats/{chat}/activity?since=&context= everything, with a `version`
|
// GET /chats/{chat}/activity?since=&context= everything, with a `version`
|
||||||
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
|
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
|
||||||
// GET /chats/{chat}/agents/{kind}/{id} one agent and its conversation
|
// GET /chats/{chat}/agents/{kind}/{id} one task and its conversation
|
||||||
// POST /chats/{chat}/agents/{kind}/{id}/stop
|
// POST /chats/{chat}/agents/{kind}/{id}/stop
|
||||||
|
// POST /chats/{chat}/compact summarise the earlier turns now
|
||||||
|
// GET /chats/{chat}/tree?since= the subagent tree (spec 3.4.0) and the room
|
||||||
|
// GET /chats/{chat}/subagents/{id}/transcript a subagent's conversation, read-only
|
||||||
|
// GET /chats/{chat}/subagents/{id}/findings what it kept
|
||||||
|
// POST /chats/{chat}/subagents/{id}/stop it and those under it
|
||||||
|
//
|
||||||
|
// The tree comes too as the reply stream's `tree` frame (chat.js hands it on). An agent chat's
|
||||||
|
// subagents run on its device: while the device is away the server sends the tree it kept
|
||||||
|
// (`live` false) and answers 409 for a conversation or findings, in its own words.
|
||||||
//
|
//
|
||||||
// The events stream is followed for as long as a chat is open: it keeps the strip current, and
|
// The events stream is followed for as long as a chat is open: it keeps the strip current, and
|
||||||
// while the panel is open a new `version` reads the panel again. The context is the costly part
|
// while the panel is open a new `version` reads the panel again. The context is the costly part
|
||||||
@@ -23,6 +35,7 @@ import { get, post, follow, why } from "./bridge.js"
|
|||||||
import { markdown } from "./markdown.js"
|
import { markdown } from "./markdown.js"
|
||||||
import { ask } from "./popup.js"
|
import { ask } from "./popup.js"
|
||||||
import { openServerFile } from "./explorer.js"
|
import { openServerFile } from "./explorer.js"
|
||||||
|
import { treePart, tickTree, treeState, TREE_LIVE } from "./tree.js"
|
||||||
|
|
||||||
const ACTIVE = ["queued", "working", "waiting"]
|
const ACTIVE = ["queued", "working", "waiting"]
|
||||||
// How many live agents show before "Show N more", as on the server's panel.
|
// How many live agents show before "Show N more", as on the server's panel.
|
||||||
@@ -31,6 +44,8 @@ const SHOWN = 6
|
|||||||
const CONTEXT_EVERY = 15000
|
const CONTEXT_EVERY = 15000
|
||||||
|
|
||||||
let layout = () => {} // (on) -> the shell makes room for the panel
|
let layout = () => {} // (on) -> the shell makes room for the panel
|
||||||
|
let onThisComputer = () => false // (chat) -> whether an agent chat works on this computer
|
||||||
|
let onCompacted = () => {} // (chat id) -> the conversation is read again
|
||||||
let chat = null // the open chat: { id, kind, … }
|
let chat = null // the open chat: { id, kind, … }
|
||||||
let stream = null // the events stream's handle
|
let stream = null // the events stream's handle
|
||||||
let retry = 0 // the reconnect timer
|
let retry = 0 // the reconnect timer
|
||||||
@@ -44,7 +59,12 @@ let readAt = 0 // when `data` was read: the elapsed times count on from there
|
|||||||
let contextAt = 0
|
let contextAt = 0
|
||||||
let reading = null // the read in flight
|
let reading = null // the read in flight
|
||||||
let readAgain = "" // "", "plain" or "context": what to read again once the read in flight is done
|
let readAgain = "" // "", "plain" or "context": what to read again once the read in flight is done
|
||||||
let view = null // null: the panel; { kind, id, name }: one agent's conversation
|
let view = null // null: the panel; { kind, id, name }: one agent's conversation ({ kind: "sub", what } a subagent's)
|
||||||
|
let tree = null // the subagent tree (spec 3.4.0), null for none or an older server
|
||||||
|
let treeRoom = [] // the room as the tree's answer gives it (an agent chat's comes only there)
|
||||||
|
let treeLive = true // false: an agent chat's device is away, and this is the tree the server kept
|
||||||
|
let treeNote = "" // why the last stop did not go through
|
||||||
|
let subTimer = 0 // a live subagent's conversation, read again while it works
|
||||||
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
|
const openRows = new Set() // agents unfolded, by kind:id; sections folded, by name
|
||||||
let ticker = 0
|
let ticker = 0
|
||||||
|
|
||||||
@@ -69,12 +89,14 @@ export function compact(value) {
|
|||||||
return `${trim(n / 1000000, 2)}M`
|
return `${trim(n / 1000000, 2)}M`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "3 agents working · 1 queued · 2 finished", or "" for none (the server's `summary_text`). */
|
/**
|
||||||
|
* "3 running · 1 queued · 2 finished", or "" for none: every background task -- a subagent, a
|
||||||
|
* team's member, a command -- counts the same, as the panel lists them together.
|
||||||
|
*/
|
||||||
export function summaryText(counts) {
|
export function summaryText(counts) {
|
||||||
if (!counts) return ""
|
if (!counts) return ""
|
||||||
const parts = []
|
const parts = []
|
||||||
if (counts.working === 1) parts.push(t("1 agent working"))
|
if (counts.working) parts.push(t("%(n)s running", { n: counts.working }))
|
||||||
else if (counts.working) parts.push(t("%(n)s agents working", { n: counts.working }))
|
|
||||||
if (counts.queued) parts.push(t("%(n)s queued", { n: counts.queued }))
|
if (counts.queued) parts.push(t("%(n)s queued", { n: counts.queued }))
|
||||||
if (counts.waiting) parts.push(t("%(n)s waiting", { n: counts.waiting }))
|
if (counts.waiting) parts.push(t("%(n)s waiting", { n: counts.waiting }))
|
||||||
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
|
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
|
||||||
@@ -139,11 +161,16 @@ export function watchActivity(next) {
|
|||||||
data = null
|
data = null
|
||||||
room = []
|
room = []
|
||||||
cursor = 0
|
cursor = 0
|
||||||
|
tree = null
|
||||||
|
treeRoom = []
|
||||||
|
treeLive = true
|
||||||
|
treeNote = ""
|
||||||
readAt = contextAt = 0
|
readAt = contextAt = 0
|
||||||
view = null
|
view = null
|
||||||
|
compactError = ""
|
||||||
openRows.clear()
|
openRows.clear()
|
||||||
renderStrip()
|
renderStrip()
|
||||||
$("activity-open")?.setAttribute("aria-expanded", String(panelOpen()))
|
markOpen()
|
||||||
if (!chat) {
|
if (!chat) {
|
||||||
if (panelOpen()) showPanel(false)
|
if (panelOpen()) showPanel(false)
|
||||||
return
|
return
|
||||||
@@ -155,6 +182,15 @@ export function watchActivity(next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The reply stream's `tree` frame: the tree as it is now. */
|
||||||
|
export function treeChanged(chatId, next) {
|
||||||
|
if (!chat || chat.id !== chatId) return
|
||||||
|
tree = next || null
|
||||||
|
if (!panelOpen()) return
|
||||||
|
if (!view) render()
|
||||||
|
else if (view.kind === "sub" && view.what === "transcript") readAgent()
|
||||||
|
}
|
||||||
|
|
||||||
/** A reply has ended, or the chat's settings changed: the context and the outputs move with it. */
|
/** A reply has ended, or the chat's settings changed: the context and the outputs move with it. */
|
||||||
export function activityChanged() {
|
export function activityChanged() {
|
||||||
if (chat && panelOpen()) read(true)
|
if (chat && panelOpen()) read(true)
|
||||||
@@ -232,8 +268,16 @@ async function read(withContext) {
|
|||||||
const id = chat.id
|
const id = chat.id
|
||||||
reading = (async () => {
|
reading = (async () => {
|
||||||
try {
|
try {
|
||||||
const a = (await get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`)).activity
|
const [r, tr] = await Promise.all([
|
||||||
|
get(`/chats/${enc(id)}/activity?since=${cursor}&context=${withContext ? "true" : "false"}`),
|
||||||
|
// A server before 3.4.0 has no tree: 404, and the panel is as it was.
|
||||||
|
get(`/chats/${enc(id)}/tree`).catch(() => null),
|
||||||
|
])
|
||||||
|
const a = r.activity
|
||||||
if (chat?.id !== id || !a) return
|
if (chat?.id !== id || !a) return
|
||||||
|
tree = tr?.tree || null
|
||||||
|
treeRoom = tr?.room || []
|
||||||
|
treeLive = tr ? tr.live !== false : true
|
||||||
if (!withContext) a.context = data?.context ?? null
|
if (!withContext) a.context = data?.context ?? null
|
||||||
else contextAt = Date.now()
|
else contextAt = Date.now()
|
||||||
if (a.team) {
|
if (a.team) {
|
||||||
@@ -269,36 +313,58 @@ async function read(withContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- The strip above the message box ---------------------------------------------------------------
|
// --- The line above the message box, and the header's button ---------------------------------------
|
||||||
|
|
||||||
|
/** Both ways into the panel say whether it is open. */
|
||||||
|
function markOpen() {
|
||||||
|
for (const id of ["activity-open", "activity-strip-btn"]) $(id)?.setAttribute("aria-expanded", String(panelOpen()))
|
||||||
|
}
|
||||||
|
|
||||||
function renderStrip() {
|
function renderStrip() {
|
||||||
const strip = $("activity-strip")
|
const strip = $("activity-strip")
|
||||||
// A chip for every open chat: what runs, while something does, as the server's strip says it (a
|
// Only while something runs, as the server's own line: the header's button is the way into the
|
||||||
// chat whose helpers all finished long ago does not carry "12 finished" for ever: the panel
|
// panel at any other time (and a chat whose tasks all finished long ago does not carry
|
||||||
// still lists them); else only the way into the panel.
|
// "12 finished" for ever: the panel still lists them).
|
||||||
strip.hidden = !chat
|
const active = chat && summary?.active ? summaryText(summary) : ""
|
||||||
if (!chat) return clear(strip)
|
strip.hidden = !active
|
||||||
const active = summary?.active ? summaryText(summary) : ""
|
if (!active) return clear(strip)
|
||||||
const text = active || t("Activity")
|
|
||||||
strip.replaceChildren(
|
strip.replaceChildren(
|
||||||
h(
|
h(
|
||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
type: "button",
|
type: "button",
|
||||||
id: "activity-open",
|
id: "activity-strip-btn",
|
||||||
class: `strip__btn${active ? " is-active" : ""}`,
|
class: "strip__btn is-active",
|
||||||
"aria-label": `${text} — ${panelOpen() ? t("Close the activity panel") : t("Open the activity panel")}`,
|
"aria-label": `${active} — ${panelOpen() ? t("Close the activity panel") : t("Open the activity panel")}`,
|
||||||
"aria-controls": "activity",
|
"aria-controls": "activity",
|
||||||
"aria-expanded": String(panelOpen()),
|
"aria-expanded": String(panelOpen()),
|
||||||
onclick: togglePanel,
|
onclick: togglePanel,
|
||||||
},
|
},
|
||||||
h("span", { class: "dot", "aria-hidden": "true" }),
|
h("span", { class: "dot", "aria-hidden": "true" }),
|
||||||
h("span", { class: "strip__text", text }),
|
h("span", { class: "strip__text", text: active }),
|
||||||
icon(ICONS.chevron),
|
icon(ICONS.chevron),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The header's button: the panel, open or closed, for every open chat. */
|
||||||
|
export function activityButton() {
|
||||||
|
return h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "tool",
|
||||||
|
id: "activity-open",
|
||||||
|
"aria-label": t("Context and background tasks"),
|
||||||
|
title: t("Context and background tasks"),
|
||||||
|
"aria-controls": "activity",
|
||||||
|
"aria-expanded": String(panelOpen()),
|
||||||
|
onclick: togglePanel,
|
||||||
|
},
|
||||||
|
icon(ICONS.tasks),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- The panel ------------------------------------------------------------------------------------------
|
// --- The panel ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Open or close the panel (the header's Activity button, the strip, Escape). */
|
/** Open or close the panel (the header's Activity button, the strip, Escape). */
|
||||||
@@ -306,14 +372,16 @@ export function showPanel(on) {
|
|||||||
if (on && !chat) return
|
if (on && !chat) return
|
||||||
$("activity").hidden = !on
|
$("activity").hidden = !on
|
||||||
layout(on)
|
layout(on)
|
||||||
$("activity-open")?.setAttribute("aria-expanded", String(on))
|
markOpen()
|
||||||
clearInterval(ticker)
|
clearInterval(ticker)
|
||||||
ticker = 0
|
ticker = 0
|
||||||
if (!on) {
|
if (!on) {
|
||||||
view = null
|
view = null
|
||||||
|
renderStrip()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ticker = setInterval(tick, 1000)
|
ticker = setInterval(tick, 1000)
|
||||||
|
renderStrip()
|
||||||
render()
|
render()
|
||||||
read(true)
|
read(true)
|
||||||
$("activity-close").focus()
|
$("activity-close").focus()
|
||||||
@@ -330,6 +398,7 @@ function tick() {
|
|||||||
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
|
for (const el of document.querySelectorAll("#activity .agent__time[data-active]")) {
|
||||||
el.textContent = duration(Number(el.dataset.seconds) + extra)
|
el.textContent = duration(Number(el.dataset.seconds) + extra)
|
||||||
}
|
}
|
||||||
|
tickTree($("activity-body"))
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderError(text) {
|
function renderError(text) {
|
||||||
@@ -359,15 +428,45 @@ function render() {
|
|||||||
}
|
}
|
||||||
// Kept where the reader was: the panel is drawn again on every change.
|
// Kept where the reader was: the panel is drawn again on every change.
|
||||||
const scroll = body.scrollTop
|
const scroll = body.scrollTop
|
||||||
body.replaceChildren(
|
// The tree draws the subagents; a team's members are not drawn again among the tasks.
|
||||||
|
const entries = (data.agents || []).filter((e) => !(tree && e.kind === "member"))
|
||||||
|
const parts = [
|
||||||
|
chat?.kind === "agent" && chat.device ? section("environment", t("Environment"), 0, environmentPart(chat)) : null,
|
||||||
section("context", t("Context"), 0, contextPart(data.context)),
|
section("context", t("Context"), 0, contextPart(data.context)),
|
||||||
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
|
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
|
||||||
section("tasks", t("Background tasks"), 0, tasksPart()),
|
tree ? section("subagents", t("Subagents"), 0, subagentsPart()) : null,
|
||||||
|
section("tasks", t("Background tasks"), 0, tasksPart(entries)),
|
||||||
|
data.team || treeRoom.length ? section("room", t("Room"), roomList().length, roomPart()) : null,
|
||||||
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
|
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
|
||||||
)
|
]
|
||||||
|
body.replaceChildren(...parts.filter(Boolean))
|
||||||
body.scrollTop = scroll
|
body.scrollTop = scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many tasks, nested ones included: those still at work, or those finished. */
|
||||||
|
function count(list, active) {
|
||||||
|
let n = 0
|
||||||
|
for (const a of list) {
|
||||||
|
if (ACTIVE.includes(a.state) === active) n += 1
|
||||||
|
n += count(a.children || [], active)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where an agent chat works: its folder, on this computer or another device. */
|
||||||
|
function environmentPart(c) {
|
||||||
|
const dir = c.device?.directory || ""
|
||||||
|
const name = dir.split(/[\\/]/).filter(Boolean).pop() || dir
|
||||||
|
const here = onThisComputer(c)
|
||||||
|
return h(
|
||||||
|
"p",
|
||||||
|
{ class: "env", title: dir },
|
||||||
|
icon(ICONS.folder),
|
||||||
|
h("bdi", { class: "env__name", text: name }),
|
||||||
|
h("span", { class: "env__where", text: here ? t("This computer") : t("Another device") }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function contextPart(c) {
|
function contextPart(c) {
|
||||||
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
|
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
|
||||||
const known = c.percent !== null && c.percent !== undefined
|
const known = c.percent !== null && c.percent !== undefined
|
||||||
@@ -410,7 +509,67 @@ function contextPart(c) {
|
|||||||
{ class: "ctx__note" },
|
{ class: "ctx__note" },
|
||||||
`${c.measured ? t("Counted by the model at the last reply; the categories are estimated.") : t("Estimated: no reply has been counted yet.")} ${t("%(n)s tools offered.", { n: c.tools_count ?? 0 })}`,
|
`${c.measured ? t("Counted by the model at the last reply; the categories are estimated.") : t("Estimated: no reply has been counted yet.")} ${t("%(n)s tools offered.", { n: c.tools_count ?? 0 })}`,
|
||||||
)
|
)
|
||||||
return h("div", { class: "ctx" }, used, bar, rows, note)
|
return h("div", { class: "ctx" }, used, bar, rows, compactRow(c), note)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far it is to the automatic compaction, and the button that compacts now. The server says it
|
||||||
|
* (2.0.0-beta.7: `auto_compact`, `until_compact` in tokens, `compactable` whether Compact can run
|
||||||
|
* now); an older one gives only `threshold` -- the server compacts before a turn once the context
|
||||||
|
* would reach that % of the window, 0 is off -- and it is worked out from that.
|
||||||
|
*/
|
||||||
|
function compactRow(c) {
|
||||||
|
const limit = Number(c.limit) || 0
|
||||||
|
const threshold = Number(c.threshold)
|
||||||
|
const auto = "auto_compact" in c ? !!c.auto_compact : threshold > 0
|
||||||
|
let until = c.until_compact
|
||||||
|
if (until === undefined && auto && limit && threshold < 100) until = Math.floor((limit * threshold) / 100) - (Number(c.used) || 0)
|
||||||
|
let words
|
||||||
|
if (!auto) words = t("Automatic compaction is off.")
|
||||||
|
else if (until === null || until === undefined) words = t("Compacts by itself once the window is known.")
|
||||||
|
else words = until > 0 ? t("%(tokens)s until auto-compact", { tokens: compact(until) }) : t("Compacts before the next reply.")
|
||||||
|
const busy = compacting === chat?.id
|
||||||
|
// `compactable` false: nothing to summarise yet, a reply being written, compaction turned off --
|
||||||
|
// the button stays, greyed; pressing an older server's says why in its own words.
|
||||||
|
const can = !("compactable" in c) || !!c.compactable
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{ class: "ctx__compact" },
|
||||||
|
h("span", { class: "ctx__until", text: words }),
|
||||||
|
h("button", {
|
||||||
|
type: "button",
|
||||||
|
class: "btn btn--sm",
|
||||||
|
text: busy ? t("Compacting…") : t("Compact"),
|
||||||
|
disabled: busy || !can,
|
||||||
|
title: t("Summarise the earlier turns now and send the summary in their place."),
|
||||||
|
onclick: compactNow,
|
||||||
|
}),
|
||||||
|
compactError ? h("p", { class: "ctx__error", role: "alert", text: compactError }) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let compacting = null // the chat being compacted
|
||||||
|
let compactError = ""
|
||||||
|
|
||||||
|
async function compactNow() {
|
||||||
|
if (!chat || compacting) return
|
||||||
|
const id = chat.id
|
||||||
|
compacting = id
|
||||||
|
compactError = ""
|
||||||
|
render()
|
||||||
|
try {
|
||||||
|
await post(`/chats/${enc(id)}/compact`)
|
||||||
|
compacting = null
|
||||||
|
if (chat?.id !== id) return
|
||||||
|
onCompacted(id)
|
||||||
|
read(true)
|
||||||
|
} catch (err) {
|
||||||
|
compacting = null
|
||||||
|
if (chat?.id !== id) return
|
||||||
|
// A server without the route in its /api/v2 says 404 or 405: said as what it means.
|
||||||
|
compactError = err?.status === 404 || err?.status === 405 ? t("This server cannot compact a chat from the app yet; the web interface can.") : why(err)
|
||||||
|
render()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A category's key as a class name: the server's words, kept to letters and underscores. */
|
/** A category's key as a class name: the server's words, kept to letters and underscores. */
|
||||||
@@ -439,64 +598,148 @@ function outputsPart(outputs) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function tasksPart() {
|
function tasksPart(entries) {
|
||||||
const entries = data.agents || []
|
const team = tree ? null : data.team
|
||||||
const team = data.team
|
|
||||||
const box = h("div", { class: "tasks", "aria-live": "polite" })
|
const box = h("div", { class: "tasks", "aria-live": "polite" })
|
||||||
if (!entries.length && !team) {
|
if (!entries.length && !team) {
|
||||||
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Helpers, commands and a team's members show here while they work.") }))
|
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Subagents, a team's members and background commands show here while they work.") }))
|
||||||
return box
|
return box
|
||||||
}
|
}
|
||||||
box.append(h("p", { class: "tasks__summary", text: summaryText(data.summary) || t("Nothing running.") }))
|
|
||||||
if (team) {
|
if (team) {
|
||||||
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of %(max)s agents", { size: team.size, max: team.max }) }))
|
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of at most %(max)s subagents", { size: team.size, max: team.max }) }))
|
||||||
if (team.goal) box.append(h("p", { class: "tasks__goal", text: team.goal }))
|
if (team.goal) box.append(h("p", { class: "tasks__goal", text: team.goal }))
|
||||||
}
|
}
|
||||||
const live = entries.filter((e) => ACTIVE.includes(e.state))
|
// At the top level, what still runs -- with its own tasks under it, finished or not -- and then
|
||||||
const ended = entries.filter((e) => !ACTIVE.includes(e.state))
|
// what finished with nothing under it still at work, folded.
|
||||||
|
const live = entries.filter((e) => ACTIVE.includes(e.state) || count(e.children || [], true))
|
||||||
|
const ended = entries.filter((e) => !live.includes(e))
|
||||||
|
box.append(h("p", { class: "tasks__group" }, h("span", { text: t("Running") }), h("span", { class: "activity__count", text: String(count(entries, true)) })))
|
||||||
if (live.length) {
|
if (live.length) {
|
||||||
box.append(h("div", { class: "tasks__list" }, live.slice(0, SHOWN).map(agentRows)))
|
box.append(h("div", { class: "tasks__list" }, live.slice(0, SHOWN).map(agentRows)))
|
||||||
if (live.length > SHOWN) box.append(fold("more", t("Show %(n)s more", { n: live.length - SHOWN }), live.slice(SHOWN)))
|
if (live.length > SHOWN) box.append(fold("more", t("Show %(n)s more", { n: live.length - SHOWN }), live.slice(SHOWN)))
|
||||||
}
|
} else box.append(h("p", { class: "activity__empty", text: t("Nothing running.") }))
|
||||||
if (ended.length) box.append(fold("finished", t("%(n)s finished", { n: ended.length }), ended))
|
if (ended.length) box.append(fold("finished", t("Finished %(n)s", { n: count(ended, false) }), ended, "tasks__finished"))
|
||||||
if (team) {
|
|
||||||
box.append(h("h3", { class: "activity__subheading", text: t("Room") }))
|
|
||||||
box.append(
|
|
||||||
room.length
|
|
||||||
? h(
|
|
||||||
"ol",
|
|
||||||
{ class: "room" },
|
|
||||||
room.map((m) =>
|
|
||||||
h(
|
|
||||||
"li",
|
|
||||||
{ class: `room__entry${m.report ? " is-report" : ""}` },
|
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{ class: "room__meta" },
|
|
||||||
h("span", { class: "room__n", text: `#${m.n}` }),
|
|
||||||
h("span", { class: "room__route", text: `${m.from} → ${(m.to || []).join(", ")}` }),
|
|
||||||
m.report ? h("span", { class: "chip room__badge", text: t("report") }) : null,
|
|
||||||
),
|
|
||||||
h("div", { class: "room__text md" }, markdown(String(m.text || ""))),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return box
|
return box
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A folded list of agents ("Show 3 more", "12 finished"), remembered open across redraws. */
|
/** The room: as the activity read it (a chat's team), else as the tree's answer gave it. */
|
||||||
function fold(name, label, list) {
|
function roomList() {
|
||||||
const el = h("details", { class: "tasks__more", open: openRows.has(`fold:${name}`) }, h("summary", { text: label }), h("div", { class: "tasks__list" }, list.map(agentRows)))
|
return room.length ? room : treeRoom
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentsPart() {
|
||||||
|
const box = treePart(tree, {
|
||||||
|
room: roomList(),
|
||||||
|
device: chat?.kind === "agent",
|
||||||
|
act: {
|
||||||
|
open: (a) => openSub(a, "transcript"),
|
||||||
|
findings: (a) => openSub(a, "findings"),
|
||||||
|
stop: stopSub,
|
||||||
|
isOpen: (key) => openRows.has(key),
|
||||||
|
toggled: (key, on) => (on ? openRows.add(key) : openRows.delete(key)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!treeLive) box.prepend(h("p", { class: "activity__empty", text: t("This device is not linked right now.") }))
|
||||||
|
if (treeNote) box.prepend(h("p", { class: "activity__empty is-error", text: treeNote }))
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopSub(a) {
|
||||||
|
if (!chat) return
|
||||||
|
const yes = await ask({ title: t("Stop %(name)s", { name: a.name }), text: t("Stop %(name)s and the subagents under it?", { name: a.name }), ok: t("Stop"), cancel: t("Cancel"), danger: true })
|
||||||
|
if (!yes) return
|
||||||
|
treeNote = ""
|
||||||
|
try {
|
||||||
|
await post(`/chats/${enc(chat.id)}/subagents/${enc(a.id)}/stop`)
|
||||||
|
} catch (err) {
|
||||||
|
treeNote = why(err)
|
||||||
|
}
|
||||||
|
read(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSub(a, what) {
|
||||||
|
view = { kind: "sub", id: a.id, name: a.name || a.id, what }
|
||||||
|
$("activity").dataset.view = "agent"
|
||||||
|
$("activity-title").textContent = what === "findings" ? `${view.name} · ${t("Findings")}` : view.name
|
||||||
|
$("activity-back").hidden = false
|
||||||
|
clear($("activity-body")).append(h("p", { class: "activity__empty", text: t("Loading…") }))
|
||||||
|
readAgent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A subagent's conversation or findings, read-only; a live one's conversation is read again while it works. */
|
||||||
|
async function readSub() {
|
||||||
|
clearTimeout(subTimer)
|
||||||
|
const { id, what } = view
|
||||||
|
let r
|
||||||
|
try {
|
||||||
|
r = await get(`/chats/${enc(chat.id)}/subagents/${enc(id)}/${what}`)
|
||||||
|
} catch (err) {
|
||||||
|
// 409: an agent chat's device is away -- the server says it is on the device.
|
||||||
|
if (view?.id === id && view.what === what) clear($("activity-body")).append(h("p", { class: "activity__empty is-error", text: why(err) }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (view?.kind !== "sub" || view.id !== id || view.what !== what) return
|
||||||
|
const body = $("activity-body")
|
||||||
|
if (what === "findings") {
|
||||||
|
const list = r.findings || []
|
||||||
|
body.replaceChildren(
|
||||||
|
list.length
|
||||||
|
? h("ol", { class: "findings" }, list.map((f) => h("li", { class: "findings__item", value: String(f.n) }, h("div", { class: "md" }, markdown(String(f.text || ""))))))
|
||||||
|
: h("p", { class: "activity__empty", text: t("Nothing yet.") }),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const atEnd = body.scrollHeight - body.scrollTop - body.clientHeight < 40
|
||||||
|
const live = TREE_LIVE.includes(r.state)
|
||||||
|
const head = h("div", { class: "convo__head" }, h("p", { class: "agent__facts" }, h("span", { class: "agent__state", text: treeState(r.state) })))
|
||||||
|
const turns = turnsOf(r.turns || [])
|
||||||
|
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
|
||||||
|
if (atEnd) body.scrollTop = body.scrollHeight
|
||||||
|
if (live) subTimer = setTimeout(() => view?.kind === "sub" && view.id === id && panelOpen() && readAgent(), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomPart() {
|
||||||
|
const room = roomList()
|
||||||
|
if (!room.length) return h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") })
|
||||||
|
return h(
|
||||||
|
"ol",
|
||||||
|
{ class: "room" },
|
||||||
|
room.map((m) =>
|
||||||
|
h(
|
||||||
|
"li",
|
||||||
|
{ class: `room__entry${m.report ? " is-report" : ""}` },
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "room__meta" },
|
||||||
|
h("span", { class: "room__n", text: `#${m.n}` }),
|
||||||
|
h("span", { class: "room__route", text: `${m.from} → ${(m.to || []).join(", ")}` }),
|
||||||
|
m.report ? h("span", { class: "chip room__badge", text: t("report") }) : null,
|
||||||
|
),
|
||||||
|
h("div", { class: "room__text md" }, markdown(String(m.text || ""))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A folded list of tasks ("Show 3 more", "Finished 12", "Tasks: 2"), remembered across redraws. */
|
||||||
|
function fold(name, label, list, cls = "") {
|
||||||
|
const el = h(
|
||||||
|
"details",
|
||||||
|
{ class: `tasks__more ${cls}`, open: openRows.has(`fold:${name}`) },
|
||||||
|
h("summary", null, h("span", { class: "tasks__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), h("span", { text: label })),
|
||||||
|
h("div", { class: "tasks__list" }, list.map(agentRows)),
|
||||||
|
)
|
||||||
el.addEventListener("toggle", () => (el.open ? openRows.add(`fold:${name}`) : openRows.delete(`fold:${name}`)))
|
el.addEventListener("toggle", () => (el.open ? openRows.add(`fold:${name}`) : openRows.delete(`fold:${name}`)))
|
||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An agent and, under it, the agents it leads or started. */
|
/** A task and, folded under it, the tasks it leads or started ("Tasks: 3"). */
|
||||||
function agentRows(a) {
|
function agentRows(a) {
|
||||||
return [agentRow(a), ...(a.children || []).map(agentRows)]
|
const kids = a.children || []
|
||||||
|
if (!kids.length) return agentRow(a)
|
||||||
|
const nested = fold(`under:${a.kind}:${a.id}`, t("Tasks: %(n)s", { n: kids.length }), kids, "tasks__nested")
|
||||||
|
nested.style.setProperty("--depth", String(Math.max(0, Math.min(6, (Number(a.depth) || 0) + 1))))
|
||||||
|
return [agentRow(a), nested]
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentRow(a) {
|
function agentRow(a) {
|
||||||
@@ -512,7 +755,7 @@ function agentRow(a) {
|
|||||||
"button",
|
"button",
|
||||||
{
|
{
|
||||||
type: "button",
|
type: "button",
|
||||||
class: "pane__btn agent__stop",
|
class: "agent__stop",
|
||||||
"aria-label": t("Stop %(name)s", { name: a.name }),
|
"aria-label": t("Stop %(name)s", { name: a.name }),
|
||||||
title: t("Stop %(name)s", { name: a.name }),
|
title: t("Stop %(name)s", { name: a.name }),
|
||||||
onclick: (e) => {
|
onclick: (e) => {
|
||||||
@@ -521,13 +764,15 @@ function agentRow(a) {
|
|||||||
stopAgent(a)
|
stopAgent(a)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
icon(ICONS.stop),
|
h("span", { class: "agent__square", "aria-hidden": "true" }),
|
||||||
)
|
)
|
||||||
: h("span", { class: "agent__stop", "aria-hidden": "true" })
|
: h("span", { class: "agent__stop is-none", "aria-hidden": "true" })
|
||||||
const facts = h(
|
const facts = h(
|
||||||
"p",
|
"p",
|
||||||
{ class: "agent__facts" },
|
{ class: "agent__facts" },
|
||||||
h("span", { class: "agent__state", text: stateWord(a.state) }),
|
h("span", { class: "agent__state", text: stateWord(a.state) }),
|
||||||
|
h("span", { text: kindWord(a) }),
|
||||||
|
a.kind !== "member" && a.role ? h("code", { class: "agent__id", text: a.name || a.id }) : null,
|
||||||
a.agent ? h("span", { text: a.agent }) : null,
|
a.agent ? h("span", { text: a.agent }) : null,
|
||||||
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
||||||
a.steps ? h("span", { text: t("%(steps)s steps, %(tools)s tools", { steps: a.steps, tools: a.tools }) }) : null,
|
a.steps ? h("span", { text: t("%(steps)s steps, %(tools)s tools", { steps: a.steps, tools: a.tools }) }) : null,
|
||||||
@@ -547,8 +792,8 @@ function agentRow(a) {
|
|||||||
h(
|
h(
|
||||||
"summary",
|
"summary",
|
||||||
{ class: "agent__row" },
|
{ class: "agent__row" },
|
||||||
h("span", { class: "agent__dot", "aria-hidden": "true" }),
|
h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)),
|
||||||
h("span", { class: "agent__what" }, h("span", { class: "agent__name", text: a.name || a.id }), a.role ? h("span", { class: "agent__role", text: a.role }) : null),
|
h("span", { class: "agent__what", title: label(a) }, h("bdi", { class: "agent__name", text: label(a) })),
|
||||||
time,
|
time,
|
||||||
stopBtn,
|
stopBtn,
|
||||||
),
|
),
|
||||||
@@ -556,12 +801,11 @@ function agentRow(a) {
|
|||||||
"div",
|
"div",
|
||||||
{ class: "agent__body" },
|
{ class: "agent__body" },
|
||||||
facts,
|
facts,
|
||||||
|
a.kind === "member" && a.role ? h("p", { class: "agent__role", text: a.role }) : null,
|
||||||
a.brief ? h("p", { class: "agent__brief", text: a.brief }) : null,
|
a.brief ? h("p", { class: "agent__brief", text: a.brief }) : null,
|
||||||
step,
|
step,
|
||||||
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
|
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
|
||||||
a.transcript
|
a.transcript ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) }) : null,
|
||||||
? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) })
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, Number(a.depth) || 0))))
|
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, Number(a.depth) || 0))))
|
||||||
@@ -569,6 +813,22 @@ function agentRow(a) {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A task's line: a team's member by its name (it is how the room and the others call it); a
|
||||||
|
* subagent or a command by what it was given to do, its id only where there is nothing else.
|
||||||
|
*/
|
||||||
|
function label(a) {
|
||||||
|
if (a.kind === "member") return a.name || a.id
|
||||||
|
return a.role || a.brief || a.name || a.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a task is: a team's member, a subagent, or a background command. */
|
||||||
|
function kindWord(a) {
|
||||||
|
if (a.kind === "member") return t("Team member")
|
||||||
|
if (a.type === "command") return t("Background command")
|
||||||
|
return t("Subagent")
|
||||||
|
}
|
||||||
|
|
||||||
async function stopAgent(a) {
|
async function stopAgent(a) {
|
||||||
if (!chat) return
|
if (!chat) return
|
||||||
if (a.kind === "member") {
|
if (a.kind === "member") {
|
||||||
@@ -602,6 +862,7 @@ function openAgent(a) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeAgent() {
|
function closeAgent() {
|
||||||
|
clearTimeout(subTimer)
|
||||||
view = null
|
view = null
|
||||||
render()
|
render()
|
||||||
$("activity-close").focus()
|
$("activity-close").focus()
|
||||||
@@ -609,6 +870,7 @@ function closeAgent() {
|
|||||||
|
|
||||||
async function readAgent() {
|
async function readAgent() {
|
||||||
if (!chat || !view) return
|
if (!chat || !view) return
|
||||||
|
if (view.kind === "sub") return readSub()
|
||||||
const { kind, id } = view
|
const { kind, id } = view
|
||||||
let r
|
let r
|
||||||
try {
|
try {
|
||||||
@@ -634,7 +896,14 @@ async function readAgent() {
|
|||||||
a.role ? h("p", { class: "agent__role", text: a.role }) : null,
|
a.role ? h("p", { class: "agent__role", text: a.role }) : null,
|
||||||
a.stoppable ? h("button", { type: "button", class: "btn btn--sm", text: t("Stop %(name)s", { name: a.name || id }), onclick: () => stopAgent(a) }) : null,
|
a.stoppable ? h("button", { type: "button", class: "btn btn--sm", text: t("Stop %(name)s", { name: a.name || id }), onclick: () => stopAgent(a) }) : null,
|
||||||
)
|
)
|
||||||
const turns = (r.transcript || []).map((turn) => {
|
const turns = turnsOf(r.transcript || [])
|
||||||
|
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
|
||||||
|
if (atEnd) body.scrollTop = body.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A conversation's turns, read-only: the person's (or the harness's) messages and the replies. */
|
||||||
|
function turnsOf(list) {
|
||||||
|
return list.map((turn) => {
|
||||||
if (turn.role === "user") {
|
if (turn.role === "user") {
|
||||||
return h("article", { class: `msg msg--user convo__turn${turn.machine ? " is-machine" : ""}` }, h("div", { class: "msg__text", text: String(turn.text || "") }))
|
return h("article", { class: `msg msg--user convo__turn${turn.machine ? " is-machine" : ""}` }, h("div", { class: "msg__text", text: String(turn.text || "") }))
|
||||||
}
|
}
|
||||||
@@ -661,14 +930,14 @@ async function readAgent() {
|
|||||||
turn.running ? h("p", { class: "msg__status muted", text: t("Writing…") }) : null,
|
turn.running ? h("p", { class: "msg__status muted", text: t("Writing…") }) : null,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
body.replaceChildren(head, h("div", { class: "convo" }, turns.length ? turns : h("p", { class: "activity__empty", text: t("Nothing yet.") })))
|
|
||||||
if (atEnd) body.scrollTop = body.scrollHeight
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Start ------------------------------------------------------------------------------------------
|
// --- Start ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
export function initActivity({ onLayout }) {
|
export function initActivity({ onLayout, here, compacted }) {
|
||||||
layout = onLayout || layout
|
layout = onLayout || layout
|
||||||
|
onThisComputer = here || onThisComputer
|
||||||
|
onCompacted = compacted || onCompacted
|
||||||
$("activity-close").replaceChildren(icon(ICONS.close))
|
$("activity-close").replaceChildren(icon(ICONS.close))
|
||||||
$("activity-close").addEventListener("click", () => showPanel(false))
|
$("activity-close").addEventListener("click", () => showPanel(false))
|
||||||
$("activity-back").replaceChildren(icon(ICONS.back))
|
$("activity-back").replaceChildren(icon(ICONS.back))
|
||||||
|
|||||||
@@ -66,6 +66,61 @@ export async function follow(path, { onFrame, onEnd }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- The terminal panel's socket -----------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Opened by Rust (`terminal_open`), which holds the token and the socket; the page gets the frames
|
||||||
|
// as `terminal` events -- `output` (the shell's bytes, base64), `control` (the server's JSON), a last
|
||||||
|
// `end` -- and sends keys, sizes and "close" through commands. As with a followed reply, a frame
|
||||||
|
// can come before `terminal_open` has answered with its id: those wait.
|
||||||
|
|
||||||
|
const terminals = new Map()
|
||||||
|
const earlyTerminal = new Map()
|
||||||
|
listen("terminal", (e) => {
|
||||||
|
const { id, kind, data } = e.payload
|
||||||
|
const f = terminals.get(id)
|
||||||
|
if (f) deliverTerminal(f, id, kind, data)
|
||||||
|
else {
|
||||||
|
if (!earlyTerminal.has(id)) earlyTerminal.set(id, [])
|
||||||
|
earlyTerminal.get(id).push([kind, data])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function deliverTerminal(f, id, kind, data) {
|
||||||
|
if (kind === "end") {
|
||||||
|
terminals.delete(id)
|
||||||
|
f.onEnd(data || {})
|
||||||
|
} else if (kind === "output") f.onOutput(data)
|
||||||
|
else if (kind === "control") f.onControl(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a shell: `target` is `{ chat }` or `{ device, directory }`; `onOutput(base64)`,
|
||||||
|
* `onControl(frame)`, and `onEnd({ code, reason, status, error })` once. The handle sends keys
|
||||||
|
* (`input`), a size (`resize`), and lets go (`close`; onEnd still comes).
|
||||||
|
*/
|
||||||
|
export async function openTerminal(target, cols, rows, { onOutput, onControl, onEnd }) {
|
||||||
|
const id = await invoke("terminal_open", {
|
||||||
|
chat: target.chat ?? null,
|
||||||
|
device: target.device ?? null,
|
||||||
|
directory: target.directory ?? null,
|
||||||
|
cols,
|
||||||
|
rows,
|
||||||
|
})
|
||||||
|
const f = { onOutput, onControl, onEnd }
|
||||||
|
terminals.set(id, f)
|
||||||
|
for (const [kind, data] of earlyTerminal.get(id) || []) {
|
||||||
|
if (!terminals.has(id)) break
|
||||||
|
deliverTerminal(f, id, kind, data)
|
||||||
|
}
|
||||||
|
earlyTerminal.delete(id)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
input: (data) => invoke("terminal_input", { id, data }),
|
||||||
|
resize: (c, r) => invoke("terminal_resize", { id, cols: c, rows: r }),
|
||||||
|
close: () => invoke("terminal_close", { id }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A web link, in the system's browser. */
|
/** A web link, in the system's browser. */
|
||||||
export function openLink(url) {
|
export function openLink(url) {
|
||||||
return invoke("open_external", { url }).catch(() => {})
|
return invoke("open_external", { url }).catch(() => {})
|
||||||
|
|||||||
+234
-41
@@ -13,15 +13,17 @@ import { markdown } from "./markdown.js"
|
|||||||
import { openViewer, showKind } from "./viewer.js"
|
import { openViewer, showKind } from "./viewer.js"
|
||||||
import { readLocal } from "./explorer.js"
|
import { readLocal } from "./explorer.js"
|
||||||
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
|
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
|
||||||
import { avatar, agentsControl, teamPossible, levelsOf, levelWord, picture } from "./models.js"
|
import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, noteSubagentsCap, SUBAGENTS_HINT, SUBAGENTS_OWN, levelsOf, levelWord, picture } from "./models.js"
|
||||||
import { readyIds, sending, sent, discard } from "./attach.js"
|
import { readyIds, sending, sent, discard } from "./attach.js"
|
||||||
import { watchActivity, activityChanged, planList } from "./activity.js"
|
import { watchActivity, activityChanged, planList, activityButton, treeChanged } from "./activity.js"
|
||||||
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
|
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
|
||||||
import { folderName } from "./list.js"
|
import { folderName } from "./list.js"
|
||||||
|
import { isProgress, progressLine, progressTicks } from "./progress.js"
|
||||||
|
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js"
|
||||||
|
|
||||||
const MODES = ["manual", "edit", "auto", "plan"]
|
const MODES = ["manual", "edit", "auto", "plan"]
|
||||||
|
|
||||||
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat), opened(chat), gone(chat) }
|
let ctx = null // set by initChat: { status(), thisDevices(), devices(), models(), me(), changed(chat), opened(chat), gone(chat) }
|
||||||
let open = null // { chat, live, more, first }
|
let open = null // { chat, live, more, first }
|
||||||
|
|
||||||
export function openChatId() {
|
export function openChatId() {
|
||||||
@@ -30,10 +32,80 @@ export function openChatId() {
|
|||||||
|
|
||||||
// --- Reading -----------------------------------------------------------------------------------
|
// --- Reading -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
function onThisComputer(chat) {
|
export function onThisComputer(chat) {
|
||||||
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
|
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A linked device, as `GET /devices` lists it (with `terminal`: whether it opens one). */
|
||||||
|
function deviceOf(id) {
|
||||||
|
return (ctx.devices?.() || []).find((d) => d.id === id) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The device a new agent chat goes to: this computer's, preferring the one linked right now. */
|
||||||
|
function draftDevice() {
|
||||||
|
const mine = ctx.thisDevices()
|
||||||
|
return mine.find((id) => deviceOf(id)) || mine[0] || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the Terminal panel is for, as the page stands: a new agent chat whose device and folder
|
||||||
|
* are chosen (the shell before the first message), an open agent chat on a device that opens
|
||||||
|
* terminals, or nothing.
|
||||||
|
*/
|
||||||
|
function terminalTarget() {
|
||||||
|
if (draft) {
|
||||||
|
if (draft.kind !== "agent" || !draft.dir) return null
|
||||||
|
const device = deviceOf(draftDevice())
|
||||||
|
if (!device?.terminal) return null
|
||||||
|
return { device: device.id, directory: draft.dir, name: device.name || t("This computer") }
|
||||||
|
}
|
||||||
|
const chat = open?.chat
|
||||||
|
if (chat?.kind !== "agent" || !chat.device) return null
|
||||||
|
const device = deviceOf(chat.device.token_id)
|
||||||
|
if (!device?.terminal) return null
|
||||||
|
return { chat: chat.id, device: device.id, directory: chat.device.directory, name: device.name || t("Terminal") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new agent chat's subagent facts (spec 3.4.0 §14): `/models?kind=agent&device=` -- each model's
|
||||||
|
// `subagents` for an agent chat, and `agent_subagents { offered, device_runs }` once. Read per
|
||||||
|
// device, again when a new chat starts; a server before 3.4.0 says nothing (no setting, as before).
|
||||||
|
const agentFacts = new Map() // device -> { at, models: Map(id -> subagents), offered, runs } | null
|
||||||
|
|
||||||
|
async function readAgentFacts(device) {
|
||||||
|
if (!device) return null
|
||||||
|
const known = agentFacts.get(device)
|
||||||
|
if (known && Date.now() - known.at < 5000) return known
|
||||||
|
try {
|
||||||
|
const r = await get(`/models?kind=agent&device=${encodeURIComponent(device)}`)
|
||||||
|
if (!r.agent_subagents) {
|
||||||
|
agentFacts.set(device, { at: Date.now(), none: true })
|
||||||
|
return agentFacts.get(device)
|
||||||
|
}
|
||||||
|
noteSubagentsCap(r.subagents_cap)
|
||||||
|
const facts = { at: Date.now(), models: new Map((r.models || []).map((m) => [m.id, m.subagents])), offered: !!r.agent_subagents.offered, runs: r.agent_subagents.device_runs }
|
||||||
|
agentFacts.set(device, facts)
|
||||||
|
return facts
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a new agent chat on `model` is offered subagents: `{ cap, own }`, or null. */
|
||||||
|
function agentOffer(facts, model) {
|
||||||
|
if (!facts || facts.none || !facts.offered || facts.runs === false) return null
|
||||||
|
const said = facts.models.get(model?.id)
|
||||||
|
if (said !== undefined && said !== null && !(typeof said === "object" ? said.offered : said)) return null
|
||||||
|
return { cap: subagentsCap(), own: namedOnly(said) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text into the message box, after what is there, for the person to read before sending. */
|
||||||
|
export function intoComposer(text) {
|
||||||
|
const box = $("composer-text")
|
||||||
|
box.value = box.value.trim() ? `${box.value.replace(/\s*$/, "")}\n\n${text}` : text
|
||||||
|
box.dispatchEvent(new Event("input"))
|
||||||
|
box.focus()
|
||||||
|
}
|
||||||
|
|
||||||
function nearBottom() {
|
function nearBottom() {
|
||||||
const th = $("thread")
|
const th = $("thread")
|
||||||
return th.scrollHeight - th.scrollTop - th.clientHeight < 80
|
return th.scrollHeight - th.scrollTop - th.clientHeight < 80
|
||||||
@@ -312,6 +384,24 @@ class Live {
|
|||||||
this.el = h("article", { class: "msg msg--assistant is-live", dataset: { id: messageId } }, this.steps, this.thinking, this.tail, this.jobs, this.status)
|
this.el = h("article", { class: "msg msg--assistant is-live", dataset: { id: messageId } }, this.steps, this.thinking, this.tail, this.jobs, this.status)
|
||||||
this.done = false
|
this.done = false
|
||||||
this.handle = null
|
this.handle = null
|
||||||
|
this.progress = null // the stream's `progress` (spec 3.4.0), drawn in place of `status` when it comes
|
||||||
|
this.said = "" // the last `status` text, for a server that sends no progress
|
||||||
|
this.clock = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The status line: the progress object's line where the server sends one, else its text. */
|
||||||
|
drawStatus() {
|
||||||
|
const p = this.progress
|
||||||
|
this.status.textContent = isProgress(p) ? progressLine(p) : this.said || t("Writing…")
|
||||||
|
this.status.dataset.phase = isProgress(p) ? p.phase : ""
|
||||||
|
const ticks = progressTicks(p)
|
||||||
|
if (ticks && !this.clock) this.clock = setInterval(() => (this.done ? this.stopClock() : this.drawStatus()), 1000)
|
||||||
|
else if (!ticks) this.stopClock()
|
||||||
|
}
|
||||||
|
|
||||||
|
stopClock() {
|
||||||
|
clearInterval(this.clock)
|
||||||
|
this.clock = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
@@ -327,11 +417,18 @@ class Live {
|
|||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.done = true
|
this.done = true
|
||||||
|
this.stopClock()
|
||||||
this.handle?.close()
|
this.handle?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
frame(event, data) {
|
frame(event, data) {
|
||||||
if (this.done) return
|
if (this.done) return
|
||||||
|
// Any frame that carries `progress` (the status frame does, from server 2.0.0-beta.8 on):
|
||||||
|
// the object, or null for nothing running -- then the status text again.
|
||||||
|
if (data && typeof data === "object" && Object.prototype.hasOwnProperty.call(data, "progress")) {
|
||||||
|
this.progress = isProgress(data.progress) ? data.progress : null
|
||||||
|
if (event !== "status") this.drawStatus()
|
||||||
|
}
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case "steps":
|
case "steps":
|
||||||
renderSteps(this.steps, data.steps || [], this.chat)
|
renderSteps(this.steps, data.steps || [], this.chat)
|
||||||
@@ -341,11 +438,16 @@ class Live {
|
|||||||
this.tailed(data)
|
this.tailed(data)
|
||||||
break
|
break
|
||||||
case "status":
|
case "status":
|
||||||
this.status.textContent = data.text || t("Writing…")
|
this.said = data.text || ""
|
||||||
|
this.drawStatus()
|
||||||
break
|
break
|
||||||
case "ask":
|
case "ask":
|
||||||
renderCards(this.chat, data.cards || [])
|
renderCards(this.chat, data.cards || [])
|
||||||
break
|
break
|
||||||
|
case "tree":
|
||||||
|
// The subagent tree as it changed (spec 3.4.0): the activity panel draws it.
|
||||||
|
treeChanged(this.chat.id, data.tree)
|
||||||
|
break
|
||||||
case "jobs":
|
case "jobs":
|
||||||
this.jobs.hidden = !(data.jobs || []).length
|
this.jobs.hidden = !(data.jobs || []).length
|
||||||
append(
|
append(
|
||||||
@@ -360,6 +462,7 @@ class Live {
|
|||||||
break
|
break
|
||||||
case "done":
|
case "done":
|
||||||
this.done = true
|
this.done = true
|
||||||
|
this.stopClock()
|
||||||
this.el.replaceWith(assistantMessage(data.message, this.chat))
|
this.el.replaceWith(assistantMessage(data.message, this.chat))
|
||||||
renderCards(this.chat, [])
|
renderCards(this.chat, [])
|
||||||
if (data.chat?.title) setTitle(data.chat.title)
|
if (data.chat?.title) setTitle(data.chat.title)
|
||||||
@@ -385,6 +488,7 @@ class Live {
|
|||||||
end(err) {
|
end(err) {
|
||||||
if (this.done) return
|
if (this.done) return
|
||||||
this.done = true
|
this.done = true
|
||||||
|
this.stopClock()
|
||||||
if (open?.live === this) open.live = null
|
if (open?.live === this) open.live = null
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -417,10 +521,32 @@ function follower(chat, messageId) {
|
|||||||
|
|
||||||
// --- Cards ----------------------------------------------------------------------------------------
|
// --- Cards ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cards waiting for an answer, as the stream's `ask` frame lists them. A card already on the
|
||||||
|
* screen is kept as it is -- the server sends the frame again whenever anything in it changes, its
|
||||||
|
* countdown included, and drawing it again would take away a command being corrected or an option
|
||||||
|
* already picked. New ones are added in the server's order; the ones no longer listed go.
|
||||||
|
*/
|
||||||
function renderCards(chat, cards) {
|
function renderCards(chat, cards) {
|
||||||
const box = clear($("cards"))
|
const box = $("cards")
|
||||||
box.hidden = !cards.length
|
const ids = cards.map((c) => String(c.id))
|
||||||
for (const card of cards) box.append(cardOf(chat, card))
|
for (const el of [...box.children]) if (el.dataset.chat !== chat.id || !ids.includes(el.dataset.card)) el.remove()
|
||||||
|
cards.forEach((card, i) => {
|
||||||
|
let el = [...box.children].find((x) => x.dataset.card === String(card.id))
|
||||||
|
if (!el) {
|
||||||
|
try {
|
||||||
|
el = cardOf(chat, card)
|
||||||
|
} catch (err) {
|
||||||
|
// Never an empty box under a status that says the reply waits: say what went wrong.
|
||||||
|
console.error("card", card, err)
|
||||||
|
el = h("section", { class: "ask" }, h("p", { class: "msg__note is-error", text: String(err) }))
|
||||||
|
}
|
||||||
|
el.dataset.card = String(card.id)
|
||||||
|
el.dataset.chat = chat.id
|
||||||
|
}
|
||||||
|
if (box.children[i] !== el) box.insertBefore(el, box.children[i] || null)
|
||||||
|
})
|
||||||
|
box.hidden = !box.children.length
|
||||||
}
|
}
|
||||||
|
|
||||||
function cardOf(chat, card) {
|
function cardOf(chat, card) {
|
||||||
@@ -430,24 +556,44 @@ function cardOf(chat, card) {
|
|||||||
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
|
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
|
||||||
for (const item of card.items) {
|
for (const item of card.items) {
|
||||||
const part = h("div", { class: "ask__item" })
|
const part = h("div", { class: "ask__item" })
|
||||||
|
// A background subagent asks, not the conversation's own turn: said first (`job`).
|
||||||
|
if (item.job) part.append(h("p", { class: "ask__header", text: t("%(job)s asks:", { job: item.job }) }))
|
||||||
|
// A subagent of the tree asks, for a call nobody in the tree may allow (spec 3.4.0, §7).
|
||||||
|
if (item.subagent) {
|
||||||
|
const sub = item.subagent
|
||||||
|
part.append(h("p", { class: "ask__header ask__subagent", text: t("Subagent %(name)s (%(id)s), %(position)s, asks:", { name: sub.name || sub.id, id: sub.id, position: sub.position || "—" }) }))
|
||||||
|
}
|
||||||
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
|
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
|
||||||
|
let detailShown = false
|
||||||
if (item.kind === "approval") {
|
if (item.kind === "approval") {
|
||||||
part.append(h("p", { class: "ask__title", text: item.tool ? t("The agent asks to run %(tool)s:", { tool: item.tool }) : t("The agent asks:") }))
|
part.append(h("p", { class: "ask__title", text: item.tool ? t("The agent asks to run %(tool)s:", { tool: item.tool }) : t("The agent asks:") }))
|
||||||
if (item.editable) {
|
if (item.editable) {
|
||||||
const edit = h("textarea", { class: "ask__edit", rows: "2", "aria-label": t("Command"), value: item.title })
|
// A command, correctable before it runs. The server says it in `detail` and puts a fixed
|
||||||
|
// "Run a command" in the title (services/device_link.py); a corrected command goes back as
|
||||||
|
// this item's text, and only when it differs -- an untouched one is not an edit.
|
||||||
|
const command = item.detail || item.title
|
||||||
|
detailShown = true
|
||||||
|
const edit = h("textarea", { class: "ask__edit", rows: "2", spellcheck: "false", "aria-label": t("Command"), value: command })
|
||||||
edit.addEventListener("input", () => {
|
edit.addEventListener("input", () => {
|
||||||
if (edit.value !== item.title) answers.texts[item.key] = edit.value
|
if (edit.value !== command) answers.texts[item.key] = edit.value
|
||||||
else delete answers.texts[item.key]
|
else delete answers.texts[item.key]
|
||||||
})
|
})
|
||||||
part.append(edit)
|
part.append(edit)
|
||||||
} else part.append(h("pre", { class: "call__pre", text: item.title }))
|
} else {
|
||||||
|
// What it wants to do (a tool's own title), then its input as the device sent it.
|
||||||
|
part.append(h("p", { class: "ask__what", text: item.title }))
|
||||||
|
if (item.detail) part.append(h("pre", { class: "call__pre", text: item.detail }))
|
||||||
|
detailShown = true
|
||||||
|
}
|
||||||
} else if (item.kind === "plan" && item.plan) {
|
} else if (item.kind === "plan" && item.plan) {
|
||||||
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
|
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
|
||||||
} else {
|
} else {
|
||||||
part.append(h("p", { class: "ask__title", text: item.title }))
|
part.append(h("p", { class: "ask__title", text: item.title }))
|
||||||
}
|
}
|
||||||
if (item.detail) part.append(h("p", { class: "muted", text: item.detail }))
|
if (item.detail && !detailShown) part.append(h("p", { class: "muted", text: item.detail }))
|
||||||
if (item.purpose) part.append(h("p", { class: "muted", text: item.purpose }))
|
// The model's account of what it is for, attributed to it; then the server's reason for asking.
|
||||||
|
if (item.purpose) part.append(h("p", { class: "muted", text: t("It says: %(purpose)s", { purpose: item.purpose }) }))
|
||||||
|
if (item.reason) part.append(h("p", { class: "muted ask__reason", text: item.reason }))
|
||||||
if (item.options?.length) {
|
if (item.options?.length) {
|
||||||
const picked = new Set()
|
const picked = new Set()
|
||||||
const opts = h("div", { class: "ask__options", role: item.multiple ? "group" : "radiogroup" })
|
const opts = h("div", { class: "ask__options", role: item.multiple ? "group" : "radiogroup" })
|
||||||
@@ -506,12 +652,11 @@ function cardOf(chat, card) {
|
|||||||
const actions = h("div", { class: "ask__actions" })
|
const actions = h("div", { class: "ask__actions" })
|
||||||
if (decides) {
|
if (decides) {
|
||||||
const isPlan = card.items.some((i) => i.kind === "plan")
|
const isPlan = card.items.some((i) => i.kind === "plan")
|
||||||
append(
|
append(actions, [
|
||||||
actions,
|
|
||||||
h("button", { type: "button", class: "btn btn--primary", text: isPlan ? t("Carry it out") : t("Allow"), onclick: () => send("allow") }),
|
h("button", { type: "button", class: "btn btn--primary", text: isPlan ? t("Carry it out") : t("Allow"), onclick: () => send("allow") }),
|
||||||
isPlan ? null : h("button", { type: "button", class: "btn", text: t("Always allow"), onclick: () => send("allow_always") }),
|
isPlan ? null : h("button", { type: "button", class: "btn", text: t("Always allow"), onclick: () => send("allow_always") }),
|
||||||
h("button", { type: "button", class: "btn", text: isPlan ? t("Not yet") : t("Deny"), onclick: () => send("deny") }),
|
h("button", { type: "button", class: "btn", text: isPlan ? t("Not yet") : t("Deny"), onclick: () => send("deny") }),
|
||||||
)
|
])
|
||||||
} else {
|
} else {
|
||||||
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
|
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
|
||||||
}
|
}
|
||||||
@@ -542,16 +687,11 @@ function renderHead(chat) {
|
|||||||
h("span", { class: "place__where", text: here ? t("This computer") : t("Another device") }),
|
h("span", { class: "place__where", text: here ? t("This computer") : t("Another device") }),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (chat.device_session_id) {
|
// The Terminal panel where the chat's device opens one (`GET /devices` says so per device);
|
||||||
tools.append(
|
// "Continue in a terminal" stays in the chat's menu.
|
||||||
h(
|
if (deviceOf(chat.device.token_id)?.terminal) tools.append(terminalButton())
|
||||||
"button",
|
|
||||||
{ type: "button", class: "tool", "aria-label": t("Continue in a terminal"), title: t("Continue in a terminal"), onclick: () => continueInTerminal(open?.chat || chat) },
|
|
||||||
icon(ICONS.terminal),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
tools.append(activityButton())
|
||||||
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
|
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,13 +704,23 @@ async function chatModels(chat) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether this chat may have a team (`GET /chats/{chat}/team`): only a chat on the server. */
|
/**
|
||||||
|
* Whether this chat is offered subagents: `{ cap, own }` (the instance's most; whether only a model
|
||||||
|
* the person names could serve them), or null. The chat's own `subagents` where the server gives
|
||||||
|
* it (2.0.0-beta.7), else its team's word (`GET /chats/{chat}/team`) -- only a chat on the server.
|
||||||
|
*/
|
||||||
async function teamOffered(chat) {
|
async function teamOffered(chat) {
|
||||||
if (chat.kind !== "chat") return false
|
const said = chat.subagents
|
||||||
|
if (said !== undefined && said !== null) {
|
||||||
|
const offered = typeof said === "object" ? said.offered : said
|
||||||
|
return offered ? { cap: subagentsCap(said), own: namedOnly(said) } : null
|
||||||
|
}
|
||||||
|
if (chat.kind !== "chat") return null
|
||||||
try {
|
try {
|
||||||
return !!(await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team?.offered
|
const team = (await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team
|
||||||
|
return team?.offered ? { cap: subagentsCap(), own: false } : null
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,26 +790,31 @@ function effortChip(model, value, onChange) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The team's size: a chip saying it, opening the two fields. `value` { min, max } or empty. */
|
/**
|
||||||
function agentsChip(value, onChange) {
|
* The subagents a chat may run: a chip saying Min–Max, opening one range with two handles.
|
||||||
|
* `value` { min, max } or empty; `cap` the instance's most, where the server says it.
|
||||||
|
*/
|
||||||
|
function agentsChip(value, onChange, { cap, own } = {}) {
|
||||||
const said = value?.min || value?.max ? `${value.min ?? "…"}–${value.max ?? "…"}` : t("default")
|
const said = value?.min || value?.max ? `${value.min ?? "…"}–${value.max ?? "…"}` : t("default")
|
||||||
return chip({
|
return chip({
|
||||||
ico: ICONS.users,
|
ico: ICONS.users,
|
||||||
label: `${t("Agents")} ${said}`,
|
label: `${t("Subagents")} ${said}`,
|
||||||
title: t("Its own agent included. Above one, it can add members that do the work while it leads them."),
|
title: own ? SUBAGENTS_OWN() : SUBAGENTS_HINT(),
|
||||||
|
cls: `cchip--subagents${own ? " is-warn" : ""}`,
|
||||||
onclick: (e) => {
|
onclick: (e) => {
|
||||||
const fields = agentsControl({ value, id: "agents-min" })
|
const fields = agentsControl({ value, id: "agents-min", cap })
|
||||||
popover(
|
popover(
|
||||||
e.currentTarget,
|
e.currentTarget,
|
||||||
h(
|
h(
|
||||||
"div",
|
"div",
|
||||||
{ class: "pop__body" },
|
{ class: "pop__body" },
|
||||||
h("label", { class: "pop__title", for: "agents-min", text: t("Agents") }),
|
h("p", { class: "pop__title", text: t("Subagents") }),
|
||||||
fields,
|
fields,
|
||||||
h("p", { class: "hint", text: t("Its own agent included. Above one, it can add members that do the work while it leads them.") }),
|
own ? h("p", { class: "hint is-warn", text: SUBAGENTS_OWN() }) : null,
|
||||||
|
h("p", { class: "hint", text: SUBAGENTS_HINT() }),
|
||||||
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: () => (closePopover(), onChange(fields.value)) })),
|
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: () => (closePopover(), onChange(fields.value)) })),
|
||||||
),
|
),
|
||||||
{ up: true, label: t("Agents") },
|
{ up: true, label: t("Subagents") },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -699,17 +854,38 @@ async function renderChips() {
|
|||||||
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||||
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
|
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
|
||||||
}
|
}
|
||||||
|
const shell = terminalTarget()
|
||||||
|
aimTerminal(shell)
|
||||||
parts.push(modelChip(models, draft.model, (id) => {
|
parts.push(modelChip(models, draft.model, (id) => {
|
||||||
draft.model = id
|
draft.model = id
|
||||||
draft.effort = levelsOf(models.find((m) => m.id === id)).start
|
draft.effort = levelsOf(models.find((m) => m.id === id)).start
|
||||||
renderChips()
|
renderChips()
|
||||||
}, mayPick))
|
}, mayPick))
|
||||||
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
|
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
|
||||||
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips())))
|
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
|
||||||
|
// An agent chat's subagents run on its device: offered as the server says for that device.
|
||||||
|
const device = draft.kind === "agent" ? draftDevice() : ""
|
||||||
|
const known = device ? agentFacts.get(device) : null
|
||||||
|
const offer = device ? agentOffer(known, model) : null
|
||||||
|
if (offer) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), offer))
|
||||||
|
if (device && (!known || Date.now() - known.at > 5000)) {
|
||||||
|
readAgentFacts(device).then((f) => {
|
||||||
|
if (f && drawing === chipsDrawn && draft?.kind === "agent") renderChips()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Last, so the chat's own settings come first where the line scrolls.
|
||||||
|
if (shell) {
|
||||||
|
const b = chip({ ico: ICONS.terminal, label: t("Terminal"), menuish: false, cls: "cchip--terminal", onclick: toggleTerminal })
|
||||||
|
b.dataset.terminalToggle = ""
|
||||||
|
b.setAttribute("aria-pressed", String(terminalOpen()))
|
||||||
|
b.setAttribute("aria-controls", "terminal")
|
||||||
|
parts.push(b)
|
||||||
|
}
|
||||||
box.replaceChildren(...parts.filter(Boolean))
|
box.replaceChildren(...parts.filter(Boolean))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const chat = open?.chat
|
const chat = open?.chat
|
||||||
|
aimTerminal(terminalTarget())
|
||||||
if (!chat?.kind) return box.replaceChildren()
|
if (!chat?.kind) return box.replaceChildren()
|
||||||
const save = async (body) => {
|
const save = async (body) => {
|
||||||
try {
|
try {
|
||||||
@@ -732,8 +908,9 @@ async function renderChips() {
|
|||||||
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
|
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
|
||||||
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
|
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
|
||||||
box.replaceChildren(...parts.filter(Boolean))
|
box.replaceChildren(...parts.filter(Boolean))
|
||||||
if (!(await teamOffered(chat)) || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
const offer = await teamOffered(chat)
|
||||||
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a })))
|
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
||||||
|
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a }), offer))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -756,6 +933,10 @@ export function statusChanged() {
|
|||||||
if (draft) {
|
if (draft) {
|
||||||
renderChips()
|
renderChips()
|
||||||
renderHello()
|
renderHello()
|
||||||
|
} else if (open?.chat?.kind) {
|
||||||
|
// The devices were read again: whether this chat's opens a terminal may have changed.
|
||||||
|
renderHead(open.chat)
|
||||||
|
aimTerminal(terminalTarget())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,6 +1124,18 @@ export function draftKind() {
|
|||||||
return draft?.kind || lastKind
|
return draft?.kind || lastKind
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `/models` was read again: a new chat being written is drawn with what it says now. */
|
||||||
|
export function modelsChanged() {
|
||||||
|
if (!draft) return
|
||||||
|
const models = ctx.models()
|
||||||
|
if (!models.some((m) => m.id === draft.model)) {
|
||||||
|
const start = models.find((m) => m.default) || models[0]
|
||||||
|
draft.model = start?.id || ""
|
||||||
|
draft.effort = levelsOf(start).start
|
||||||
|
}
|
||||||
|
renderChips()
|
||||||
|
}
|
||||||
|
|
||||||
/** The greeting of a new chat, and the choice of what it is. */
|
/** The greeting of a new chat, and the choice of what it is. */
|
||||||
function renderHello() {
|
function renderHello() {
|
||||||
const box = $("new-chat")
|
const box = $("new-chat")
|
||||||
@@ -1027,7 +1220,7 @@ async function createChat(text, attachments) {
|
|||||||
const agents = draft.agents
|
const agents = draft.agents
|
||||||
if (agents && (agents.min || agents.max)) body.agents = agents
|
if (agents && (agents.min || agents.max)) body.agents = agents
|
||||||
if (draft.kind === "agent") {
|
if (draft.kind === "agent") {
|
||||||
const device = ctx.thisDevices()[0]
|
const device = draftDevice()
|
||||||
if (!device || !agentReady(ctx.status())) {
|
if (!device || !agentReady(ctx.status())) {
|
||||||
renderNotice()
|
renderNotice()
|
||||||
throw { detail: agentState(ctx.status()).sentence }
|
throw { detail: agentState(ctx.status()).sentence }
|
||||||
|
|||||||
+4
-1
@@ -27,7 +27,8 @@ export function h(tag, attrs, ...children) {
|
|||||||
return el
|
return el
|
||||||
}
|
}
|
||||||
|
|
||||||
export function append(el, children) {
|
/** append(el, child, [more], …): children one by one or in arrays, nested as deep as they come. */
|
||||||
|
export function append(el, ...children) {
|
||||||
for (const c of children.flat(Infinity)) {
|
for (const c of children.flat(Infinity)) {
|
||||||
if (c === null || c === undefined || c === false) continue
|
if (c === null || c === undefined || c === false) continue
|
||||||
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
|
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
|
||||||
@@ -75,12 +76,14 @@ export const ICONS = {
|
|||||||
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
|
||||||
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
|
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
|
||||||
chevronDown: "M3.5 6L8 10.5 12.5 6 11.4 4.9 8 8.3 4.6 4.9z",
|
chevronDown: "M3.5 6L8 10.5 12.5 6 11.4 4.9 8 8.3 4.6 4.9z",
|
||||||
|
chevronUp: "M3.5 10L8 5.5 12.5 10 11.4 11.1 8 7.7 4.6 11.1z",
|
||||||
more: "M2.5 6.75h2.5v2.5H2.5zM6.75 6.75h2.5v2.5h-2.5zM11 6.75h2.5v2.5H11z",
|
more: "M2.5 6.75h2.5v2.5H2.5zM6.75 6.75h2.5v2.5h-2.5zM11 6.75h2.5v2.5H11z",
|
||||||
edit: "M10.75 1.75l3.5 3.5-8.5 8.5h-3.5v-3.5zM3.75 10.87v1.38h1.38l6.62-6.62-1.38-1.38z",
|
edit: "M10.75 1.75l3.5 3.5-8.5 8.5h-3.5v-3.5zM3.75 10.87v1.38h1.38l6.62-6.62-1.38-1.38z",
|
||||||
copy: "M5 1.5h8.5V11H5zM6.5 3v6.5H12V3zM2.5 5H4v8.5h6.5V15h-8z",
|
copy: "M5 1.5h8.5V11H5zM6.5 3v6.5H12V3zM2.5 5H4v8.5h6.5V15h-8z",
|
||||||
attach: "M7.25 2.5h1.5v4.75h4.75v1.5H8.75v4.75h-1.5V8.75H2.5v-1.5h4.75z",
|
attach: "M7.25 2.5h1.5v4.75h4.75v1.5H8.75v4.75h-1.5V8.75H2.5v-1.5h4.75z",
|
||||||
bolt: "M9.5 1L3 9.25h4.25L6.5 15 13 6.75H8.75z",
|
bolt: "M9.5 1L3 9.25h4.25L6.5 15 13 6.75H8.75z",
|
||||||
stop: "M4.5 4.5h7v7h-7z",
|
stop: "M4.5 4.5h7v7h-7z",
|
||||||
|
tasks: "M2 3.25h2v2H2zM5.5 3.5H14V5H5.5zM2 7h2v2H2zM5.5 7.25H14v1.5H5.5zM2 10.75h2v2H2zM5.5 11H14v1.5H5.5z",
|
||||||
back: "M10 3.5L5.5 8 10 12.5 11.1 11.4 7.7 8 11.1 4.6z",
|
back: "M10 3.5L5.5 8 10 12.5 11.1 11.4 7.7 8 11.1 4.6z",
|
||||||
terminal: "M1.5 2.5h13v11h-13zM3 4v8h10V4zM4.2 5.4l2.6 2.6-2.6 2.6-1-1L4.8 8 3.2 6.4zM7.5 10h4v1.5h-4z",
|
terminal: "M1.5 2.5h13v11h-13zM3 4v8h10V4zM4.2 5.4l2.6 2.6-2.6 2.6-1-1L4.8 8 3.2 6.4zM7.5 10h4v1.5h-4z",
|
||||||
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
|
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
|
||||||
|
|||||||
+36
-6
@@ -12,13 +12,15 @@ import { renderSettings, initSettings, scrollToSection } from "./settings.js"
|
|||||||
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
import { initViewer, onViewer, closeViewer } from "./viewer.js"
|
||||||
import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
|
import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
|
||||||
import { initList, refreshList, markCurrent } from "./list.js"
|
import { initList, refreshList, markCurrent } from "./list.js"
|
||||||
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind } from "./chat.js"
|
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer, modelsChanged, intoComposer } from "./chat.js"
|
||||||
|
import { initTerminal, showTerminal, terminalOpen, terminalAvailable, toggleTerminal, terminalThemeChanged } from "./terminal.js"
|
||||||
import { initAttach, allowAttach } from "./attach.js"
|
import { initAttach, allowAttach } from "./attach.js"
|
||||||
import { initPalette, openPalette } from "./palette.js"
|
import { initPalette, openPalette } from "./palette.js"
|
||||||
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
|
||||||
import { initAgent, link } from "./agent.js"
|
import { initAgent, link } from "./agent.js"
|
||||||
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
|
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
|
||||||
import { closePopover } from "./popup.js"
|
import { closePopover } from "./popup.js"
|
||||||
|
import { noteSubagentsCap } from "./models.js"
|
||||||
|
|
||||||
let status = null
|
let status = null
|
||||||
let view = "" // "setup" | "app"
|
let view = "" // "setup" | "app"
|
||||||
@@ -66,6 +68,7 @@ function applyTheme(name) {
|
|||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
root.dataset.theme = name
|
root.dataset.theme = name
|
||||||
root.dataset.base = name
|
root.dataset.base = name
|
||||||
|
terminalThemeChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Views ------------------------------------------------------------------------------------
|
// --- Views ------------------------------------------------------------------------------------
|
||||||
@@ -116,11 +119,17 @@ function stopApp() {
|
|||||||
closeViewer()
|
closeViewer()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModels() {
|
let modelsAt = 0 // when /models was last read
|
||||||
|
|
||||||
|
/** The account's models, with what a new chat on each is offered. `keep`: a failed read keeps the last list. */
|
||||||
|
async function loadModels({ keep = false } = {}) {
|
||||||
try {
|
try {
|
||||||
models = (await get("/models")).models || []
|
const r = await get("/models")
|
||||||
|
models = r.models || []
|
||||||
|
noteSubagentsCap(r.subagents_cap)
|
||||||
|
modelsAt = Date.now()
|
||||||
} catch {
|
} catch {
|
||||||
models = []
|
if (!keep) models = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +164,10 @@ function showPage(name) {
|
|||||||
$("page-scheduled").hidden = name !== "scheduled"
|
$("page-scheduled").hidden = name !== "scheduled"
|
||||||
$("settings").hidden = name !== "settings"
|
$("settings").hidden = name !== "settings"
|
||||||
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
|
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
|
||||||
if (name !== "chat") showPanel(false)
|
if (name !== "chat") {
|
||||||
|
showPanel(false)
|
||||||
|
showTerminal(false)
|
||||||
|
}
|
||||||
if (narrow()) sideOpen(false)
|
if (narrow()) sideOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +186,10 @@ function startNew(kind) {
|
|||||||
newChat(kind || draftKind())
|
newChat(kind || draftKind())
|
||||||
markCurrent()
|
markCurrent()
|
||||||
localPane()
|
localPane()
|
||||||
|
// What a new chat is offered (its models, Subagents) is the server's word now, not at sign-in:
|
||||||
|
// an administrator may have switched subagents on or added a model while the app stayed open in
|
||||||
|
// the tray. Drawn at once from the last list, then again from the fresh one.
|
||||||
|
if (started && Date.now() - modelsAt > 5000) loadModels({ keep: true }).then(modelsChanged)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a chat, from the list or the palette. */
|
/** Open a chat, from the list or the palette. */
|
||||||
@@ -210,6 +226,7 @@ function commands() {
|
|||||||
{ label: t("Settings"), run: () => showSettings(true) },
|
{ label: t("Settings"), run: () => showSettings(true) },
|
||||||
]
|
]
|
||||||
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
|
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
|
||||||
|
if (terminalAvailable()) out.push({ label: terminalOpen() ? t("Close terminal") : t("Terminal"), run: toggleTerminal })
|
||||||
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
|
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
|
||||||
out.push(
|
out.push(
|
||||||
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: link },
|
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: link },
|
||||||
@@ -254,6 +271,7 @@ async function boot() {
|
|||||||
initChat({
|
initChat({
|
||||||
status: () => status,
|
status: () => status,
|
||||||
thisDevices,
|
thisDevices,
|
||||||
|
devices: () => devices,
|
||||||
models: () => models,
|
models: () => models,
|
||||||
me: () => me,
|
me: () => me,
|
||||||
changed: () => refreshList(),
|
changed: () => refreshList(),
|
||||||
@@ -278,10 +296,22 @@ async function boot() {
|
|||||||
onViewer((on) => {
|
onViewer((on) => {
|
||||||
$("shell").dataset.viewer = String(on)
|
$("shell").dataset.viewer = String(on)
|
||||||
})
|
})
|
||||||
|
// One panel column: the activity panel and the terminal take turns in it (the shell stays open).
|
||||||
|
initTerminal({
|
||||||
|
onLayout: (on) => {
|
||||||
|
$("shell").dataset.terminal = String(on)
|
||||||
|
if (on && panelOpen()) showPanel(false)
|
||||||
|
},
|
||||||
|
composer: intoComposer,
|
||||||
|
})
|
||||||
initActivity({
|
initActivity({
|
||||||
onLayout: (on) => {
|
onLayout: (on) => {
|
||||||
$("shell").dataset.activity = String(on)
|
$("shell").dataset.activity = String(on)
|
||||||
|
if (on && terminalOpen()) showTerminal(false)
|
||||||
},
|
},
|
||||||
|
here: onThisComputer,
|
||||||
|
// The conversation as it now stands: the summary's divider, read again, if it is still open.
|
||||||
|
compacted: (id) => openChatId() === id && openChat(id),
|
||||||
})
|
})
|
||||||
|
|
||||||
$("new-chat-btn").prepend(icon(ICONS.plus))
|
$("new-chat-btn").prepend(icon(ICONS.plus))
|
||||||
@@ -339,7 +369,7 @@ async function boot() {
|
|||||||
renderBar(status)
|
renderBar(status)
|
||||||
route()
|
route()
|
||||||
// The agent came up or went: which devices are linked changes with it.
|
// The agent came up or went: which devices are linked changes with it.
|
||||||
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(refreshList)
|
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(() => (refreshList(), statusChanged()))
|
||||||
})
|
})
|
||||||
listen("open-settings", () => {
|
listen("open-settings", () => {
|
||||||
if (view === "app") showSettings(true)
|
if (view === "app") showSettings(true)
|
||||||
|
|||||||
+93
-42
@@ -123,7 +123,7 @@ export function modelPicker({ id, models, value, onChange, label }) {
|
|||||||
return btn
|
return btn
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- How hard a model thinks, and how many agents a chat may have --------------------------------
|
// --- How hard a model thinks, and how many subagents a chat may have --------------------------------
|
||||||
//
|
//
|
||||||
// The levels are the harness spec's (3.2.0, `models/schema.json`), as /api/v2/models gives them:
|
// The levels are the harness spec's (3.2.0, `models/schema.json`), as /api/v2/models gives them:
|
||||||
// `levels` in offering order (`off` first where the model can switch thinking off, then its
|
// `levels` in offering order (`off` first where the model can switch thinking off, then its
|
||||||
@@ -193,69 +193,120 @@ export function effortControl({ model, value, onChange, id, compact }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a new chat on `model` may have a team: a chat on the server (an agent chat's team runs
|
* Whether a new chat on `model` is offered subagents: the model's `subagents.offered` in `/models`
|
||||||
* on its device), a model that calls tools and serves two requests at once (`parallel` null is a
|
* (server 2.0.0-beta.7: subagents on for the model and the instance, and at least one model could
|
||||||
* hosted API with no stated limit). The server decides again; this only keeps the fields away
|
* serve as one -- the model itself with room for two requests at once, or a usable model outside
|
||||||
* where they could never apply. An open chat asks the server (`GET /chats/{chat}/team`).
|
* its one-model-at-a-time server). A server that does not say it: a model that calls tools and
|
||||||
|
* serves two requests at once (`parallel` null is a hosted API with no stated limit). The server
|
||||||
|
* decides again; this only keeps the chip away where it could never apply. An open chat has its
|
||||||
|
* own `subagents` (chat.js).
|
||||||
*/
|
*/
|
||||||
export function teamPossible(model) {
|
export function teamPossible(model) {
|
||||||
|
const said = model?.subagents
|
||||||
|
if (said !== undefined && said !== null) return typeof said === "object" ? !!said.offered : !!said
|
||||||
return !!model?.tools && (model.parallel === null || model.parallel === undefined || model.parallel >= 2)
|
return !!model?.tools && (model.parallel === null || model.parallel === undefined || model.parallel >= 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether only a model the person names in the chat could serve its subagents (`named_only`). */
|
||||||
|
export function namedOnly(said) {
|
||||||
|
return !!(said && typeof said === "object" && said.named_only)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The instance's most, as `/models` said it last (`subagents_cap`); 32 where it says nothing.
|
||||||
|
export const SUBAGENTS_CAP = 32
|
||||||
|
let knownCap = null
|
||||||
|
export function noteSubagentsCap(n) {
|
||||||
|
knownCap = Number(n) >= 1 ? Number(n) : null
|
||||||
|
}
|
||||||
|
/** The most subagents a chat may have: the first of `sources` that says it (`cap`), else the instance's. */
|
||||||
|
export function subagentsCap(...sources) {
|
||||||
|
for (const s of sources) {
|
||||||
|
const n = Number(s?.cap)
|
||||||
|
if (Number.isFinite(n) && n >= 1) return n
|
||||||
|
}
|
||||||
|
return knownCap || SUBAGENTS_CAP
|
||||||
|
}
|
||||||
|
// The numbers count the chat's own agent: 1 is the agent alone.
|
||||||
|
export const SUBAGENTS_HINT = () => t("The numbers count this chat's own agent: 1 is the agent alone, with no subagents; Max 4 is the agent and up to 3 subagents. A subagent runs on the chat's model, or on another only when you name it in the chat.")
|
||||||
|
// The spec's `subagents.setting.own`: beside the setting where the chat's model cannot serve them.
|
||||||
|
export const SUBAGENTS_OWN = () => t("This model serves one request at a time: subagents need another model, on another server, named in the chat.")
|
||||||
|
|
||||||
|
// A new chat's setting as the harness spec seeds it (`loop.json` agents): 1 to 4.
|
||||||
|
export const AGENTS_DEFAULT = { min: 1, max: 4 }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The team's size: the fewest and the most agents, the chat's own agent included, as two number
|
* The agents a chat may run, its own agent counted: one range from 1 to `cap` with two handles,
|
||||||
* fields on one shared track. `value` is { min, max } (empty fields for a new chat: the person's
|
* Min and Max -- two range inputs overlaid on one track, the span between them filled. Each is a
|
||||||
* default for new chats then stands); `onChange({ min, max })` once a field is changed, with the
|
* real slider (arrow keys, Home/End, Page Up/Down), and Min never passes Max. `value` is
|
||||||
* empty one left out. The server checks the pair and says the bounds when it refuses one.
|
* { min, max } (empty for a new chat: the spec's defaults are shown, and left to the server
|
||||||
|
* unless a handle moves); `el.value` the pair. The server checks it again.
|
||||||
*/
|
*/
|
||||||
export function agentsControl({ value, onChange, id, compact }) {
|
export function agentsControl({ value, onChange, id, cap = SUBAGENTS_CAP }) {
|
||||||
const field = (which, v, label) =>
|
const top = Math.max(1, Number(cap) || SUBAGENTS_CAP)
|
||||||
|
const clamp = (n, d) => Math.min(top, Math.max(1, Number.isFinite(Number(n)) && n !== null && n !== "" ? Number(n) : d))
|
||||||
|
let given = value || {}
|
||||||
|
let touched = false
|
||||||
|
const handle = (which, label) =>
|
||||||
h("input", {
|
h("input", {
|
||||||
type: "number",
|
type: "range",
|
||||||
class: "agents__input",
|
class: `range2__input range2__input--${which}`,
|
||||||
id: which === "min" ? id : null,
|
id: which === "min" ? id : null,
|
||||||
min: "1",
|
min: "1",
|
||||||
|
max: String(top),
|
||||||
step: "1",
|
step: "1",
|
||||||
inputmode: "numeric",
|
|
||||||
value: v === undefined || v === null ? "" : String(v),
|
|
||||||
placeholder: compact ? null : t("default"),
|
|
||||||
"aria-label": label,
|
"aria-label": label,
|
||||||
title: label,
|
title: label,
|
||||||
})
|
})
|
||||||
const low = field("min", value?.min, t("Fewest"))
|
const low = handle("min", t("Min"))
|
||||||
const high = field("max", value?.max, t("Most"))
|
const high = handle("max", t("Max"))
|
||||||
const bounds = () => {
|
const said = h("output", { class: "range2__said", "aria-live": "polite", for: id || null })
|
||||||
high.min = low.value || "1"
|
const fill = h("span", { class: "range2__fill", "aria-hidden": "true" })
|
||||||
if (high.value) low.max = high.value
|
const box = h("span", { class: "range2__box" }, h("span", { class: "range2__track", "aria-hidden": "true" }, fill), low, high)
|
||||||
else low.removeAttribute("max")
|
const draw = () => {
|
||||||
|
const lo = Number(low.value)
|
||||||
|
const hi = Number(high.value)
|
||||||
|
const at = (n) => (top > 1 ? ((n - 1) / (top - 1)) * 100 : 0)
|
||||||
|
box.style.setProperty("--lo", `${at(lo)}%`)
|
||||||
|
box.style.setProperty("--hi", `${at(hi)}%`)
|
||||||
|
// Where the two meet, the one that can still move is on top: Min at the far end, else Max.
|
||||||
|
low.classList.toggle("is-top", lo === hi && hi === top)
|
||||||
|
low.setAttribute("aria-valuetext", `${t("Min")} ${lo}`)
|
||||||
|
high.setAttribute("aria-valuetext", `${t("Max")} ${hi}`)
|
||||||
|
said.textContent = `${t("Min")} ${lo} · ${t("Max")} ${hi}`
|
||||||
}
|
}
|
||||||
bounds()
|
const set = (v) => {
|
||||||
const read = () => {
|
const hi = clamp(v?.max, Math.min(AGENTS_DEFAULT.max, top))
|
||||||
const out = {}
|
const lo = Math.min(hi, clamp(v?.min, AGENTS_DEFAULT.min))
|
||||||
if (low.value) out.min = Number(low.value)
|
high.value = String(hi)
|
||||||
if (high.value) out.max = Number(high.value)
|
low.value = String(lo)
|
||||||
return out
|
draw()
|
||||||
}
|
|
||||||
for (const input of [low, high]) {
|
|
||||||
input.addEventListener("change", () => {
|
|
||||||
bounds()
|
|
||||||
onChange?.(read())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
low.addEventListener("input", () => {
|
||||||
|
if (Number(low.value) > Number(high.value)) low.value = high.value
|
||||||
|
touched = true
|
||||||
|
draw()
|
||||||
|
})
|
||||||
|
high.addEventListener("input", () => {
|
||||||
|
if (Number(high.value) < Number(low.value)) high.value = low.value
|
||||||
|
touched = true
|
||||||
|
draw()
|
||||||
|
})
|
||||||
|
for (const input of [low, high]) input.addEventListener("change", () => onChange?.(read()))
|
||||||
|
const read = () => (touched || given.min || given.max ? { min: Number(low.value), max: Number(high.value) } : {})
|
||||||
|
set(given)
|
||||||
const el = h(
|
const el = h(
|
||||||
"span",
|
"span",
|
||||||
{ class: `agents${compact ? " agents--compact" : ""}`, role: "group", "aria-label": t("Agents"), title: t("Its own agent included. Above one, it can add members that do the work while it leads them.") },
|
{ class: "range2", role: "group", "aria-label": t("Subagents") },
|
||||||
compact ? h("span", { class: "agents__label", text: t("Agents") }) : null,
|
said,
|
||||||
low,
|
box,
|
||||||
h("span", { class: "agents__dash", "aria-hidden": "true", text: "–" }),
|
h("span", { class: "range2__ends", "aria-hidden": "true" }, h("span", { text: "1" }), h("span", { text: String(top) })),
|
||||||
high,
|
|
||||||
)
|
)
|
||||||
Object.defineProperty(el, "value", {
|
Object.defineProperty(el, "value", {
|
||||||
get: read,
|
get: read,
|
||||||
set: (v) => {
|
set: (v) => {
|
||||||
low.value = v?.min ?? ""
|
given = v || {}
|
||||||
high.value = v?.max ?? ""
|
touched = false
|
||||||
bounds()
|
set(given)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return el
|
return el
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// What a session is doing, as one line: the harness's `progress` object (spec 3.4.0,
|
||||||
|
// harness/progress.md in the LLeMbas CLI) drawn the way every client draws it --
|
||||||
|
// `{{text}} · {{elapsed}}`, with the shared texts (`loop.json` progress.texts) as this page's
|
||||||
|
// sentences. The server sends the object beside the old `status` text in a chat's stream; where it
|
||||||
|
// sends none (an older server), the status text is drawn as before.
|
||||||
|
//
|
||||||
|
// The texts are the spec's, word for word with their `{{field}}`s: the server's catalogues
|
||||||
|
// translate them under the same keys (scripts/gen-i18n.py takes them from there).
|
||||||
|
|
||||||
|
/** The thirteen phases: a text for each, and a variant where a field may be missing. */
|
||||||
|
const TEXTS = {
|
||||||
|
queued: "Queued",
|
||||||
|
"queued.ahead": "Queued: {{ahead}} ahead",
|
||||||
|
waiting_for_model: "Waiting for {{model}}: {{reason}}",
|
||||||
|
loading_model: "Loading {{model}}",
|
||||||
|
reading_prompt: "Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)",
|
||||||
|
"reading_prompt.bare": "Reading the prompt",
|
||||||
|
thinking: "Thinking",
|
||||||
|
writing: "Writing: {{rate}} tok/s",
|
||||||
|
"writing.bare": "Writing",
|
||||||
|
running_tool: "Running {{tool}}: {{what}}",
|
||||||
|
"running_tool.bare": "Running {{tool}}",
|
||||||
|
waiting_for_person: "Waiting for you: {{what}}",
|
||||||
|
waiting_for_subagents: "Waiting for subagents: {{running}} running",
|
||||||
|
compacting: "Compacting the conversation",
|
||||||
|
retrying: "Retrying in {{in}} s: {{reason}}",
|
||||||
|
stopped: "Stopped",
|
||||||
|
"stopped.reason": "Stopped: {{reason}}",
|
||||||
|
failed: "Failed: {{reason}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The words a field stands for. */
|
||||||
|
const REASONS = {
|
||||||
|
busy: "it serves one request at a time and is answering another",
|
||||||
|
slots: "all its slots are in use",
|
||||||
|
group: "its server holds another model now",
|
||||||
|
}
|
||||||
|
const WHAT = {
|
||||||
|
approval: "allow {{tool}}?",
|
||||||
|
"approval.bare": "an approval",
|
||||||
|
question: "a question",
|
||||||
|
plan: "the plan",
|
||||||
|
}
|
||||||
|
const STOPPED = { person: "by you", steps: "step limit", budget: "budget" }
|
||||||
|
|
||||||
|
/** Every sentence above, for scripts/gen-i18n.py to find (it reads `t("…")` calls by hand). */
|
||||||
|
export const PROGRESS_SENTENCES = [...Object.values(TEXTS), ...Object.values(REASONS), ...Object.values(WHAT), ...Object.values(STOPPED)]
|
||||||
|
|
||||||
|
const NO_ELAPSED = ["stopped", "failed"]
|
||||||
|
|
||||||
|
const has = (p, k) => p[k] !== undefined && p[k] !== null && p[k] !== ""
|
||||||
|
|
||||||
|
/** A sentence in the page's language (the spec's text is the key, as in the server's
|
||||||
|
* catalogues), its `{{field}}`s filled -- after the lookup, so a field's own text is never read
|
||||||
|
* as a template. */
|
||||||
|
function say(text, fields = {}) {
|
||||||
|
return t(text).replace(/\{\{(\w+)\}\}/g, (all, name) => (Object.prototype.hasOwnProperty.call(fields, name) ? String(fields[name]) : all))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An integer as it is; a rate with one decimal, in the page's language (12.5, 12,5). */
|
||||||
|
function rate(n) {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(document.documentElement.lang || "en", { useGrouping: false, minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n)
|
||||||
|
} catch {
|
||||||
|
return Number(n).toFixed(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const int = (n) => String(Math.trunc(Number(n) || 0))
|
||||||
|
|
||||||
|
/** Whole seconds, as a ticking clock writes them: 5s, 1m 05s, 1h 02m. */
|
||||||
|
export function elapsed(ms) {
|
||||||
|
const total = Math.max(0, Math.floor((Number(ms) || 0) / 1000))
|
||||||
|
if (total < 60) return `${total}s`
|
||||||
|
const minutes = Math.floor(total / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ${String(total % 60).padStart(2, "0")}s`
|
||||||
|
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `p` is a progress object this page can draw (one of the thirteen phases). */
|
||||||
|
export function isProgress(p) {
|
||||||
|
return !!p && typeof p === "object" && Object.prototype.hasOwnProperty.call(TEXTS, p.phase)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phase's text, its fields filled; "" for nothing this page knows. */
|
||||||
|
export function progressText(p, now = Date.now()) {
|
||||||
|
if (!isProgress(p)) return ""
|
||||||
|
switch (p.phase) {
|
||||||
|
case "queued":
|
||||||
|
return has(p, "ahead") ? say(TEXTS["queued.ahead"], { ahead: int(p.ahead) }) : say(TEXTS.queued)
|
||||||
|
case "waiting_for_model":
|
||||||
|
return say(TEXTS.waiting_for_model, { model: p.model || "", reason: REASONS[p.reason] ? say(REASONS[p.reason]) : p.reason || "" })
|
||||||
|
case "loading_model":
|
||||||
|
return say(TEXTS.loading_model, { model: p.model || "" })
|
||||||
|
case "reading_prompt":
|
||||||
|
if (!has(p, "done") || !(Number(p.total) > 0)) return say(TEXTS["reading_prompt.bare"])
|
||||||
|
return say(TEXTS.reading_prompt, { done: int(p.done), total: int(p.total), percent: int(Math.floor((Number(p.done) * 100) / Number(p.total))) })
|
||||||
|
case "writing":
|
||||||
|
return has(p, "rate") ? say(TEXTS.writing, { rate: rate(p.rate) }) : say(TEXTS["writing.bare"])
|
||||||
|
case "running_tool":
|
||||||
|
return has(p, "what") ? say(TEXTS.running_tool, { tool: p.tool || "", what: p.what }) : say(TEXTS["running_tool.bare"], { tool: p.tool || "" })
|
||||||
|
case "waiting_for_person": {
|
||||||
|
const what = p.what === "approval" ? (has(p, "tool") ? say(WHAT.approval, { tool: p.tool }) : say(WHAT["approval.bare"])) : WHAT[p.what] ? say(WHAT[p.what]) : p.what || ""
|
||||||
|
return say(TEXTS.waiting_for_person, { what })
|
||||||
|
}
|
||||||
|
case "waiting_for_subagents":
|
||||||
|
return say(TEXTS.waiting_for_subagents, { running: int(p.running) })
|
||||||
|
case "retrying": {
|
||||||
|
const left = Math.max(0, Math.trunc(Number(p.in) || 0) - Math.floor((now - Number(p.since || now)) / 1000))
|
||||||
|
return say(TEXTS.retrying, { in: String(left), reason: p.reason || "" })
|
||||||
|
}
|
||||||
|
case "stopped":
|
||||||
|
return has(p, "reason") ? say(TEXTS["stopped.reason"], { reason: STOPPED[p.reason] ? say(STOPPED[p.reason]) : p.reason }) : say(TEXTS.stopped)
|
||||||
|
case "failed":
|
||||||
|
return say(TEXTS.failed, { reason: p.reason || "" })
|
||||||
|
default:
|
||||||
|
return say(TEXTS[p.phase])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line: `{{text}} · {{elapsed}}`, the text alone for stopped and failed (and where `bare`:
|
||||||
|
* a row with no room for the clock).
|
||||||
|
*/
|
||||||
|
export function progressLine(p, now = Date.now(), { bare = false } = {}) {
|
||||||
|
const text = progressText(p, now)
|
||||||
|
if (!text || bare || NO_ELAPSED.includes(p.phase) || !has(p, "since")) return text
|
||||||
|
// progress.line, `{{text}} · {{elapsed}}`: the same in every language.
|
||||||
|
return `${text} · ${elapsed(now - Number(p.since))}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the line changes by itself as time passes (it has a clock, or a countdown). */
|
||||||
|
export function progressTicks(p) {
|
||||||
|
return isProgress(p) && !NO_ELAPSED.includes(p.phase) && has(p, "since")
|
||||||
|
}
|
||||||
+4
-4
@@ -341,7 +341,7 @@ async function drawTask(id) {
|
|||||||
fact(t("Last run"), statusChip(task.last_run, task.running)),
|
fact(t("Last run"), statusChip(task.last_run, task.running)),
|
||||||
fact(t("Model"), modelWords(models, task.model)),
|
fact(t("Model"), modelWords(models, task.model)),
|
||||||
fact(t("Reasoning effort"), h("span", { text: task.effort ? levelWord(task.effort) : t("The model's own") })),
|
fact(t("Reasoning effort"), h("span", { text: task.effort ? levelWord(task.effort) : t("The model's own") })),
|
||||||
fact(t("Agents"), h("span", { text: task.agents ? t("A team of up to %(n)s", { n: task.agents }) : t("No team") })),
|
fact(t("Subagents"), h("span", { text: task.agents ? t("Up to %(n)s at once", { n: task.agents }) : t("None") })),
|
||||||
fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
|
fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
|
||||||
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })),
|
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })),
|
||||||
),
|
),
|
||||||
@@ -582,15 +582,15 @@ async function drawForm(task) {
|
|||||||
const agents = h(
|
const agents = h(
|
||||||
"select",
|
"select",
|
||||||
{ id: "sched-agents", onchange: (e) => (draft.agents = Number(e.target.value) || null) },
|
{ id: "sched-agents", onchange: (e) => (draft.agents = Number(e.target.value) || null) },
|
||||||
h("option", { value: "", text: t("No team"), selected: !draft.agents }),
|
h("option", { value: "", text: t("None"), selected: !draft.agents }),
|
||||||
Array.from({ length: MAX_AGENTS - 1 }, (_, i) => i + 2).map((n) => h("option", { value: String(n), text: t("A team of up to %(n)s", { n }), selected: draft.agents === n })),
|
Array.from({ length: MAX_AGENTS - 1 }, (_, i) => i + 2).map((n) => h("option", { value: String(n), text: t("Up to %(n)s at once", { n }), selected: draft.agents === n })),
|
||||||
)
|
)
|
||||||
settings.replaceChildren(
|
settings.replaceChildren(
|
||||||
field(t("Tell me"), select("sched-notify", NOTIFY, "notify", () => (settings.querySelector("#sched-notify-hint").textContent = NOTIFY_HINT[draft.notify]())), NOTIFY_HINT[draft.notify]?.() || "", "sched-notify", "sched-notify-hint"),
|
field(t("Tell me"), select("sched-notify", NOTIFY, "notify", () => (settings.querySelector("#sched-notify-hint").textContent = NOTIFY_HINT[draft.notify]())), NOTIFY_HINT[draft.notify]?.() || "", "sched-notify", "sched-notify-hint"),
|
||||||
field(t("Runs"), select("sched-mode", MODE, "mode"), draft.mode === "continue" ? t("Each run sees the ones before it.") : t("Each run starts with nothing but the instruction."), "sched-mode"),
|
field(t("Runs"), select("sched-mode", MODE, "mode"), draft.mode === "continue" ? t("Each run sees the ones before it.") : t("Each run starts with nothing but the instruction."), "sched-mode"),
|
||||||
field(t("Model"), modelSelect, t("A busy model is waited for, then the next free one is used."), "sched-model"),
|
field(t("Model"), modelSelect, t("A busy model is waited for, then the next free one is used."), "sched-model"),
|
||||||
field(t("Reasoning effort"), effort, levels.length ? "" : t("Choose a model to choose its effort."), "sched-effort"),
|
field(t("Reasoning effort"), effort, levels.length ? "" : t("Choose a model to choose its effort."), "sched-effort"),
|
||||||
field(t("Agents"), agents, t("A team works on it together, its own agent included."), "sched-agents"),
|
field(t("Subagents"), agents, t("How many work on it at once, the run's own agent among them, on the same model."), "sched-agents"),
|
||||||
)
|
)
|
||||||
settings.querySelector("#sched-mode").addEventListener("change", drawSettings)
|
settings.querySelector("#sched-mode").addEventListener("change", drawSettings)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
// The terminal panel: a shell on the device an agent chat works on, beside the chat -- the web
|
||||||
|
// page's panel (the server's static/js/terminal.js), in this app. Two places it opens:
|
||||||
|
//
|
||||||
|
// - an open agent chat: the chat's own shell (`{ chat }`), the one the web page shares;
|
||||||
|
// - a new agent chat, as soon as its device and folder are chosen (`{ device, directory }`):
|
||||||
|
// a shell there before the first message, which the chat that message makes takes over.
|
||||||
|
//
|
||||||
|
// The socket is Rust's (bridge.js `openTerminal`): the page never holds the token. xterm is the
|
||||||
|
// server's vendored build (ui/vendor, scripts/fetch-vendor.sh), loaded the first time the panel
|
||||||
|
// opens. Never beside the activity panel: the shell's layout has one panel column (as the web
|
||||||
|
// page has), so opening one closes the other -- the shell stays open behind it.
|
||||||
|
//
|
||||||
|
// Three things about xterm the web panel learnt the hard way, kept here: it cannot fit while its
|
||||||
|
// box is hidden (so every fit waits until the panel is shown), the panel changes size without a
|
||||||
|
// window `resize` (so a ResizeObserver), and it reads no CSS variables (so the theme is read from
|
||||||
|
// the computed tokens, and again when the theme changes).
|
||||||
|
|
||||||
|
import { $, h, icon, ICONS } from "./dom.js"
|
||||||
|
import { openTerminal, why } from "./bridge.js"
|
||||||
|
|
||||||
|
const ANSI = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", "brightMagenta", "brightCyan", "brightWhite"]
|
||||||
|
const MAX_MARKED = 200
|
||||||
|
|
||||||
|
let ctx = { layout: () => {}, composer: () => {} }
|
||||||
|
let target = null // what the panel is for: { key, chat } | { key, device, directory }, with `name`
|
||||||
|
let term = null
|
||||||
|
let fit = null
|
||||||
|
let session = null // the open socket's handle, and which target it is for: { handle, key, draft }
|
||||||
|
let observer = null
|
||||||
|
let loading = null
|
||||||
|
// A device's shell marks its own commands (OSC 133 A/B/C/D, OSC 7 for where it is) and says so
|
||||||
|
// in its ready frame (`osc`): then a command and its output are read from the panel's own
|
||||||
|
// buffer. Without the marks, "send" falls back to the screen as it is, and says so.
|
||||||
|
let osc = false
|
||||||
|
let pending = null
|
||||||
|
let finished = []
|
||||||
|
let visiting = -1
|
||||||
|
|
||||||
|
const panel = () => $("terminal")
|
||||||
|
|
||||||
|
function say(text, isError) {
|
||||||
|
const el = $("terminal-message")
|
||||||
|
el.textContent = text || ""
|
||||||
|
el.classList.toggle("is-error", !!isError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the panel is shown. */
|
||||||
|
export function terminalOpen() {
|
||||||
|
return !panel().hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether there is a shell to open for what the chat page shows now. */
|
||||||
|
export function terminalAvailable() {
|
||||||
|
return !!target
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- xterm, loaded once --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (loading) return loading
|
||||||
|
loading = new Promise((resolve, reject) => {
|
||||||
|
if (typeof window.Terminal === "function" && window.FitAddon) return resolve()
|
||||||
|
document.head.append(h("link", { rel: "stylesheet", href: "vendor/xterm.css" }))
|
||||||
|
const script = (src) =>
|
||||||
|
new Promise((ok, fail) => {
|
||||||
|
const s = h("script", { src })
|
||||||
|
s.addEventListener("load", ok)
|
||||||
|
s.addEventListener("error", () => fail(new Error(src)))
|
||||||
|
document.head.append(s)
|
||||||
|
})
|
||||||
|
script("vendor/xterm.js")
|
||||||
|
.then(() => script("vendor/xterm-addon-fit.js"))
|
||||||
|
.then(resolve, reject)
|
||||||
|
})
|
||||||
|
loading.catch(() => {
|
||||||
|
loading = null
|
||||||
|
})
|
||||||
|
return loading
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTheme() {
|
||||||
|
const style = getComputedStyle(document.documentElement)
|
||||||
|
const token = (name, fallback) => (style.getPropertyValue(name) || "").trim() || fallback
|
||||||
|
const theme = {
|
||||||
|
background: token("--code-bg", "black"),
|
||||||
|
foreground: token("--ink", "white"),
|
||||||
|
cursor: token("--accent", "white"),
|
||||||
|
cursorAccent: token("--code-bg", "black"),
|
||||||
|
selectionBackground: token("--accent-soft", "gray"),
|
||||||
|
}
|
||||||
|
for (const name of ANSI) {
|
||||||
|
const value = token(`--ansi-${name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`, "")
|
||||||
|
if (value) theme[name] = value
|
||||||
|
}
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The theme changed: xterm's colours are read again. */
|
||||||
|
export function terminalThemeChanged() {
|
||||||
|
if (term) term.options.theme = readTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
if (term) return
|
||||||
|
const style = getComputedStyle(document.documentElement)
|
||||||
|
term = new window.Terminal({
|
||||||
|
allowProposedApi: true,
|
||||||
|
cursorBlink: true,
|
||||||
|
fontFamily: style.getPropertyValue("--font-mono").trim() || "monospace",
|
||||||
|
fontSize: parseFloat(style.getPropertyValue("--terminal-font-size")) || 13,
|
||||||
|
scrollback: 5000,
|
||||||
|
theme: readTheme(),
|
||||||
|
})
|
||||||
|
fit = new window.FitAddon.FitAddon()
|
||||||
|
term.loadAddon(fit)
|
||||||
|
term.open($("terminal-screen"))
|
||||||
|
term.parser.registerOscHandler(133, onCommandMark)
|
||||||
|
term.parser.registerOscHandler(7, onWhere)
|
||||||
|
term.onData((data) => session?.handle?.input(data))
|
||||||
|
// Ctrl+C is the shell's interrupt; copy and paste are the shifted pair, as in every terminal.
|
||||||
|
term.attachCustomKeyEventHandler((e) => {
|
||||||
|
if (!e.ctrlKey || !e.shiftKey || e.type !== "keydown") return true
|
||||||
|
if (osc && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
||||||
|
visit(e.key === "ArrowUp" ? -1 : 1)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const key = e.key.toLowerCase()
|
||||||
|
if (key === "c") {
|
||||||
|
const text = term.getSelection()
|
||||||
|
if (text) navigator.clipboard.writeText(text).catch(() => {})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (key === "v") {
|
||||||
|
navigator.clipboard
|
||||||
|
.readText()
|
||||||
|
.then((text) => session?.handle?.input(text))
|
||||||
|
.catch(() => {})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
observer = new ResizeObserver(() => refit())
|
||||||
|
observer.observe($("terminal-screen"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function refit() {
|
||||||
|
if (!term || !fit || !terminalOpen() || !$("terminal-screen").offsetWidth) return
|
||||||
|
try {
|
||||||
|
fit.fit()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session?.handle?.resize(term.cols, term.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The socket ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function decode(b64) {
|
||||||
|
const raw = atob(b64)
|
||||||
|
const bytes = new Uint8Array(raw.length)
|
||||||
|
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (!target || !term || session) return
|
||||||
|
const t0 = target
|
||||||
|
// The session exists before the socket answers, so frames that come first are this one's.
|
||||||
|
const mine = { handle: null, key: t0.key, draft: !t0.chat }
|
||||||
|
session = mine
|
||||||
|
say(t("Connecting…"))
|
||||||
|
let handle
|
||||||
|
try {
|
||||||
|
handle = await openTerminal(t0.chat ? { chat: t0.chat } : { device: t0.device, directory: t0.directory }, term.cols || 80, term.rows || 24, {
|
||||||
|
onOutput: (b64) => {
|
||||||
|
if (session === mine) term.write(decode(b64))
|
||||||
|
},
|
||||||
|
onControl: (frame) => {
|
||||||
|
if (session === mine) control(frame)
|
||||||
|
},
|
||||||
|
onEnd: (end) => {
|
||||||
|
if (session !== mine) return
|
||||||
|
session = null
|
||||||
|
ended(end)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (session === mine) session = null
|
||||||
|
say(why(err), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mine.handle = handle
|
||||||
|
// Let go of meanwhile (another chat, the panel's target changed): closed at once.
|
||||||
|
if (session !== mine) handle.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
function control(frame) {
|
||||||
|
if (frame.t === "ready") {
|
||||||
|
say(frame.shared ? t("Connected. This shell is also open in another tab, and they share a size.") : t("Connected."))
|
||||||
|
if (frame.dir) $("terminal-where").textContent = frame.dir
|
||||||
|
osc = !!frame.osc
|
||||||
|
showMarkControls()
|
||||||
|
showLast("")
|
||||||
|
refit()
|
||||||
|
if (terminalOpen()) term.focus()
|
||||||
|
} else if (frame.t === "behind") {
|
||||||
|
// This window stopped reading and was let go so the others kept up. The scrollback is the
|
||||||
|
// state: connecting again costs nothing.
|
||||||
|
say(frame.message || t("Reconnecting…"))
|
||||||
|
drop()
|
||||||
|
term.reset()
|
||||||
|
forgetMarks()
|
||||||
|
connect()
|
||||||
|
} else if (frame.t === "closed" || frame.t === "error") {
|
||||||
|
// No reconnect of its own: a new shell has lost the folder, the environment and the
|
||||||
|
// half-typed command, and quietly putting one in its place is worse than saying it went.
|
||||||
|
say(frame.message || t("This terminal closed."), frame.t === "error")
|
||||||
|
drop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ended(end) {
|
||||||
|
if (end.code === 4401 || end.status === 401) say(t("Sign in again: the server no longer accepts this app's sign-in."), true)
|
||||||
|
else if (end.status === 404) say(t("The terminal needs a newer LLeMbas server. Ask its administrator to update it."), true)
|
||||||
|
else if (end.error) say(end.error, true)
|
||||||
|
else say(t("Disconnected. Close and reopen to reconnect."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Let go of the socket (the shell on the device lives on, as the server keeps it). */
|
||||||
|
function drop() {
|
||||||
|
const s = session
|
||||||
|
session = null
|
||||||
|
s?.handle?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- What the panel is for -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the panel what the chat page shows: `{ chat, name, directory }` for an open agent chat,
|
||||||
|
* `{ device, directory, name }` for a new one with its device and folder chosen, or null (no shell
|
||||||
|
* to open). A new chat's shell becomes its chat's when the first message has made the chat: the
|
||||||
|
* server took that shell over, so the socket stays as it is.
|
||||||
|
*/
|
||||||
|
export function aimTerminal(next) {
|
||||||
|
const key = next ? (next.chat ? `chat:${next.chat}` : `device:${next.device}:${next.directory}`) : ""
|
||||||
|
const before = target
|
||||||
|
target = next ? { ...next, key } : null
|
||||||
|
if (target) {
|
||||||
|
$("terminal-title").textContent = target.name || t("Terminal")
|
||||||
|
$("terminal-where").textContent = target.directory || ""
|
||||||
|
$("terminal-where").title = target.directory || ""
|
||||||
|
}
|
||||||
|
if (before?.key === key) return
|
||||||
|
// The draft's shell is the new chat's now: the same socket, said for the chat from here on.
|
||||||
|
const adopted = session?.draft && before && !before.chat && target?.chat && before.directory === target.directory && before.device === target.device
|
||||||
|
if (adopted) {
|
||||||
|
session.key = key
|
||||||
|
session.draft = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (session) {
|
||||||
|
drop()
|
||||||
|
if (term) term.reset()
|
||||||
|
forgetMarks()
|
||||||
|
say("")
|
||||||
|
}
|
||||||
|
if (!target) {
|
||||||
|
if (terminalOpen()) showTerminal(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (terminalOpen()) connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Showing it ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Show or hide the panel. Hiding keeps the shell and its socket: reopening is instant. */
|
||||||
|
export async function showTerminal(on) {
|
||||||
|
if (on && !target) return
|
||||||
|
panel().hidden = !on
|
||||||
|
ctx.layout(on)
|
||||||
|
markOpen()
|
||||||
|
if (!on) return
|
||||||
|
try {
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
say(t("The terminal could not be loaded."), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!terminalOpen()) return
|
||||||
|
build()
|
||||||
|
// A frame later: the panel has only just been shown and has no width to fit to yet.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
refit()
|
||||||
|
connect()
|
||||||
|
term.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleTerminal() {
|
||||||
|
showTerminal(!terminalOpen())
|
||||||
|
}
|
||||||
|
|
||||||
|
function markOpen() {
|
||||||
|
for (const b of document.querySelectorAll("[data-terminal-toggle]")) b.setAttribute("aria-pressed", String(terminalOpen()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The header's button for an open chat; a new chat has the chip under the composer instead. */
|
||||||
|
export function terminalButton() {
|
||||||
|
return h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "tool",
|
||||||
|
"aria-label": t("Terminal"),
|
||||||
|
title: t("Terminal"),
|
||||||
|
"aria-controls": "terminal",
|
||||||
|
"aria-pressed": String(terminalOpen()),
|
||||||
|
"data-terminal-toggle": "",
|
||||||
|
onclick: toggleTerminal,
|
||||||
|
},
|
||||||
|
icon(ICONS.terminal),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- A device shell's own marks ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
function onCommandMark(data) {
|
||||||
|
if (!osc || !term) return false
|
||||||
|
const [kind, code] = String(data).split(";")
|
||||||
|
if (kind === "A") pending = { prompt: term.registerMarker(0) }
|
||||||
|
else if (kind === "B") {
|
||||||
|
pending = pending || {}
|
||||||
|
pending.input = term.registerMarker(0)
|
||||||
|
pending.inputX = term.buffer.active.cursorX
|
||||||
|
} else if (kind === "C") {
|
||||||
|
pending = pending || {}
|
||||||
|
pending.output = term.registerMarker(0)
|
||||||
|
pending.command = commandText(pending)
|
||||||
|
} else if (kind === "D") {
|
||||||
|
if (pending?.output) {
|
||||||
|
pending.exit = parseInt(code, 10) || 0
|
||||||
|
pending.end = term.registerMarker(0)
|
||||||
|
commandDone(pending)
|
||||||
|
}
|
||||||
|
pending = null
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWhere(data) {
|
||||||
|
if (!osc) return false
|
||||||
|
let path = String(data).replace(/^file:\/\/[^/]*/, "")
|
||||||
|
try {
|
||||||
|
path = decodeURIComponent(path)
|
||||||
|
} catch {}
|
||||||
|
if (path) $("terminal-where").textContent = path
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function linesBetween(from, to, firstX) {
|
||||||
|
const buffer = term.buffer.active
|
||||||
|
const out = []
|
||||||
|
for (let y = from; y < to; y++) {
|
||||||
|
const line = buffer.getLine(y)
|
||||||
|
if (!line) continue
|
||||||
|
const text = line.translateToString(true, y === from ? firstX || 0 : 0)
|
||||||
|
if (line.isWrapped && out.length) out[out.length - 1] += text
|
||||||
|
else out.push(text)
|
||||||
|
}
|
||||||
|
while (out.length && !out[out.length - 1].trim()) out.pop()
|
||||||
|
return out.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandText(mark) {
|
||||||
|
if (!mark.input || !mark.output || mark.input.line < 0) return ""
|
||||||
|
return linesBetween(mark.input.line, Math.max(mark.output.line, mark.input.line + 1), mark.inputX).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputOf(mark) {
|
||||||
|
if (!mark.output || !mark.end || mark.output.line < 0 || mark.end.line < 0) return ""
|
||||||
|
return linesBetween(mark.output.line, mark.end.line, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandDone(mark) {
|
||||||
|
finished.push(mark)
|
||||||
|
if (finished.length > MAX_MARKED) finished.shift()
|
||||||
|
visiting = -1
|
||||||
|
showLast((mark.command || "…") + (mark.exit ? ` · exit ${mark.exit}` : ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockOf(mark) {
|
||||||
|
const where = $("terminal-where").textContent
|
||||||
|
const head = `${where ? `${where} ` : ""}$ ${mark.command || ""}`
|
||||||
|
return "```\n" + head + "\n" + outputOf(mark) + "\n```\n" + (mark.exit ? `exit ${mark.exit}\n` : "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastFinished() {
|
||||||
|
for (let i = finished.length - 1; i >= 0; i--) if (finished[i].output && finished[i].output.line >= 0) return finished[i]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit(step) {
|
||||||
|
const live = finished.filter((m) => (m.prompt || m.input || m.output)?.line >= 0)
|
||||||
|
if (!live.length) return
|
||||||
|
visiting = visiting < 0 ? live.length : visiting
|
||||||
|
visiting = Math.max(0, Math.min(live.length - 1, visiting + step))
|
||||||
|
const m = live[visiting]
|
||||||
|
term.scrollToLine((m.prompt || m.input || m.output).line)
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgetMarks() {
|
||||||
|
finished = []
|
||||||
|
pending = null
|
||||||
|
visiting = -1
|
||||||
|
osc = false
|
||||||
|
showMarkControls()
|
||||||
|
showLast("")
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMarkControls() {
|
||||||
|
$("terminal-prev").hidden = !osc
|
||||||
|
$("terminal-next").hidden = !osc
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLast(text) {
|
||||||
|
$("terminal-last").textContent = text || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handing it to the chat --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** What "send" and "copy" take: the selection; else the last command (with marks); else the screen. */
|
||||||
|
function capture() {
|
||||||
|
if (!term) return ""
|
||||||
|
const chosen = term.getSelection()
|
||||||
|
if (chosen && chosen.trim()) return "```\n" + chosen + "\n```\n"
|
||||||
|
if (osc) {
|
||||||
|
const mark = lastFinished()
|
||||||
|
if (!mark) {
|
||||||
|
say(t("Nothing has been run in this shell yet."))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return blockOf(mark)
|
||||||
|
}
|
||||||
|
const buffer = term.buffer.active
|
||||||
|
const last = buffer.baseY + buffer.cursorY
|
||||||
|
const lines = []
|
||||||
|
for (let y = Math.max(0, last - 40); y <= last; y++) {
|
||||||
|
const line = buffer.getLine(y)
|
||||||
|
if (line) lines.push(line.translateToString(true))
|
||||||
|
}
|
||||||
|
const text = lines.join("\n").replace(/\n+$/, "")
|
||||||
|
if (!text.trim()) {
|
||||||
|
say(t("Nothing to send: select some output first."))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
say(t("Copied the last of the screen, as it appeared. This shell does not mark where commands begin."))
|
||||||
|
return "```\n" + text + "\n```\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Into the message box, never sent: what a machine printed is for the person to read first. */
|
||||||
|
function sendToChat() {
|
||||||
|
const text = capture()
|
||||||
|
if (!text) return
|
||||||
|
ctx.composer(text)
|
||||||
|
// Where the panel covers the chat (a narrow window), it steps aside to show the message box.
|
||||||
|
if (window.matchMedia("(max-width: 64rem)").matches) showTerminal(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyOut() {
|
||||||
|
const text = capture()
|
||||||
|
if (!text) return
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => say(t("Copied.")),
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initTerminal({ onLayout, composer }) {
|
||||||
|
ctx = { layout: onLayout || (() => {}), composer: composer || (() => {}) }
|
||||||
|
$("terminal-icon").replaceChildren(icon(ICONS.terminal))
|
||||||
|
$("terminal-prev").replaceChildren(icon(ICONS.chevronUp))
|
||||||
|
$("terminal-next").replaceChildren(icon(ICONS.chevronDown))
|
||||||
|
$("terminal-copy").replaceChildren(icon(ICONS.copy))
|
||||||
|
$("terminal-send").prepend(icon(ICONS.send))
|
||||||
|
$("terminal-close").replaceChildren(icon(ICONS.close))
|
||||||
|
$("terminal-prev").addEventListener("click", () => visit(-1))
|
||||||
|
$("terminal-next").addEventListener("click", () => visit(1))
|
||||||
|
$("terminal-copy").addEventListener("click", copyOut)
|
||||||
|
$("terminal-send").addEventListener("click", sendToChat)
|
||||||
|
$("terminal-close").addEventListener("click", () => showTerminal(false))
|
||||||
|
}
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
// The subagent tree (harness spec 3.4.0, subagents.md §12) as the activity panel draws it, the
|
||||||
|
// server's own panel in this app (`chat/_activity_tasks.html` tree_row): the main agent at the
|
||||||
|
// root, every subagent under its parent in the order added, how many slots are open, then the
|
||||||
|
// ended ones folded apart, dimmed, with how each ended. A row is the tasks' grid -- chevron, who
|
||||||
|
// it is and what it is doing, its context use in the time column, a stop square -- and opened,
|
||||||
|
// its facts (state, model, mode and request options, messages, findings), its task, its phase
|
||||||
|
// line, the last room message to or from it, and the buttons to read its conversation and its
|
||||||
|
// findings. Its subagents nest under it.
|
||||||
|
//
|
||||||
|
// The data is the server's `GET /api/v2/chats/{chat}/tree` (and the stream's `tree` frame):
|
||||||
|
//
|
||||||
|
// { min, max, size, open_slots,
|
||||||
|
// agents: [{ id, name, position, parent, state, model, mode, request, manager, task,
|
||||||
|
// context: { used, window }, progress, findings, messages }],
|
||||||
|
// ended: [{ ..., ended }] }
|
||||||
|
//
|
||||||
|
// Everything in it but the numbers is a model's words: set as text, never as markup.
|
||||||
|
|
||||||
|
import { h, icon, ICONS } from "./dom.js"
|
||||||
|
import { progressLine, progressText } from "./progress.js"
|
||||||
|
|
||||||
|
/** The states' words (loop.json team.read.states; the server's TREE_STATE_WORDS). */
|
||||||
|
const STATES = {
|
||||||
|
working: "working",
|
||||||
|
waiting: "waiting for a slot",
|
||||||
|
finished: "finished",
|
||||||
|
idle: "idle",
|
||||||
|
paused: "paused: context full",
|
||||||
|
done: "done",
|
||||||
|
out_of_context: "out of context",
|
||||||
|
stopped: "stopped",
|
||||||
|
}
|
||||||
|
/** A subagent that may still be stopped (the server's TREE_LIVE). */
|
||||||
|
export const TREE_LIVE = ["working", "waiting", "paused"]
|
||||||
|
|
||||||
|
export function treeState(state) {
|
||||||
|
return STATES[state] ? t(STATES[state]) : String(state || "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** floor(used × 100 / window), or null when the window is not known. */
|
||||||
|
export function contextPercent(c) {
|
||||||
|
const used = Number(c?.used)
|
||||||
|
const window = Number(c?.window)
|
||||||
|
if (!(window > 0) || !(used >= 0)) return null
|
||||||
|
return Math.min(100, Math.floor((used * 100) / window))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The agents in tree order (an agent, then those under it, depth first), each with its depth. */
|
||||||
|
export function treeOrder(agents) {
|
||||||
|
const names = new Map()
|
||||||
|
for (const a of agents) names.set(a.id, a.id).set(a.name, a.id)
|
||||||
|
const parentOf = (a) => {
|
||||||
|
const p = a.parent ? names.get(a.parent) : null
|
||||||
|
return p && p !== a.id ? p : null
|
||||||
|
}
|
||||||
|
const under = new Map()
|
||||||
|
for (const a of agents) {
|
||||||
|
const p = parentOf(a)
|
||||||
|
if (!under.has(p)) under.set(p, [])
|
||||||
|
under.get(p).push(a)
|
||||||
|
}
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
const walk = (parent, depth) => {
|
||||||
|
for (const a of under.get(parent) || []) {
|
||||||
|
if (seen.has(a.id)) continue
|
||||||
|
seen.add(a.id)
|
||||||
|
out.push({ agent: a, depth })
|
||||||
|
walk(a.id, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(null, 0)
|
||||||
|
for (const a of agents) if (!seen.has(a.id)) out.push({ agent: a, depth: 0 })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The room's messages to or from an agent (by name). */
|
||||||
|
function messagesOf(room, a) {
|
||||||
|
return room.filter((m) => m.from === a.name || (m.to || []).includes(a.name) || (m.recipients || []).includes(a.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstLine(text) {
|
||||||
|
return String(text || "").split("\n").find((l) => l.trim()) || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tree as rows. `act` holds what the rows can do: `open(a)` (its conversation, read-only),
|
||||||
|
* `findings(a)`, `stop(a)`; `isOpen(key)` / `toggled(key, open)` keep unfolded rows unfolded
|
||||||
|
* across redraws. `room` is the room as read so far; `device` says the subagents run on the
|
||||||
|
* computer the chat runs on (an agent chat).
|
||||||
|
*/
|
||||||
|
export function treePart(tree, { room = [], act = {}, now = Date.now(), device = false } = {}) {
|
||||||
|
const box = h("div", { class: "subagents" })
|
||||||
|
box.append(h("p", { class: "tasks__team", text: t("%(size)s of %(max)s", { size: tree.size ?? (tree.agents || []).length, max: tree.max ?? "?" }) }))
|
||||||
|
if (Number(tree.open_slots) > 0) box.append(h("p", { class: "tasks__goal subagents__slots", text: t("Open slots: %(n)s", { n: tree.open_slots }) }))
|
||||||
|
if (device) box.append(h("p", { class: "tasks__goal", text: t("They run on the computer this chat runs on.") }))
|
||||||
|
box.append(h("div", { class: "tasks__list subagents__list" }, treeOrder(tree.agents || []).map(({ agent, depth }) => row(agent, depth, { room, act, now, ended: false }))))
|
||||||
|
const ended = tree.ended || []
|
||||||
|
if (ended.length) {
|
||||||
|
const key = "fold:ended"
|
||||||
|
const fold = h(
|
||||||
|
"details",
|
||||||
|
{ class: "tasks__more subagents__ended", open: !!act.isOpen?.(key) },
|
||||||
|
h("summary", null, h("span", { class: "tasks__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), h("span", { text: `${t("Ended")} ${ended.length}` })),
|
||||||
|
h("div", { class: "tasks__list" }, ended.map((a) => row(a, 0, { room, act, now, ended: true }))),
|
||||||
|
)
|
||||||
|
fold.addEventListener("toggle", () => act.toggled?.(key, fold.open))
|
||||||
|
box.append(fold)
|
||||||
|
}
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(a, depth, { room, act, now, ended }) {
|
||||||
|
const key = `sub:${a.id}`
|
||||||
|
const main = !a.parent && (a.id === "lead" || a.name === "lead")
|
||||||
|
const pct = ended ? null : contextPercent(a.context)
|
||||||
|
const said = messagesOf(room, a)
|
||||||
|
const last = said[said.length - 1]
|
||||||
|
const mode = a.mode ? (a.request?.length ? `${a.mode} + ${a.request.join(", ")}` : a.mode) : ""
|
||||||
|
const live = !ended && TREE_LIVE.includes(a.state)
|
||||||
|
const phase = !ended && a.progress ? progressText(a.progress, now) : ""
|
||||||
|
const stop =
|
||||||
|
live && !main && act.stop
|
||||||
|
? h(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "agent__stop",
|
||||||
|
"aria-label": t("Stop %(name)s", { name: a.name }),
|
||||||
|
title: t("Stop %(name)s", { name: a.name }),
|
||||||
|
onclick: (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
act.stop(a)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h("span", { class: "agent__square", "aria-hidden": "true" }),
|
||||||
|
)
|
||||||
|
: h("span", { class: "agent__stop is-none", "aria-hidden": "true" })
|
||||||
|
const el = h(
|
||||||
|
"details",
|
||||||
|
{ class: `agent subagent is-${String(a.state || "").replace(/[^a-z_]/g, "")}${ended ? " is-ended" : ""}`, open: !!act.isOpen?.(key), dataset: { id: a.id } },
|
||||||
|
h(
|
||||||
|
"summary",
|
||||||
|
{ class: "agent__row" },
|
||||||
|
h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)),
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "agent__what" },
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{ class: "subagent__who" },
|
||||||
|
h("bdi", { class: "agent__name", text: a.name || a.id }),
|
||||||
|
a.id !== a.name ? h("span", { class: "agent__id", text: a.id }) : null,
|
||||||
|
),
|
||||||
|
main ? h("span", { class: "agent__role", text: t("the main agent") }) : a.position ? h("span", { class: "agent__role", text: a.position }) : null,
|
||||||
|
// The phase alone where a row has no room for its clock (progress.md §3); ended: how.
|
||||||
|
h("span", { class: "subagent__phase", dataset: { phase: phase ? a.progress.phase : "" }, text: phase || treeState(a.state) }),
|
||||||
|
),
|
||||||
|
h("span", { class: "agent__time", title: t("Context used"), text: pct === null ? "" : `${pct}%` }),
|
||||||
|
stop,
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{ class: "agent__body" },
|
||||||
|
h(
|
||||||
|
"p",
|
||||||
|
{ class: "agent__facts" },
|
||||||
|
h("span", { class: "agent__state", text: treeState(a.state) }),
|
||||||
|
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
|
||||||
|
mode ? h("span", { text: mode }) : null,
|
||||||
|
h("span", { text: t("messages: %(n)s", { n: a.messages ?? said.length }) }),
|
||||||
|
a.findings ? h("span", { text: t("findings: %(n)s", { n: a.findings }) }) : null,
|
||||||
|
),
|
||||||
|
a.task && !main ? h("p", { class: "agent__brief", text: a.task }) : null,
|
||||||
|
phase ? h("p", { class: "agent__text subagent__line", dataset: { progress: JSON.stringify(a.progress) }, text: progressLine(a.progress, now) }) : null,
|
||||||
|
last ? h("p", { class: "agent__text", text: `${last.from} → ${(last.to || []).join(", ")}: ${firstLine(last.text)}` }) : null,
|
||||||
|
main
|
||||||
|
? null
|
||||||
|
: h(
|
||||||
|
"p",
|
||||||
|
{ class: "subagent__buttons" },
|
||||||
|
act.open ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => act.open(a) }) : null,
|
||||||
|
act.findings ? h("button", { type: "button", class: "btn btn--sm", text: t("Findings"), onclick: () => act.findings(a) }) : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, depth))))
|
||||||
|
el.addEventListener("toggle", () => act.toggled?.(key, el.open))
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tree's phase lines move on by themselves between reads (their clock). */
|
||||||
|
export function tickTree(root, now = Date.now()) {
|
||||||
|
for (const el of root.querySelectorAll(".subagent__line[data-progress]")) {
|
||||||
|
try {
|
||||||
|
el.textContent = progressLine(JSON.parse(el.dataset.progress), now)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
/* LLeMbas v2.0.0-beta.6 (a71e9b4bdb8a) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
|
/* LLeMbas v2.0.0-beta.8 (d06ca353b801) static/css/tokens.css, copied by scripts/fetch-tokens.sh: do not edit.
|
||||||
The font-face rules are left out; the app bundles no font. */
|
The font-face rules are left out; the app bundles no font. */
|
||||||
/*
|
/*
|
||||||
Design tokens.
|
Design tokens.
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||||
|
//# sourceMappingURL=addon-fit.js.map
|
||||||
Vendored
+218
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
|
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||||
|
* https://github.com/chjj/term.js
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* Originally forked from (with the author's permission):
|
||||||
|
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||||
|
* http://bellard.org/jslinux/
|
||||||
|
* Copyright (c) 2011 Fabrice Bellard
|
||||||
|
* The original design remains. The terminal itself
|
||||||
|
* has been extended to include xterm CSI codes, among
|
||||||
|
* other features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default styles for xterm.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
cursor: text;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.focus,
|
||||||
|
.xterm:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helpers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
/**
|
||||||
|
* The z-index of the helpers must be higher than the canvases in order for
|
||||||
|
* IMEs to appear on top.
|
||||||
|
*/
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helper-textarea {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
left: -9999em;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: -5;
|
||||||
|
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view {
|
||||||
|
/* TODO: Composition position got messed up somewhere */
|
||||||
|
background: #000;
|
||||||
|
color: #FFF;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||||
|
background-color: #000;
|
||||||
|
overflow-y: scroll;
|
||||||
|
cursor: default;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-scroll-area {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-char-measure-element {
|
||||||
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -9999em;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.enable-mouse-events {
|
||||||
|
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.xterm-cursor-pointer,
|
||||||
|
.xterm .xterm-cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.column-select.focus {
|
||||||
|
/* Column selection mode */
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility:not(.debug),
|
||||||
|
.xterm .xterm-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree {
|
||||||
|
user-select: text;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .live-region {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-dim {
|
||||||
|
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||||
|
* explicitly in the generated class and reset to 1 here */
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-underline-1 { text-decoration: underline; }
|
||||||
|
.xterm-underline-2 { text-decoration: double underline; }
|
||||||
|
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||||
|
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||||
|
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||||
|
|
||||||
|
.xterm-overline {
|
||||||
|
text-decoration: overline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||||
|
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||||
|
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||||
|
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||||
|
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||||
|
|
||||||
|
.xterm-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||||
|
z-index: 6;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-overview-ruler {
|
||||||
|
z-index: 8;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-top {
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(9924),a=i(844),h=i(4725),c=i(2585),l=i(3656);let d=t.AccessibilityManager=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=this._coreBrowserService.mainDocument.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=this._coreBrowserService.mainDocument.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;e<this._terminal.rows;e++)this._rowElements[e]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[e]);if(this._topBoundaryFocusListener=e=>this._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=this._coreBrowserService.mainDocument.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this.register((0,l.addDisposableDomListener)(document,"selectionchange",(()=>this._handleSelectionChange()))),this.register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.innerText=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shiftLine truncated
|
||||||
|
//# sourceMappingURL=xterm.js.map
|
||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"tag": "v2.0.0-beta.6",
|
"tag": "v2.0.0-beta.8",
|
||||||
"commit": "a71e9b4bdb8acb6e9949e978253a72c347cc67f3",
|
"commit": "d06ca353b801916302f0ce0cc9ae3e5244ff184f",
|
||||||
"files": {
|
"files": {
|
||||||
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
|
"cm-autocomplete.mjs": {"server": "26fe431a5f54e29952e094c93151f11473a80677bfb339711ecca08195a89df4", "copy": "fcb7fcbe4e2b072d031520ec898c81ac3f2f05fb4126e03003c9b5e60ffaa8de"},
|
||||||
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},
|
"cm-codemirror.mjs": {"server": "dea86a106e13c9316dfedfd29209780b853bdb2b477edf3f7c90f497b592a223", "copy": "0b80ae1289a1dc3e6718727e78bc757caad657e907d0351597867d4f90c36bd2"},
|
||||||
@@ -19,6 +19,9 @@
|
|||||||
"lezer-common.mjs": {"server": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f", "copy": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f"},
|
"lezer-common.mjs": {"server": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f", "copy": "b63d03ca460cc7a6ce09cbf0de24943fec22bcbe2359dc35978a57b875b2450f"},
|
||||||
"lezer-highlight.mjs": {"server": "b74148d673103ae08330a045a464acddf4ee8b563b213ea26e50f2be01a6d19d", "copy": "df21a1741ae80f2ad2b6d25860cad9ff3cda246a4fba171535d9dc92df150c1a"},
|
"lezer-highlight.mjs": {"server": "b74148d673103ae08330a045a464acddf4ee8b563b213ea26e50f2be01a6d19d", "copy": "df21a1741ae80f2ad2b6d25860cad9ff3cda246a4fba171535d9dc92df150c1a"},
|
||||||
"lezer-lr.mjs": {"server": "4c2dcc61b5c3aedb091dfd60ce8abaeb94704b58fa9036547b0b8d2c75f701e4", "copy": "f41bc21dc3fd874132e1d9862aae57946ae2604d91330303924c03aecfd3bc21"},
|
"lezer-lr.mjs": {"server": "4c2dcc61b5c3aedb091dfd60ce8abaeb94704b58fa9036547b0b8d2c75f701e4", "copy": "f41bc21dc3fd874132e1d9862aae57946ae2604d91330303924c03aecfd3bc21"},
|
||||||
|
"xterm.js": {"server": "1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495", "copy": "1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495"},
|
||||||
|
"xterm.css": {"server": "ba8e6985669488981ccf40c0cefe3aba80722cb6c92de7ad628b0bd717faf2b6", "copy": "ba8e6985669488981ccf40c0cefe3aba80722cb6c92de7ad628b0bd717faf2b6"},
|
||||||
|
"xterm-addon-fit.js": {"server": "bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089", "copy": "bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089"},
|
||||||
"node-process.mjs": {"server": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981", "copy": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981"}
|
"node-process.mjs": {"server": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981", "copy": "0c7460cbd81d8427a882867726b6db43beff3399f045ad942f4a65fef4705981"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user