diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d232d..6d9790a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,16 @@ All notable changes to LLeMbas Desktop. The format follows ### 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. - `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). diff --git a/scripts/fetch-vendor.sh b/scripts/fetch-vendor.sh index 7be2565..eb0a946 100755 --- a/scripts/fetch-vendor.sh +++ b/scripts/fetch-vendor.sh @@ -1,6 +1,8 @@ #!/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/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 copy the modules at that ref (a tag such as # 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 bad=1 fi - done < <(sed -n -E 's/^ *"([a-z0-9-]+\.mjs)": *\{"server": *"[0-9a-f]{64}", *"copy": *"([0-9a-f]{64})"\}.*/\1\t\2/p' "$LOCK") - for f in "$OUT"/*.mjs; do + 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"/*; do grep -q "\"$(basename "$f")\"" "$LOCK" || { echo "fetch-vendor: $f is not in $LOCK" >&2; bad=1; } done [ "$count" -gt 0 ] || { echo "fetch-vendor: $LOCK lists no file" >&2; exit 1; } @@ -87,6 +89,16 @@ for name in $names; do fi entries+=("$(printf ' "%s": {"server": "%s", "copy": "%s"}' "$name" "$have" "$(sha "$work/out/$name")")") 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). stub="src/lembas/web/static/js/node-process.mjs" git -C "$SERVER" show "$commit:$stub" > "$work/out/node-process.mjs" \ @@ -101,7 +113,7 @@ done rm -rf "$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" last=$((${#entries[@]} - 1)) diff --git a/scripts/gen-i18n.py b/scripts/gen-i18n.py index 518872f..b6079f2 100755 --- a/scripts/gen-i18n.py +++ b/scripts/gen-i18n.py @@ -376,6 +376,29 @@ KEYS = ( "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.", ) # The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the @@ -383,6 +406,8 @@ KEYS = ( # the device, as the server's catalogue has it. OWN: dict[str, dict[str, str]] = { "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", @@ -604,6 +629,8 @@ OWN: dict[str, dict[str, str]] = { "No day ticked: every day.": "Žádný den nezaškrtnutý: každý den.", }, "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", @@ -825,6 +852,8 @@ OWN: dict[str, dict[str, str]] = { "No day ticked: every day.": "Kein Tag angehakt: jeden Tag.", }, "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.", @@ -1046,6 +1075,8 @@ OWN: dict[str, dict[str, str]] = { "No day ticked: every day.": "Ningún día marcado: todos los días.", }, "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", @@ -1267,6 +1298,8 @@ OWN: dict[str, dict[str, str]] = { "No day ticked: every day.": "Aucun jour coché : tous les jours.", }, "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.", @@ -1488,6 +1521,8 @@ OWN: dict[str, dict[str, str]] = { "No day ticked: every day.": "Ha egy nap sincs bejelölve: minden nap.", }, "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", @@ -1709,6 +1744,8 @@ OWN: dict[str, dict[str, str]] = { "No day ticked: every day.": "Nessun giorno spuntato: ogni giorno.", }, "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", @@ -1930,6 +1967,8 @@ OWN: dict[str, dict[str, str]] = { "No day ticked: every day.": "Żaden dzień niezaznaczony: codziennie.", }, "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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b6d55cf..c92b7f3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -279,6 +279,15 @@ dependencies = [ "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]] name = "block2" version = "0.6.2" @@ -511,6 +520,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie" version = "0.18.2" @@ -623,6 +638,15 @@ dependencies = [ "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]] name = "cssparser" version = "0.37.0" @@ -686,6 +710,12 @@ dependencies = [ "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]] name = "dbus" version = "0.9.12" @@ -775,8 +805,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "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]] @@ -1552,6 +1593,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "hyper" version = "1.12.0" @@ -2097,6 +2147,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tungstenite", "ureq", ] @@ -3488,6 +3539,17 @@ dependencies = [ "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]] name = "sha2" version = "0.10.9" @@ -3496,7 +3558,7 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -4539,6 +4601,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "typeid" version = "1.0.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6f37ece..f019b08 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,8 @@ tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" 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] libc = "0.2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 68709ac..756d263 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ mod remote; mod sealed; mod server; mod settings; +mod terminal; mod watch; use std::collections::HashMap; @@ -115,6 +116,8 @@ struct App { signing_in: Mutex>>, streams: Mutex>>, next_stream: AtomicU64, + /// The terminal panel's sockets (`terminal.rs`), by the page's id for each. + terminals: terminal::Sessions, sealed: sealed::Pages, watch: watch::Watch, /// 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(); if app.account.lock().unwrap().take().is_some() { server::forget_account(&app.config_dir); + app.terminals.close_all(); announce(handle); } } @@ -511,6 +517,69 @@ 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, + device: Option, + directory: Option, + cols: u16, + rows: u16, +) -> Result { + let app = handle.state::(); + 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(); + Ok(app.terminals.open(url, token, move |down| { + if down.kind == "end" { + h.state::().terminals.forget(down.id); + let refused = down.data.get("code").and_then(Value::as_u64) + == Some(u64::from(terminal::CLOSE_UNAUTHORIZED)) + || down.data.get("status").and_then(Value::as_u64) == Some(401); + if refused { + token_refused(&h); + } + } + let _ = h.emit("terminal", down); + })) +} + +/// 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 ---------------------------------------------------------------------------------- /// What became of one file sent as an attachment: the server's attachment, or why not. `reason` @@ -1560,6 +1629,10 @@ pub fn run() { terminal_word, stream_open, stream_close, + terminal_open, + terminal_input, + terminal_resize, + terminal_close, local_list, local_read, sealed_open, @@ -1621,6 +1694,7 @@ pub fn run() { signing_in: Mutex::new(None), streams: Mutex::new(HashMap::new()), next_stream: AtomicU64::new(0), + terminals: terminal::Sessions::default(), sealed: sealed::Pages::default(), watch: watch::Watch::default(), dropped: Mutex::new(Vec::new()), diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs new file mode 100644 index 0000000..2c247e9 --- /dev/null +++ b/src-tauri/src/terminal.rs @@ -0,0 +1,437 @@ +//! 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>; + +/// 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 { + 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), + 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, + pub reason: String, + pub status: Option, + pub error: Option, +} + +impl End { + fn failed(error: impl Into) -> 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>>, +} + +impl Sessions { + /// Open a terminal's socket on its own thread. `emit` gets every frame, the `end` last; + /// the id is the page's handle on it. + pub fn open(&self, url: String, token: String, emit: impl Fn(Down) + 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); + 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 { + 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, 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 = 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::(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(); + } +} diff --git a/tests/ui/mock-tauri.js b/tests/ui/mock-tauri.js index 8bdfd35..a1afee2 100644 --- a/tests/ui/mock-tauri.js +++ b/tests/ui/mock-tauri.js @@ -10,12 +10,18 @@ // 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 = [] @@ -36,6 +42,17 @@ 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 }) } @@ -61,6 +78,24 @@ 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": @@ -96,5 +131,10 @@ /** 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(""), } })() diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index 9226ee2..75ec98c 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -175,7 +175,7 @@ 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", linked: true }] }, + "GET /devices": { devices: [{ id: "token1", name: "workbench", host: "workbench", platform: "linux", terminal: true }] }, "GET /chats": { chats: [chatRow(), agentChat()], more: false }, "GET /schedules": { schedules: [] }, ...over, @@ -433,6 +433,188 @@ test("A5: an open chat follows its own `subagents`: offered, offered with the no } }) +// --- 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, []) +}) + // --- Run ---------------------------------------------------------------------------------------- const only = process.argv[2] || "" diff --git a/ui/app.css b/ui/app.css index e68d8b7..8a32db3 100644 --- a/ui/app.css +++ b/ui/app.css @@ -327,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-viewer="true"] { --panel-col: minmax(var(--inspector-width-min), 42%); } .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 --------------------------------------------------------------------------- */ @@ -1291,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: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__label { overflow: hidden; text-overflow: ellipsis; } .cchip__chev { display: grid; color: var(--ink-faint); } @@ -1558,12 +1561,65 @@ 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 .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; } + /* --- Narrower windows ---------------------------------------------------------------------- */ /* 64rem (--bp-wide): a panel goes over the chat instead of beside it. */ @media (max-width: 64rem) { - .shell[data-viewer="true"], .shell[data-activity="true"] { --panel-col: 0px; } - .viewer, .activity, .shell[data-activity="true"] .viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; } + .shell[data-viewer="true"], .shell[data-activity="true"], .shell[data-terminal="true"] { --panel-col: 0px; } + .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__next { display: none; } } diff --git a/ui/i18n.js b/ui/i18n.js index 2ed6148..3b48a79 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -68,6 +68,7 @@ const LEMBAS_I18N = { "Choose a model to choose its effort.": "Vyberte model, abyste mohli zvolit jeho náročnost.", "Close": "Zavřít", "Close (LLeMbas stays in the tray)": "Zavřít (LLeMbas zůstane v oznamovací oblasti)", + "Close terminal": "Zavřít terminál", "Close the activity panel": "Zavřít panel aktivity", "Command": "Příkaz", "Commands": "Příkazy", @@ -77,12 +78,18 @@ const LEMBAS_I18N = { "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.", "Connect": "Připojit", + "Connected.": "Připojeno.", + "Connected. This shell is also open in another tab, and they share a size.": "Připojeno. Tento shell je otevřený i na jiné kartě a sdílejí velikost.", + "Connecting…": "Připojuje se…", "Context": "Kontext", "Context and background tasks": "Kontext a úlohy na pozadí", "Continue in a terminal": "Pokračovat v terminálu", "Continue in a terminal…": "Pokračovat v terminálu…", "Copied": "Zkopírováno", + "Copied the last of the screen, as it appeared. This shell does not mark where commands begin.": "Zkopírován konec obrazovky, tak jak vypadal. Tento shell neoznačuje, kde příkazy začínají.", + "Copied.": "Zkopírováno.", "Copy": "Kopírovat", + "Copy the last command and its output": "Zkopírovat poslední příkaz a jeho výstup", "Counted by the model at the last reply; the categories are estimated.": "Spočítal model při poslední odpovědi; kategorie jsou odhadnuté.", "Cut short here; the whole of it is in the web interface.": "Zde zkráceno; celé je to ve webovém rozhraní.", "Days": "Dny", @@ -91,6 +98,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Smazat tuto konverzaci?", "Delete this scheduled task?": "Odstranit tuto naplánovanou úlohu?", "Deny": "Zamítnout", + "Disconnected. Close and reopen to reconnect.": "Odpojeno. Pro opětovné připojení zavřete a znovu otevřete.", "Done": "Hotovo", "Drop the files to attach them": "Pusťte soubory a přiloží se", "Due %(when)s": "Naplánováno na %(when)s", @@ -165,12 +173,14 @@ const LEMBAS_I18N = { "Not measured.": "Neměřeno.", "Not run yet": "Ještě neběžela", "Not yet": "Ještě ne", + "Nothing has been run in this shell yet.": "V tomto shellu se zatím nic nespustilo.", "Nothing has been said in the room yet.": "V místnosti zatím nikdo nic neřekl.", "Nothing is scheduled.": "Nic není naplánováno.", "Nothing matches.": "Nic neodpovídá.", "Nothing running.": "Nic neběží.", "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í.", "Nothing to report": "Nic k hlášení", + "Nothing to send: select some output first.": "Není co odeslat: nejdřív označte část výstupu.", "Nothing yet.": "Zatím nic.", "Notifications": "Upozornění", "One chat that grows": "Jedna konverzace, která roste", @@ -192,9 +202,11 @@ const LEMBAS_I18N = { "Plan — changes nothing": "Plan — nic nemění", "Preview": "Náhled", "Project folders": "Složky projektů", + "Put the last command and its output into the message box": "Vložit poslední příkaz a jeho výstup do pole zprávy", "Read again": "Načíst znovu", "Reasoning effort": "Náročnost uvažování", "Recommended": "Doporučeno", + "Reconnecting…": "Znovu se připojuje…", "Regenerate reply": "Vygenerovat odpověď znovu", "Remove": "Odebrat", "Remove %(dir)s": "Odebrat %(dir)s", @@ -220,6 +232,7 @@ const LEMBAS_I18N = { "Search": "Hledat", "Search chats": "Hledat v konverzacích", "Send": "Odeslat", + "Send to chat": "Odeslat do konverzace", "Sending…": "Odesílá se…", "Server": "Server", "Set the time yourself": "Nastavit čas sami", @@ -228,6 +241,7 @@ const LEMBAS_I18N = { "Shown by the agent": "Ukázal agent", "Sidebar": "Postranní panel", "Sign in": "Přihlásit se", + "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.", "Sign out": "Odhlásit se", "Signed in as %(who)s.": "Přihlášen jako %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlášením se přihlášení aplikace na serveru zruší; propojení agenta zůstane. Chcete-li použít jiný server, odhlaste se.", @@ -250,6 +264,7 @@ const LEMBAS_I18N = { "Team: %(size)s of at most %(max)s subagents": "Tým: %(size)s z nejvýše %(max)s podagentů", "Tell me": "Upozornit mě", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozornit mě, když je odpověď nebo práce agenta hotová, zatímco jsem jinde", + "Terminal": "Terminál", "The agent asks to run %(tool)s:": "Agent žádá o spuštění %(tool)s:", "The agent asks:": "Agent se ptá:", "The agent has finished.": "Agent dokončil práci.", @@ -269,11 +284,17 @@ const LEMBAS_I18N = { "The last %(n)s runs failed: %(why)s": "Posledních %(n)s spuštění selhalo: %(why)s", "The model's own": "Podle modelu", "The most an agent chat may do here": "Nejvíc, co tu agentní konverzace smí", + "The next command": "Další příkaz", + "The next command (Ctrl+Shift+↓)": "Další příkaz (Ctrl+Shift+↓)", "The next runs": "Další spuštění", "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.", + "The previous command": "Předchozí příkaz", + "The previous command (Ctrl+Shift+↑)": "Předchozí příkaz (Ctrl+Shift+↑)", "The reply is ready.": "Odpověď je hotová.", "The result is no longer there.": "Výsledek už tu není.", "The server refused this computer's link: %(why)s": "Server odmítl propojení tohoto počítače: %(why)s", + "The terminal could not be loaded.": "Terminál se nepodařilo načíst.", + "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.", "These words give no run in the future.": "Z těchto slov nevychází žádné budoucí spuštění.", "Think before answering": "Přemýšlet před odpovědí", "Thinking": "Přemýšlí", @@ -286,6 +307,7 @@ const LEMBAS_I18N = { "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.", "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í.", "This server has no scheduled tasks yet.": "Tento server zatím nemá naplánované úlohy.", + "This terminal closed.": "Tento terminál se zavřel.", "Thought": "Přemýšlel", "Time": "Čas", "Times in %(zone)s": "Časové pásmo: %(zone)s", @@ -399,6 +421,7 @@ const LEMBAS_I18N = { "Choose a model to choose its effort.": "Wählen Sie ein Modell, um seinen Aufwand zu wählen.", "Close": "Schließen", "Close (LLeMbas stays in the tray)": "Schließen (LLeMbas bleibt im Benachrichtigungsbereich)", + "Close terminal": "Terminal schließen", "Close the activity panel": "Aktivitätsbereich schließen", "Command": "Befehl", "Commands": "Befehle", @@ -408,12 +431,18 @@ const LEMBAS_I18N = { "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.", "Connect": "Verbinden", + "Connected.": "Verbunden.", + "Connected. This shell is also open in another tab, and they share a size.": "Verbunden. Diese Shell ist auch in einem anderen Tab offen, und beide teilen sich eine Größe.", + "Connecting…": "Verbinde…", "Context": "Kontext", "Context and background tasks": "Kontext und Hintergrundaufgaben", "Continue in a terminal": "Im Terminal fortsetzen", "Continue in a terminal…": "Im Terminal fortsetzen…", "Copied": "Kopiert", + "Copied the last of the screen, as it appeared. This shell does not mark where commands begin.": "Das Ende des Bildschirms so kopiert, wie es erschien. Diese Shell markiert nicht, wo Befehle beginnen.", + "Copied.": "Kopiert.", "Copy": "Kopieren", + "Copy the last command and its output": "Den letzten Befehl und seine Ausgabe kopieren", "Counted by the model at the last reply; the categories are estimated.": "Vom Modell bei der letzten Antwort gezählt; die Kategorien sind geschätzt.", "Cut short here; the whole of it is in the web interface.": "Hier gekürzt; vollständig steht es in der Weboberfläche.", "Days": "Tage", @@ -422,6 +451,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Diesen Chat löschen?", "Delete this scheduled task?": "Diese geplante Aufgabe löschen?", "Deny": "Ablehnen", + "Disconnected. Close and reopen to reconnect.": "Getrennt. Schließen und erneut öffnen, um neu zu verbinden.", "Done": "Fertig", "Drop the files to attach them": "Dateien ablegen, um sie anzuhängen", "Due %(when)s": "Fällig %(when)s", @@ -496,12 +526,14 @@ const LEMBAS_I18N = { "Not measured.": "Nicht gemessen.", "Not run yet": "Noch nicht gelaufen", "Not yet": "Noch nicht", + "Nothing has been run in this shell yet.": "In dieser Shell wurde noch nichts ausgeführt.", "Nothing has been said in the room yet.": "Im Raum wurde noch nichts gesagt.", "Nothing is scheduled.": "Nichts ist geplant.", "Nothing matches.": "Nichts gefunden.", "Nothing running.": "Nichts läuft.", "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.", "Nothing to report": "Nichts zu melden", + "Nothing to send: select some output first.": "Nichts zu senden: Markieren Sie zuerst etwas Ausgabe.", "Nothing yet.": "Noch nichts.", "Notifications": "Benachrichtigungen", "One chat that grows": "Ein Chat, der wächst", @@ -523,9 +555,11 @@ const LEMBAS_I18N = { "Plan — changes nothing": "Plan — ändert nichts", "Preview": "Vorschau", "Project folders": "Projektordner", + "Put the last command and its output into the message box": "Den letzten Befehl und seine Ausgabe ins Nachrichtenfeld legen", "Read again": "Neu lesen", "Reasoning effort": "Denkaufwand", "Recommended": "Empfohlen", + "Reconnecting…": "Verbinde neu…", "Regenerate reply": "Antwort neu generieren", "Remove": "Entfernen", "Remove %(dir)s": "%(dir)s entfernen", @@ -551,6 +585,7 @@ const LEMBAS_I18N = { "Search": "Suchen", "Search chats": "Chats durchsuchen", "Send": "Senden", + "Send to chat": "An den Chat senden", "Sending…": "Wird gesendet…", "Server": "Server", "Set the time yourself": "Zeit selbst festlegen", @@ -559,6 +594,7 @@ const LEMBAS_I18N = { "Shown by the agent": "Vom Agenten gezeigt", "Sidebar": "Seitenleiste", "Sign in": "Anmelden", + "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.", "Sign out": "Abmelden", "Signed in as %(who)s.": "Angemeldet als %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Beim Abmelden gibt die App ihre Anmeldung am Server auf; die Verbindung des Agenten bleibt. Melden Sie sich ab, um einen anderen Server zu verwenden.", @@ -581,6 +617,7 @@ const LEMBAS_I18N = { "Team: %(size)s of at most %(max)s subagents": "Team: %(size)s von höchstens %(max)s Subagenten", "Tell me": "Benachrichtigen", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Benachrichtigen, wenn eine Antwort oder die Arbeit des Agenten fertig ist, während ich woanders bin", + "Terminal": "Terminal", "The agent asks to run %(tool)s:": "Der Agent möchte %(tool)s ausführen:", "The agent asks:": "Der Agent fragt:", "The agent has finished.": "Der Agent ist fertig.", @@ -600,11 +637,17 @@ const LEMBAS_I18N = { "The last %(n)s runs failed: %(why)s": "Die letzten %(n)s Läufe sind fehlgeschlagen: %(why)s", "The model's own": "Die des Modells", "The most an agent chat may do here": "Das Meiste, was ein Agent-Chat hier darf", + "The next command": "Nächster Befehl", + "The next command (Ctrl+Shift+↓)": "Nächster Befehl (Ctrl+Shift+↓)", "The next runs": "Die nächsten Läufe", "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.", + "The previous command": "Vorheriger Befehl", + "The previous command (Ctrl+Shift+↑)": "Vorheriger Befehl (Ctrl+Shift+↑)", "The reply is ready.": "Die Antwort ist fertig.", "The result is no longer there.": "Das Ergebnis ist nicht mehr da.", "The server refused this computer's link: %(why)s": "Der Server hat die Verknüpfung dieses Computers abgelehnt: %(why)s", + "The terminal could not be loaded.": "Das Terminal konnte nicht geladen werden.", + "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.", "These words give no run in the future.": "Diese Worte ergeben keinen künftigen Lauf.", "Think before answering": "Vor der Antwort nachdenken", "Thinking": "Denkt nach", @@ -617,6 +660,7 @@ const LEMBAS_I18N = { "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.", "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.", "This server has no scheduled tasks yet.": "Dieser Server hat noch keine geplanten Aufgaben.", + "This terminal closed.": "Dieses Terminal wurde geschlossen.", "Thought": "Nachgedacht", "Time": "Uhrzeit", "Times in %(zone)s": "Zeitzone: %(zone)s", @@ -730,6 +774,7 @@ const LEMBAS_I18N = { "Choose a model to choose its effort.": "Elija un modelo para elegir su esfuerzo.", "Close": "Cerrar", "Close (LLeMbas stays in the tray)": "Cerrar (LLeMbas sigue en la bandeja del sistema)", + "Close terminal": "Cerrar terminal", "Close the activity panel": "Cerrar el panel de actividad", "Command": "Comando", "Commands": "Comandos", @@ -739,12 +784,18 @@ const LEMBAS_I18N = { "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.", "Connect": "Conectar", + "Connected.": "Conectado.", + "Connected. This shell is also open in another tab, and they share a size.": "Conectado. Este shell también está abierto en otra pestaña, y comparten tamaño.", + "Connecting…": "Conectando…", "Context": "Contexto", "Context and background tasks": "Contexto y tareas en segundo plano", "Continue in a terminal": "Continuar en un terminal", "Continue in a terminal…": "Continuar en un terminal…", "Copied": "Copiado", + "Copied the last of the screen, as it appeared. This shell does not mark where commands begin.": "Se ha copiado el final de la pantalla, tal como aparecía. Este shell no marca dónde empiezan los comandos.", + "Copied.": "Copiado.", "Copy": "Copiar", + "Copy the last command and its output": "Copiar el último comando y su salida", "Counted by the model at the last reply; the categories are estimated.": "Contado por el modelo en la última respuesta; las categorías son estimaciones.", "Cut short here; the whole of it is in the web interface.": "Recortado aquí; completo está en la interfaz web.", "Days": "Días", @@ -753,6 +804,7 @@ const LEMBAS_I18N = { "Delete this chat?": "¿Eliminar este chat?", "Delete this scheduled task?": "¿Eliminar esta tarea programada?", "Deny": "Denegar", + "Disconnected. Close and reopen to reconnect.": "Desconectado. Cierre y vuelva a abrir para reconectar.", "Done": "Hecho", "Drop the files to attach them": "Suelte los archivos para adjuntarlos", "Due %(when)s": "Prevista %(when)s", @@ -827,12 +879,14 @@ const LEMBAS_I18N = { "Not measured.": "Sin medir.", "Not run yet": "Aún no se ha ejecutado", "Not yet": "Todavía no", + "Nothing has been run in this shell yet.": "Aún no se ha ejecutado nada en este shell.", "Nothing has been said in the room yet.": "Todavía no se ha dicho nada en la sala.", "Nothing is scheduled.": "No hay nada programado.", "Nothing matches.": "No hay coincidencias.", "Nothing running.": "Nada en marcha.", "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.", "Nothing to report": "Nada que informar", + "Nothing to send: select some output first.": "Nada que enviar: seleccione primero algo de la salida.", "Nothing yet.": "Aún nada.", "Notifications": "Notificaciones", "One chat that grows": "Un chat que va creciendo", @@ -854,9 +908,11 @@ const LEMBAS_I18N = { "Plan — changes nothing": "Plan — no cambia nada", "Preview": "Vista previa", "Project folders": "Carpetas de proyectos", + "Put the last command and its output into the message box": "Poner el último comando y su salida en el cuadro de mensaje", "Read again": "Leer de nuevo", "Reasoning effort": "Esfuerzo de razonamiento", "Recommended": "Recomendado", + "Reconnecting…": "Reconectando…", "Regenerate reply": "Regenerar respuesta", "Remove": "Quitar", "Remove %(dir)s": "Quitar %(dir)s", @@ -882,6 +938,7 @@ const LEMBAS_I18N = { "Search": "Buscar", "Search chats": "Buscar en los chats", "Send": "Enviar", + "Send to chat": "Enviar al chat", "Sending…": "Enviando…", "Server": "Servidor", "Set the time yourself": "Fijar la hora usted mismo", @@ -890,6 +947,7 @@ const LEMBAS_I18N = { "Shown by the agent": "Mostrado por el agente", "Sidebar": "Barra lateral", "Sign in": "Iniciar sesión", + "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.", "Sign out": "Cerrar sesión", "Signed in as %(who)s.": "Sesión iniciada como %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Al cerrar sesión, la aplicación renuncia a su acceso en el servidor; la vinculación del agente se mantiene. Cierre sesión para usar otro servidor.", @@ -912,6 +970,7 @@ const LEMBAS_I18N = { "Team: %(size)s of at most %(max)s subagents": "Equipo: %(size)s de un máximo de %(max)s subagentes", "Tell me": "Avisarme", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Avisarme cuando una respuesta o el trabajo de un agente termine mientras estoy en otra parte", + "Terminal": "Terminal", "The agent asks to run %(tool)s:": "El agente pide ejecutar %(tool)s:", "The agent asks:": "El agente pregunta:", "The agent has finished.": "El agente ha terminado.", @@ -931,11 +990,17 @@ const LEMBAS_I18N = { "The last %(n)s runs failed: %(why)s": "Las últimas %(n)s ejecuciones fallaron: %(why)s", "The model's own": "La del modelo", "The most an agent chat may do here": "Lo máximo que un chat de agente puede hacer aquí", + "The next command": "El comando siguiente", + "The next command (Ctrl+Shift+↓)": "El comando siguiente (Ctrl+Shift+↓)", "The next runs": "Las próximas ejecuciones", "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.", + "The previous command": "El comando anterior", + "The previous command (Ctrl+Shift+↑)": "El comando anterior (Ctrl+Shift+↑)", "The reply is ready.": "La respuesta está lista.", "The result is no longer there.": "El resultado ya no está.", "The server refused this computer's link: %(why)s": "El servidor rechazó la vinculación de este equipo: %(why)s", + "The terminal could not be loaded.": "No se ha podido cargar el terminal.", + "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.", "These words give no run in the future.": "Estas palabras no dan ninguna ejecución futura.", "Think before answering": "Razonar antes de responder", "Thinking": "Pensando", @@ -948,6 +1013,7 @@ const LEMBAS_I18N = { "This model serves one request at a time: subagents need another model, on another server, named in the chat.": "Este modelo atiende una solicitud a la vez: los subagentes necesitan otro modelo, en otro servidor, nombrado en el chat.", "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.", "This server has no scheduled tasks yet.": "Este servidor aún no tiene tareas programadas.", + "This terminal closed.": "Este terminal se ha cerrado.", "Thought": "Pensamiento", "Time": "Hora", "Times in %(zone)s": "Zona horaria: %(zone)s", @@ -1061,6 +1127,7 @@ const LEMBAS_I18N = { "Choose a model to choose its effort.": "Choisissez un modèle pour choisir son effort.", "Close": "Fermer", "Close (LLeMbas stays in the tray)": "Fermer (LLeMbas reste dans la zone de notification)", + "Close terminal": "Fermer le terminal", "Close the activity panel": "Fermer le panneau d’activité", "Command": "Commande", "Commands": "Commandes", @@ -1070,12 +1137,18 @@ const LEMBAS_I18N = { "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.", "Connect": "Se connecter", + "Connected.": "Connecté.", + "Connected. This shell is also open in another tab, and they share a size.": "Connecté. Ce shell est aussi ouvert dans un autre onglet, et ils partagent une taille.", + "Connecting…": "Connexion…", "Context": "Contexte", "Context and background tasks": "Contexte et tâches en arrière-plan", "Continue in a terminal": "Continuer dans un terminal", "Continue in a terminal…": "Continuer dans un terminal…", "Copied": "Copié", + "Copied the last of the screen, as it appeared. This shell does not mark where commands begin.": "La fin de l'écran a été copiée, telle qu'elle apparaissait. Ce shell ne marque pas où commencent les commandes.", + "Copied.": "Copié.", "Copy": "Copier", + "Copy the last command and its output": "Copier la dernière commande et sa sortie", "Counted by the model at the last reply; the categories are estimated.": "Compté par le modèle lors de la dernière réponse ; les catégories sont estimées.", "Cut short here; the whole of it is in the web interface.": "Tronqué ici ; le tout est dans l'interface web.", "Days": "Jours", @@ -1084,6 +1157,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Supprimer cette conversation ?", "Delete this scheduled task?": "Supprimer cette tâche planifiée ?", "Deny": "Refuser", + "Disconnected. Close and reopen to reconnect.": "Déconnecté. Fermez et rouvrez pour vous reconnecter.", "Done": "Terminé", "Drop the files to attach them": "Déposez les fichiers pour les joindre", "Due %(when)s": "Prévue %(when)s", @@ -1158,12 +1232,14 @@ const LEMBAS_I18N = { "Not measured.": "Non mesuré.", "Not run yet": "Pas encore exécutée", "Not yet": "Pas encore", + "Nothing has been run in this shell yet.": "Rien n'a encore été exécuté dans ce shell.", "Nothing has been said in the room yet.": "Rien n’a encore été dit dans la salle.", "Nothing is scheduled.": "Rien n’est planifié.", "Nothing matches.": "Aucun résultat.", "Nothing running.": "Rien en cours.", "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.", "Nothing to report": "Rien à signaler", + "Nothing to send: select some output first.": "Rien à envoyer : sélectionnez d'abord une partie de la sortie.", "Nothing yet.": "Rien pour l'instant.", "Notifications": "Notifications", "One chat that grows": "Une conversation qui grandit", @@ -1185,9 +1261,11 @@ const LEMBAS_I18N = { "Plan — changes nothing": "Plan — ne change rien", "Preview": "Aperçu", "Project folders": "Dossiers de projets", + "Put the last command and its output into the message box": "Mettre la dernière commande et sa sortie dans la zone de message", "Read again": "Relire", "Reasoning effort": "Effort de raisonnement", "Recommended": "Recommandé", + "Reconnecting…": "Reconnexion…", "Regenerate reply": "Régénérer la réponse", "Remove": "Retirer", "Remove %(dir)s": "Retirer %(dir)s", @@ -1213,6 +1291,7 @@ const LEMBAS_I18N = { "Search": "Rechercher", "Search chats": "Rechercher dans les conversations", "Send": "Envoyer", + "Send to chat": "Envoyer à la conversation", "Sending…": "Envoi…", "Server": "Serveur", "Set the time yourself": "Régler l'heure vous-même", @@ -1221,6 +1300,7 @@ const LEMBAS_I18N = { "Shown by the agent": "Montré par l'agent", "Sidebar": "Barre latérale", "Sign in": "Se connecter", + "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.", "Sign out": "Se déconnecter", "Signed in as %(who)s.": "Connecté en tant que %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "La déconnexion abandonne la connexion de l'application au serveur ; la liaison de l'agent reste. Déconnectez-vous pour utiliser un autre serveur.", @@ -1243,6 +1323,7 @@ const LEMBAS_I18N = { "Team: %(size)s of at most %(max)s subagents": "Équipe : %(size)s sur %(max)s sous-agents au plus", "Tell me": "Me prévenir", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Me prévenir quand une réponse ou le travail d'un agent est terminé pendant que je suis ailleurs", + "Terminal": "Terminal", "The agent asks to run %(tool)s:": "L'agent demande à exécuter %(tool)s :", "The agent asks:": "L'agent demande :", "The agent has finished.": "L'agent a terminé.", @@ -1262,11 +1343,17 @@ const LEMBAS_I18N = { "The last %(n)s runs failed: %(why)s": "Les %(n)s dernières exécutions ont échoué : %(why)s", "The model's own": "Celle du modèle", "The most an agent chat may do here": "Le maximum qu'une conversation d'agent peut faire ici", + "The next command": "La commande suivante", + "The next command (Ctrl+Shift+↓)": "La commande suivante (Ctrl+Shift+↓)", "The next runs": "Les prochaines exécutions", "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.", + "The previous command": "La commande précédente", + "The previous command (Ctrl+Shift+↑)": "La commande précédente (Ctrl+Shift+↑)", "The reply is ready.": "La réponse est prête.", "The result is no longer there.": "Le résultat n'est plus là.", "The server refused this computer's link: %(why)s": "Le serveur a refusé la liaison de cet ordinateur : %(why)s", + "The terminal could not be loaded.": "Le terminal n'a pas pu être chargé.", + "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.", "These words give no run in the future.": "Ces mots ne donnent aucune exécution à venir.", "Think before answering": "Réfléchir avant de répondre", "Thinking": "Réflexion", @@ -1279,6 +1366,7 @@ const LEMBAS_I18N = { "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 discussion.", "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.", "This server has no scheduled tasks yet.": "Ce serveur n’a pas encore de tâches planifiées.", + "This terminal closed.": "Ce terminal s'est fermé.", "Thought": "Réflexion", "Time": "Heure", "Times in %(zone)s": "Fuseau horaire : %(zone)s", @@ -1392,6 +1480,7 @@ const LEMBAS_I18N = { "Choose a model to choose its effort.": "Válasszon modellt az erőfeszítés megadásához.", "Close": "Bezárás", "Close (LLeMbas stays in the tray)": "Bezárás (a LLeMbas a tálcán marad)", + "Close terminal": "Terminál bezárása", "Close the activity panel": "A tevékenységpanel bezárása", "Command": "Parancs", "Commands": "Parancsok", @@ -1401,12 +1490,18 @@ const LEMBAS_I18N = { "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.", "Connect": "Csatlakozás", + "Connected.": "Kapcsolódva.", + "Connected. This shell is also open in another tab, and they share a size.": "Kapcsolódva. Ez a shell egy másik lapon is nyitva van, és közös a méretük.", + "Connecting…": "Kapcsolódás…", "Context": "Kontextus", "Context and background tasks": "Kontextus és háttérfeladatok", "Continue in a terminal": "Folytatás terminálban", "Continue in a terminal…": "Folytatás terminálban…", "Copied": "Másolva", + "Copied the last of the screen, as it appeared. This shell does not mark where commands begin.": "A képernyő vége átmásolva, ahogy megjelent. Ez a shell nem jelöli, hol kezdődnek a parancsok.", + "Copied.": "Másolva.", "Copy": "Másolás", + "Copy the last command and its output": "Az utolsó parancs és kimenetének másolása", "Counted by the model at the last reply; the categories are estimated.": "A modell számolta az utolsó válasznál; a kategóriák becslések.", "Cut short here; the whole of it is in the web interface.": "Itt rövidítve; a teljes a webes felületen van.", "Days": "Napok", @@ -1415,6 +1510,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Törli ezt a csevegést?", "Delete this scheduled task?": "Törli ezt az ütemezett feladatot?", "Deny": "Elutasítás", + "Disconnected. Close and reopen to reconnect.": "Kapcsolat bontva. Az újrakapcsolódáshoz zárja be és nyissa meg újra.", "Done": "Kész", "Drop the files to attach them": "Engedje el a fájlokat a csatoláshoz", "Due %(when)s": "Esedékes: %(when)s", @@ -1489,12 +1585,14 @@ const LEMBAS_I18N = { "Not measured.": "Nincs mérve.", "Not run yet": "Még nem futott", "Not yet": "Még nem", + "Nothing has been run in this shell yet.": "Ebben a shellben még semmi sem futott.", "Nothing has been said in the room yet.": "A szobában még senki nem mondott semmit.", "Nothing is scheduled.": "Nincs semmi ütemezve.", "Nothing matches.": "Nincs találat.", "Nothing running.": "Semmi sem fut.", "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.", "Nothing to report": "Nincs jelentenivaló", + "Nothing to send: select some output first.": "Nincs mit küldeni: előbb jelöljön ki valamennyi kimenetet.", "Nothing yet.": "Még semmi.", "Notifications": "Értesítések", "One chat that grows": "Egy beszélgetés, amely bővül", @@ -1516,9 +1614,11 @@ const LEMBAS_I18N = { "Plan — changes nothing": "Plan — semmit nem változtat", "Preview": "Előnézet", "Project folders": "Projektmappák", + "Put the last command and its output into the message box": "Az utolsó parancs és kimenetének beillesztése az üzenetmezőbe", "Read again": "Újraolvasás", "Reasoning effort": "Gondolkodási erőfeszítés", "Recommended": "Ajánlott", + "Reconnecting…": "Újrakapcsolódás…", "Regenerate reply": "Válasz újragenerálása", "Remove": "Eltávolítás", "Remove %(dir)s": "%(dir)s eltávolítása", @@ -1544,6 +1644,7 @@ const LEMBAS_I18N = { "Search": "Keresés", "Search chats": "Keresés a beszélgetésekben", "Send": "Küldés", + "Send to chat": "Küldés a csevegésbe", "Sending…": "Küldés…", "Server": "Kiszolgáló", "Set the time yourself": "Az időpont megadása kézzel", @@ -1552,6 +1653,7 @@ const LEMBAS_I18N = { "Shown by the agent": "Az ügynök mutatta", "Sidebar": "Oldalsáv", "Sign in": "Bejelentkezés", + "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.", "Sign out": "Kijelentkezés", "Signed in as %(who)s.": "Bejelentkezve: %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Kijelentkezéskor az alkalmazás lemond a szerveren lévő bejelentkezéséről; az ügynök kapcsolata megmarad. Más szerver használatához jelentkezzen ki.", @@ -1574,6 +1676,7 @@ const LEMBAS_I18N = { "Team: %(size)s of at most %(max)s subagents": "Csapat: %(size)s a legfeljebb %(max)s alügynökből", "Tell me": "Értesítés", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Szóljon, ha egy válasz vagy az ügynök munkája elkészül, amíg máshol vagyok", + "Terminal": "Terminál", "The agent asks to run %(tool)s:": "Az ügynök a(z) %(tool)s futtatását kéri:", "The agent asks:": "Az ügynök kérdezi:", "The agent has finished.": "Az ügynök befejezte.", @@ -1593,11 +1696,17 @@ const LEMBAS_I18N = { "The last %(n)s runs failed: %(why)s": "Az utolsó %(n)s futás sikertelen: %(why)s", "The model's own": "A modell sajátja", "The most an agent chat may do here": "A legtöbb, amit egy ügynök-csevegés itt tehet", + "The next command": "Következő parancs", + "The next command (Ctrl+Shift+↓)": "Következő parancs (Ctrl+Shift+↓)", "The next runs": "A következő futások", "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.", + "The previous command": "Előző parancs", + "The previous command (Ctrl+Shift+↑)": "Előző parancs (Ctrl+Shift+↑)", "The reply is ready.": "A válasz kész.", "The result is no longer there.": "Az eredmény már nincs meg.", "The server refused this computer's link: %(why)s": "A szerver elutasította ennek a számítógépnek a kapcsolatát: %(why)s", + "The terminal could not be loaded.": "A terminált nem sikerült betölteni.", + "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.", "These words give no run in the future.": "Ezekből a szavakból nem következik jövőbeli futás.", "Think before answering": "Gondolkodjon válasz előtt", "Thinking": "Gondolkodás", @@ -1610,6 +1719,7 @@ const LEMBAS_I18N = { "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, egy másik szerveren, a beszélgetésben megnevezve.", "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.", "This server has no scheduled tasks yet.": "Ezen a szerveren még nincsenek ütemezett feladatok.", + "This terminal closed.": "Ez a terminál bezárult.", "Thought": "Gondolkodott", "Time": "Időpont", "Times in %(zone)s": "Időzóna: %(zone)s", @@ -1723,6 +1833,7 @@ const LEMBAS_I18N = { "Choose a model to choose its effort.": "Scegli un modello per sceglierne l’impegno.", "Close": "Chiudi", "Close (LLeMbas stays in the tray)": "Chiudi (LLeMbas resta nell'area di notifica)", + "Close terminal": "Chiudi terminale", "Close the activity panel": "Chiudi il pannello attività", "Command": "Comando", "Commands": "Comandi", @@ -1732,12 +1843,18 @@ const LEMBAS_I18N = { "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.", "Connect": "Connetti", + "Connected.": "Connesso.", + "Connected. This shell is also open in another tab, and they share a size.": "Connesso. Questa shell è aperta anche in un'altra scheda, e le due condividono la dimensione.", + "Connecting…": "Connessione in corso…", "Context": "Contesto", "Context and background tasks": "Contesto e attività in background", "Continue in a terminal": "Continua in un terminale", "Continue in a terminal…": "Continua in un terminale…", "Copied": "Copiato", + "Copied the last of the screen, as it appeared. This shell does not mark where commands begin.": "Copiata l'ultima parte dello schermo, così come appariva. Questa shell non segna dove iniziano i comandi.", + "Copied.": "Copiato.", "Copy": "Copia", + "Copy the last command and its output": "Copia l'ultimo comando e il suo output", "Counted by the model at the last reply; the categories are estimated.": "Contato dal modello all’ultima risposta; le categorie sono stime.", "Cut short here; the whole of it is in the web interface.": "Troncato qui; il resto è nell'interfaccia web.", "Days": "Giorni", @@ -1746,6 +1863,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Eliminare questa chat?", "Delete this scheduled task?": "Eliminare questa attività pianificata?", "Deny": "Nega", + "Disconnected. Close and reopen to reconnect.": "Disconnesso. Chiudi e riapri per riconnetterti.", "Done": "Fatto", "Drop the files to attach them": "Rilasci i file per allegarli", "Due %(when)s": "Prevista %(when)s", @@ -1820,12 +1938,14 @@ const LEMBAS_I18N = { "Not measured.": "Non misurato.", "Not run yet": "Non ancora eseguita", "Not yet": "Non ancora", + "Nothing has been run in this shell yet.": "In questa shell non è ancora stato eseguito nulla.", "Nothing has been said in the room yet.": "Nella stanza non è ancora stato detto nulla.", "Nothing is scheduled.": "Non c’è niente di pianificato.", "Nothing matches.": "Nessun risultato.", "Nothing running.": "Nulla in esecuzione.", "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.", "Nothing to report": "Niente da segnalare", + "Nothing to send: select some output first.": "Niente da inviare: seleziona prima un po' di output.", "Nothing yet.": "Ancora niente.", "Notifications": "Notifiche", "One chat that grows": "Una chat che cresce", @@ -1847,9 +1967,11 @@ const LEMBAS_I18N = { "Plan — changes nothing": "Plan — non cambia nulla", "Preview": "Anteprima", "Project folders": "Cartelle dei progetti", + "Put the last command and its output into the message box": "Metti l'ultimo comando e il suo output nella casella del messaggio", "Read again": "Rileggi", "Reasoning effort": "Sforzo di ragionamento", "Recommended": "Consigliato", + "Reconnecting…": "Riconnessione…", "Regenerate reply": "Rigenera risposta", "Remove": "Rimuovi", "Remove %(dir)s": "Rimuovi %(dir)s", @@ -1875,6 +1997,7 @@ const LEMBAS_I18N = { "Search": "Cerca", "Search chats": "Cerca nelle chat", "Send": "Invia", + "Send to chat": "Invia alla chat", "Sending…": "Invio…", "Server": "Server", "Set the time yourself": "Imposta tu l’orario", @@ -1883,6 +2006,7 @@ const LEMBAS_I18N = { "Shown by the agent": "Mostrato dall'agente", "Sidebar": "Barra laterale", "Sign in": "Accedi", + "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.", "Sign out": "Esci", "Signed in as %(who)s.": "Accesso effettuato come %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Uscendo, l'app rinuncia al suo accesso sul server; il collegamento dell'agente resta. Esca per usare un altro server.", @@ -1905,6 +2029,7 @@ const LEMBAS_I18N = { "Team: %(size)s of at most %(max)s subagents": "Squadra: %(size)s su un massimo di %(max)s sottoagenti", "Tell me": "Avvisami", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Avvisami quando una risposta o il lavoro di un agente è finito mentre sono altrove", + "Terminal": "Terminale", "The agent asks to run %(tool)s:": "L'agente chiede di eseguire %(tool)s:", "The agent asks:": "L'agente chiede:", "The agent has finished.": "L'agente ha finito.", @@ -1924,11 +2049,17 @@ const LEMBAS_I18N = { "The last %(n)s runs failed: %(why)s": "Le ultime %(n)s esecuzioni non sono riuscite: %(why)s", "The model's own": "Quello del modello", "The most an agent chat may do here": "Il massimo che una chat agente può fare qui", + "The next command": "Il comando successivo", + "The next command (Ctrl+Shift+↓)": "Il comando successivo (Ctrl+Shift+↓)", "The next runs": "Le prossime esecuzioni", "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.", + "The previous command": "Il comando precedente", + "The previous command (Ctrl+Shift+↑)": "Il comando precedente (Ctrl+Shift+↑)", "The reply is ready.": "La risposta è pronta.", "The result is no longer there.": "Il risultato non c’è più.", "The server refused this computer's link: %(why)s": "Il server ha rifiutato il collegamento di questo computer: %(why)s", + "The terminal could not be loaded.": "Impossibile caricare il terminale.", + "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.", "These words give no run in the future.": "Queste parole non danno nessuna esecuzione futura.", "Think before answering": "Ragiona prima di rispondere", "Thinking": "Ragionamento", @@ -1941,6 +2072,7 @@ const LEMBAS_I18N = { "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.", "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ì.", "This server has no scheduled tasks yet.": "Questo server non ha ancora attività pianificate.", + "This terminal closed.": "Questo terminale si è chiuso.", "Thought": "Ha ragionato", "Time": "Ora", "Times in %(zone)s": "Fuso orario: %(zone)s", @@ -2054,6 +2186,7 @@ const LEMBAS_I18N = { "Choose a model to choose its effort.": "Wybierz model, aby wybrać jego wysiłek.", "Close": "Zamknij", "Close (LLeMbas stays in the tray)": "Zamknij (LLeMbas zostaje w zasobniku systemowym)", + "Close terminal": "Zamknij terminal", "Close the activity panel": "Zamknij panel aktywności", "Command": "Polecenie", "Commands": "Polecenia", @@ -2063,12 +2196,18 @@ const LEMBAS_I18N = { "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.", "Connect": "Połącz", + "Connected.": "Połączono.", + "Connected. This shell is also open in another tab, and they share a size.": "Połączono. Ta powłoka jest otwarta też w innej karcie i dzielą one rozmiar.", + "Connecting…": "Łączenie…", "Context": "Kontekst", "Context and background tasks": "Kontekst i zadania w tle", "Continue in a terminal": "Kontynuuj w terminalu", "Continue in a terminal…": "Kontynuuj w terminalu…", "Copied": "Skopiowano", + "Copied the last of the screen, as it appeared. This shell does not mark where commands begin.": "Skopiowano koniec ekranu tak, jak wyglądał. Ta powłoka nie oznacza, gdzie zaczynają się polecenia.", + "Copied.": "Skopiowano.", "Copy": "Kopiuj", + "Copy the last command and its output": "Kopiuj ostatnie polecenie i jego wynik", "Counted by the model at the last reply; the categories are estimated.": "Policzone przez model przy ostatniej odpowiedzi; kategorie są szacunkowe.", "Cut short here; the whole of it is in the web interface.": "Tu skrócone; całość jest w interfejsie WWW.", "Days": "Dni", @@ -2077,6 +2216,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Usunąć ten czat?", "Delete this scheduled task?": "Usunąć to zaplanowane zadanie?", "Deny": "Odrzuć", + "Disconnected. Close and reopen to reconnect.": "Rozłączono. Aby połączyć ponownie, zamknij i otwórz ponownie.", "Done": "Gotowe", "Drop the files to attach them": "Upuść pliki, aby je dołączyć", "Due %(when)s": "Zaplanowane na %(when)s", @@ -2151,12 +2291,14 @@ const LEMBAS_I18N = { "Not measured.": "Nie zmierzono.", "Not run yet": "Jeszcze nie uruchomione", "Not yet": "Jeszcze nie", + "Nothing has been run in this shell yet.": "W tej powłoce nic jeszcze nie uruchomiono.", "Nothing has been said in the room yet.": "W pokoju nikt jeszcze nic nie powiedział.", "Nothing is scheduled.": "Nic nie jest zaplanowane.", "Nothing matches.": "Nic nie pasuje.", "Nothing running.": "Nic nie działa.", "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ą.", "Nothing to report": "Nic do zgłoszenia", + "Nothing to send: select some output first.": "Nie ma czego wysłać: najpierw należy zaznaczyć jakiś wynik.", "Nothing yet.": "Jeszcze nic.", "Notifications": "Powiadomienia", "One chat that grows": "Jeden czat, który rośnie", @@ -2178,9 +2320,11 @@ const LEMBAS_I18N = { "Plan — changes nothing": "Plan — niczego nie zmienia", "Preview": "Podgląd", "Project folders": "Foldery projektów", + "Put the last command and its output into the message box": "Wstaw ostatnie polecenie i jego wynik do pola wiadomości", "Read again": "Wczytaj ponownie", "Reasoning effort": "Poziom rozumowania", "Recommended": "Zalecane", + "Reconnecting…": "Ponowne łączenie…", "Regenerate reply": "Wygeneruj odpowiedź ponownie", "Remove": "Usuń", "Remove %(dir)s": "Usuń %(dir)s", @@ -2206,6 +2350,7 @@ const LEMBAS_I18N = { "Search": "Szukaj", "Search chats": "Szukaj w czatach", "Send": "Wyślij", + "Send to chat": "Wyślij do czatu", "Sending…": "Wysyłanie…", "Server": "Serwer", "Set the time yourself": "Ustaw czas samodzielnie", @@ -2214,6 +2359,7 @@ const LEMBAS_I18N = { "Shown by the agent": "Pokazane przez agenta", "Sidebar": "Pasek boczny", "Sign in": "Zaloguj się", + "Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.", "Sign out": "Wyloguj się", "Signed in as %(who)s.": "Zalogowano jako %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Wylogowanie zrzeka się logowania aplikacji na serwerze; połączenie agenta zostaje. Wyloguj się, aby użyć innego serwera.", @@ -2236,6 +2382,7 @@ const LEMBAS_I18N = { "Team: %(size)s of at most %(max)s subagents": "Zespół: %(size)s z najwyżej %(max)s podagentów", "Tell me": "Powiadamiaj", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Powiadom mnie, gdy odpowiedź lub praca agenta jest gotowa, a ja jestem gdzie indziej", + "Terminal": "Terminal", "The agent asks to run %(tool)s:": "Agent prosi o uruchomienie %(tool)s:", "The agent asks:": "Agent pyta:", "The agent has finished.": "Agent skończył.", @@ -2255,11 +2402,17 @@ const LEMBAS_I18N = { "The last %(n)s runs failed: %(why)s": "Ostatnie uruchomienia nie powiodły się (%(n)s): %(why)s", "The model's own": "Domyślne modelu", "The most an agent chat may do here": "Najwięcej, co czat agenta może tu zrobić", + "The next command": "Następne polecenie", + "The next command (Ctrl+Shift+↓)": "Następne polecenie (Ctrl+Shift+↓)", "The next runs": "Następne uruchomienia", "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.", + "The previous command": "Poprzednie polecenie", + "The previous command (Ctrl+Shift+↑)": "Poprzednie polecenie (Ctrl+Shift+↑)", "The reply is ready.": "Odpowiedź jest gotowa.", "The result is no longer there.": "Wyniku już nie ma.", "The server refused this computer's link: %(why)s": "Serwer odrzucił połączenie tego komputera: %(why)s", + "The terminal could not be loaded.": "Nie udało się wczytać terminala.", + "The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminal wymaga nowszego serwera LLeMbas. Poproś jego administratora o aktualizację.", "These words give no run in the future.": "Z tych słów nie wynika żadne przyszłe uruchomienie.", "Think before answering": "Rozumuj przed odpowiedzią", "Thinking": "Rozumowanie", @@ -2272,6 +2425,7 @@ const LEMBAS_I18N = { "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, nazwanego w czacie.", "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.", "This server has no scheduled tasks yet.": "Ten serwer nie ma jeszcze zaplanowanych zadań.", + "This terminal closed.": "Ten terminal został zamknięty.", "Thought": "Rozumowanie", "Time": "Godzina", "Times in %(zone)s": "Strefa czasowa: %(zone)s", @@ -2385,6 +2539,7 @@ const LEMBAS_I18N = { "Choose a model to choose its effort.": "Vyberte model, aby ste mohli zvoliť jeho náročnosť.", "Close": "Zavrieť", "Close (LLeMbas stays in the tray)": "Zavrieť (LLeMbas zostane v oznamovacej oblasti)", + "Close terminal": "Zavrieť terminál", "Close the activity panel": "Zavrieť panel aktivity", "Command": "Príkaz", "Commands": "Príkazy", @@ -2394,12 +2549,18 @@ const LEMBAS_I18N = { "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.", "Connect": "Pripojiť", + "Connected.": "Pripojené.", + "Connected. This shell is also open in another tab, and they share a size.": "Pripojené. Tento shell je otvorený aj v inej karte a zdieľajú veľkosť.", + "Connecting…": "Pripája sa…", "Context": "Kontext", "Context and background tasks": "Kontext a úlohy na pozadí", "Continue in a terminal": "Pokračovať v termináli", "Continue in a terminal…": "Pokračovať v termináli…", "Copied": "Skopírované", + "Copied the last of the screen, as it appeared. This shell does not mark where commands begin.": "Skopírovaný koniec obrazovky, tak ako vyzeral. Tento shell neoznačuje, kde sa príkazy začínajú.", + "Copied.": "Skopírované.", "Copy": "Kopírovať", + "Copy the last command and its output": "Skopírovať posledný príkaz a jeho výstup", "Counted by the model at the last reply; the categories are estimated.": "Spočítal model pri poslednej odpovedi; kategórie sú odhadnuté.", "Cut short here; the whole of it is in the web interface.": "Tu skrátené; celé je to vo webovom rozhraní.", "Days": "Dni", @@ -2408,6 +2569,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Zmazať túto konverzáciu?", "Delete this scheduled task?": "Odstrániť túto naplánovanú úlohu?", "Deny": "Odmietnuť", + "Disconnected. Close and reopen to reconnect.": "Odpojené. Zavrite a znova otvorte, aby sa pripojilo.", "Done": "Hotovo", "Drop the files to attach them": "Pustite súbory a priložia sa", "Due %(when)s": "Naplánované na %(when)s", @@ -2482,12 +2644,14 @@ const LEMBAS_I18N = { "Not measured.": "Nezmerané.", "Not run yet": "Ešte nebežala", "Not yet": "Ešte nie", + "Nothing has been run in this shell yet.": "V tomto shelli sa zatiaľ nič nespustilo.", "Nothing has been said in the room yet.": "V miestnosti zatiaľ nikto nič nepovedal.", "Nothing is scheduled.": "Nič nie je naplánované.", "Nothing matches.": "Nič nezodpovedá.", "Nothing running.": "Nič nebeží.", "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ú.", "Nothing to report": "Nič na hlásenie", + "Nothing to send: select some output first.": "Nie je čo odoslať: najprv označte časť výstupu.", "Nothing yet.": "Zatiaľ nič.", "Notifications": "Upozornenia", "One chat that grows": "Jedna konverzácia, ktorá rastie", @@ -2509,9 +2673,11 @@ const LEMBAS_I18N = { "Plan — changes nothing": "Plan — nič nemení", "Preview": "Náhľad", "Project folders": "Priečinky projektov", + "Put the last command and its output into the message box": "Vložiť posledný príkaz a jeho výstup do políčka správy", "Read again": "Načítať znova", "Reasoning effort": "Náročnosť uvažovania", "Recommended": "Odporúčané", + "Reconnecting…": "Znova sa pripája…", "Regenerate reply": "Vygenerovať odpoveď znova", "Remove": "Odstrániť", "Remove %(dir)s": "Odstrániť %(dir)s", @@ -2537,6 +2703,7 @@ const LEMBAS_I18N = { "Search": "Hľadať", "Search chats": "Hľadať v konverzáciách", "Send": "Poslať", + "Send to chat": "Poslať do konverzácie", "Sending…": "Odosiela sa…", "Server": "Server", "Set the time yourself": "Nastaviť čas sami", @@ -2545,6 +2712,7 @@ const LEMBAS_I18N = { "Shown by the agent": "Ukázal agent", "Sidebar": "Bočný panel", "Sign in": "Prihlásiť sa", + "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.", "Sign out": "Odhlásiť sa", "Signed in as %(who)s.": "Prihlásený ako %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlásením sa prihlásenie aplikácie na serveri zruší; prepojenie agenta zostane. Ak chcete použiť iný server, odhláste sa.", @@ -2567,6 +2735,7 @@ const LEMBAS_I18N = { "Team: %(size)s of at most %(max)s subagents": "Tím: %(size)s z najviac %(max)s podagentov", "Tell me": "Upozorniť ma", "Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozorniť ma, keď je odpoveď alebo práca agenta hotová, kým som inde", + "Terminal": "Terminál", "The agent asks to run %(tool)s:": "Agent žiada spustiť %(tool)s:", "The agent asks:": "Agent sa pýta:", "The agent has finished.": "Agent dokončil prácu.", @@ -2586,11 +2755,17 @@ const LEMBAS_I18N = { "The last %(n)s runs failed: %(why)s": "Posledné spustenia zlyhali (%(n)s): %(why)s", "The model's own": "Podľa modelu", "The most an agent chat may do here": "Najviac, čo tu agentská konverzácia smie", + "The next command": "Nasledujúci príkaz", + "The next command (Ctrl+Shift+↓)": "Nasledujúci príkaz (Ctrl+Shift+↓)", "The next runs": "Ďalšie spustenia", "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.", + "The previous command": "Predchádzajúci príkaz", + "The previous command (Ctrl+Shift+↑)": "Predchádzajúci príkaz (Ctrl+Shift+↑)", "The reply is ready.": "Odpoveď je hotová.", "The result is no longer there.": "Výsledok už tu nie je.", "The server refused this computer's link: %(why)s": "Server odmietol prepojenie tohto počítača: %(why)s", + "The terminal could not be loaded.": "Terminál sa nepodarilo načítať.", + "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.", "These words give no run in the future.": "Z týchto slov nevychádza žiadne budúce spustenie.", "Think before answering": "Premýšľať pred odpoveďou", "Thinking": "Premýšľa", @@ -2603,6 +2778,7 @@ const LEMBAS_I18N = { "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.", "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.", "This server has no scheduled tasks yet.": "Tento server zatiaľ nemá naplánované úlohy.", + "This terminal closed.": "Tento terminál sa zatvoril.", "Thought": "Premýšľal", "Time": "Čas", "Times in %(zone)s": "Časové pásmo: %(zone)s", diff --git a/ui/index.html b/ui/index.html index 3d9d3be..d21385f 100644 --- a/ui/index.html +++ b/ui/index.html @@ -135,6 +135,26 @@ + +