diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 9a5839b93..4fd268060 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -25,11 +25,10 @@ on: permissions: contents: read -# Off until a first build has been checked by hand: run this workflow manually, -# download the Coucou-Linux artifact, try it, then set this to 'true' and push a -# linux-v* tag. +# On: a linux-v* tag publishes. Run this workflow by hand first and try the +# Coucou-Linux artifact; set this to 'false' to build tags without publishing. env: - PUBLISH: 'false' + PUBLISH: 'true' jobs: build: diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 146694ea6..05729f246 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -12,12 +12,13 @@ on: permissions: contents: write -# Publishing is paused: Defender flags the unsigned installer as -# Trojan:Win32/Wacatac.H!ml (false positive, reported to Microsoft). Tags still -# build, to check the app compiles, but nothing is uploaded. Set this back to -# 'true' once the installer is signed or the detection is cleared. +# Publishing is on: Microsoft reviewed the Defender false positive +# (Trojan:Win32/Wacatac.H!ml) and removed the detection. Before pushing a +# windows-v* tag, run this workflow by hand and scan the artifacts with Defender: +# a new unsigned build can still trip its machine-learning model. Set this to +# 'false' to build tags without publishing. env: - PUBLISH: 'false' + PUBLISH: 'true' jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6992a6cd0..a52108f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Windows and Linux 0.2.0 — unreleased + +The Windows and Linux app catches up with the Mac, from 0.1.1 to 0.2.1 — everything except Apple Music and the iPhone, which depend on macOS and iCloud. + +- **Agents**: Codex, GitHub Copilot CLI and Muse Code sessions with Allow / Deny in the island; Gemini CLI, Antigravity, Cursor Agent, OpenCode, Amp and Hermes sessions on their own pills; Claude Code sessions from the Claude app on Windows. Install them all from Settings → Agents, which shows the diff and takes a dated backup before writing — one hardened writer for every agent's config (#278 by @Totopo27, #298 by @kobaltgit, #231 by @BeyondBirthday07) +- **Questions**: answer Claude Code's multiple-choice questions from the island; answers are checked against the questions asked, and a question answered in the terminal takes the card down (#216 by @PythonTilk) +- **The permission card** comes up for every agent, brings its pill forward, can be folded without answering, and never decides on its own +- **Chat**: Anthropic, Google AI, OpenAI and OpenRouter, switchable by clicking the model name; local models through Ollama, LM Studio or any OpenAI-compatible server, streamed, thinking hidden; Markdown answers with a copy button; full answers; your first name in the greeting; `COUCOU_ANTHROPIC_BASE_URL` for a gateway, https only (#161 by @4rchila, #166 by @AlphaIsYour, #173 by @AinzDerErste, #206 by @Totopo27) +- **Plan usage**: Claude's 5-hour and weekly limits and Codex's, in the island header (#171 by @AinzDerErste) +- **Live diff**: each file Claude edits shows in the ticker with its +N −M, and a click opens the diff; the finished card shows Claude's final message +- **GitHub**: your pull requests with their CI, reviews waiting for you, the CI of your default branches, alerts when CI turns red or green, and your contribution grid +- **Mochi**: the wardrobe and seasonal outfits, the new greeting and its sound, and Mochi on the desktop (Windows, X11 and layer-shell compositors) +- **Keyboard shortcuts** from anywhere, changeable in Settings → Shortcuts; the defaults never type an AltGr character on French, German, Spanish, Italian or Portuguese keyboards +- **Weekly recap** on Monday mornings, shareable as an image; history stays on your computer +- **Pills**: declare the tools you use and pick your main one; hook-based pills no longer ask for a key; "Open terminal" brings the session's own window forward on Windows +- **10 languages**: English, 中文, हिन्दी, Español, العربية, Français, বাংলা, Português (Brasil), Русский, Bahasa Indonesia — Settings → General → Language (#228; picker from #226 by @alexisrja) +- **File drop** works from every Explorer view, and Cancel works (#240 by @KauaDc, #126); only files a real drop delivered can be read +- **Linux**: auto-close on KDE/Wayland and GNOME (#160 by @4rchila, #136), the island at the top on GNOME (#149 by @betodoescher), pinned to its display on Hyprland and Sway with a display picker (#227 by @chuxclay), GNOME large text no longer cuts the island (#122), an Arch Linux PKGBUILD (#299 by @FabioLukas123, #230) +- The step ticker no longer stops at a session's 20th step (#265 by @PythonTilk), ticker steps keep their own line (from #203 by @shakibbinkabir), `tauri dev` no longer crashes on EBUSY (#202 by @Andrev-91) + ## 0.2.1 — October 7, 2026 - **Hermes Agent** (Nous Research, open-source): sessions appear in the notch — live tool steps, the final response when done, and the platform (Telegram, Discord…) when running via the gateway. Install from Settings → Agents → Hermes: it writes a small Python plugin to `~/.hermes/plugins/coucou/` and enables it in `~/.hermes/config.yaml`, with the same preview, backup and confirmation flow as other agents *(macOS, GitHub build)* (#288) diff --git a/README.md b/README.md index 577d9ff8e..812669fe2 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ macOS releases are published as `v*` tags. See [CHANGELOG.md](CHANGELOG.md) for | [0.1.1](https://github.com/Louis-CFM/coucou/releases/tag/v0.1.1) | Oct 2, 2026 | Gemini and OpenAI chat, Linux build, more agents and pills, security hardening | | [0.1.0](https://github.com/Louis-CFM/coucou/releases/tag/v0.1.0) | Sep 27, 2026 | First release: Mochi, Claude Code sessions, chat, file drop, integrations | -Windows 0.1.1 and Linux 0.1.1 (beta) are in Releases under the `windows-v*` and `linux-v*` tags. +Windows and Linux builds are in Releases under the `windows-v*` and `linux-v*` tags; the newest Windows installer is always at [`windows-latest`](https://github.com/Louis-CFM/coucou/releases/tag/windows-latest). ## Demo mode @@ -148,10 +148,9 @@ Everything else, troubleshooting included, is in [docs/IPHONE.md](docs/IPHONE.md ### Windows -The Windows installer is **temporarily unavailable**. Microsoft Defender wrongly -flags the unsigned installer as malware; a false-positive report is under review -at Microsoft and the installer will come back once it is cleared and signed. -Until then you can [build it from source](#build-from-source). +Download **[Coucou-Windows.msi](https://github.com/Louis-CFM/coucou/releases/download/windows-latest/Coucou-Windows.msi)** or **[Coucou-Windows-setup.exe](https://github.com/Louis-CFM/coucou/releases/download/windows-latest/Coucou-Windows-setup.exe)** (Windows 10/11, always the newest version) and run it. The installer isn't code-signed yet, so SmartScreen may warn you: click **More info → Run anyway**. You can also [build it from source](#build-from-source). + +**Windows and Linux 0.2.0** catch up with the Mac: Codex, Copilot CLI, Muse Code and six more agents with approvals from the island, answers to Claude's questions, live diffs, GitHub pull requests and CI, plan usage, local models, the wardrobe, Mochi on the desktop, keyboard shortcuts, the weekly recap and 10 languages. See the [changelog](CHANGELOG.md). There is no notch on a PC, so the island slides out of the top edge of the screen instead of hiding inside one. See [`windows/README.md`](windows/README.md) for the @@ -159,17 +158,17 @@ rest of the differences. ### Linux -The first Linux build is out as a beta: download it from [Coucou for Linux 0.1.1 (beta)](https://github.com/Louis-CFM/coucou/releases/tag/linux-v0.1.1), x86_64 only for now. Later versions will be in [Releases](https://github.com/Louis-CFM/coucou/releases) under `linux-v*` tags. +Download the newest Linux build from [Releases](https://github.com/Louis-CFM/coucou/releases) (`linux-v*` tags), x86_64 only for now. - **AppImage** (any distribution): `chmod +x Coucou-Linux-*.AppImage`, then run it. - **Debian / Ubuntu**: `sudo apt install ./Coucou-Linux-*.deb` - **Fedora / openSUSE**: `sudo dnf install ./Coucou-Linux-*.rpm` -Check a download with `sha256sum -c SHA256SUMS --ignore-missing`. Gemini CLI, Antigravity, Google AI, OpenAI and local model (Ollama / LM Studio) chat are macOS only for now. +Check a download with `sha256sum -c SHA256SUMS --ignore-missing`. On Arch Linux, build it with the [PKGBUILD](linux/arch/PKGBUILD). The island sits on the top edge on compositors with layer-shell — COSMIC, KDE Plasma, Hyprland, Sway and other wlroots compositors. GNOME has no layer-shell, -so there it opens as a regular window. See [`windows/README.md`](windows/README.md#linux). +so there it runs through XWayland as a dock window at the top of the screen. See [`windows/README.md`](windows/README.md#linux). ### Build from source @@ -243,7 +242,7 @@ If Coucou isn't running, the hook exits immediately: **Claude Code is never bloc | Hermes | Plugin — **Settings → Agents → Hermes → Install** | Mac only | | Any other | `--agent ` flag; see [`docs/AGENTS.md`](docs/AGENTS.md) | No | -OpenCode, Amp and Hermes use a plugin model rather than a hook command. Coucou installs the plugin file into `~/.config/opencode/plugins/`, `~/.config/amp/plugins/` and `~/.hermes/plugins/coucou/` respectively — these are macOS paths; the Windows/Linux build does not support them. +OpenCode, Amp and Hermes use a plugin model rather than a hook command. Coucou installs the plugin file into `~/.config/opencode/plugins/`, `~/.config/amp/plugins/` and `~/.hermes/plugins/coucou/` respectively. On Windows and Linux the plugins start the `coucou-hook` relay directly — see [`windows/README.md`](windows/README.md#supported-agents). ## Things to try diff --git a/linux/arch/PKGBUILD b/linux/arch/PKGBUILD new file mode 100644 index 000000000..f02432474 --- /dev/null +++ b/linux/arch/PKGBUILD @@ -0,0 +1,69 @@ +# Maintainer: FabioLukas123 +# Coucou for Arch Linux — built from the Tauri app in windows/, which runs on +# Windows and Linux alike. + +pkgname=coucou-git +_pkgname=coucou +pkgver=0.2.0 +pkgrel=1 +pkgdesc="Mochi lives at the top of your screen and keeps an eye on your Claude Code sessions" +arch=('x86_64' 'aarch64') +url="https://github.com/Louis-CFM/coucou" +license=("MIT" "LicenseRef-coucou-assets") +depends=( + 'webkit2gtk-4.1' + 'gtk3' + 'gtk-layer-shell' + 'libayatana-appindicator' + 'gst-plugins-base' + 'gst-plugins-good' + 'dbus' + 'xdg-utils' + 'hicolor-icon-theme' +) +optdepends=( + 'gnome-keyring: store API keys (or any other Secret Service provider)' + 'kwallet: store API keys (or any other Secret Service provider)' +) +makedepends=('git' 'cargo' 'nodejs' 'npm') +provides=("$_pkgname") +conflicts=("$_pkgname") +# makepkg's default LTO and debug flags make the Rust build very slow. +options=('!lto' '!debug') +source=("$_pkgname::git+https://github.com/Louis-CFM/coucou.git") +sha256sums=('SKIP') + +pkgver() { + cd "$_pkgname" + local v + v=$(sed -n 's/^version = "\(.*\)"/\1/p' windows/Cargo.toml | head -1) + printf "%s.r%s.%s" "$v" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)" +} + +prepare() { + cd "$_pkgname/windows" + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" + npm ci +} + +build() { + cd "$_pkgname/windows" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR="$PWD/target" + # beforeBuildCommand builds the front end and the coucou-hook relay. + npx tauri build --no-bundle +} + +package() { + cd "$_pkgname" + install -Dm755 windows/target/release/coucou "$pkgdir/usr/bin/coucou" + # Found next to the app, then copied into ~/.local/share/coucou/bin at launch. + install -Dm755 windows/target/release/coucou-hook "$pkgdir/usr/bin/coucou-hook" + install -Dm644 linux/arch/coucou.desktop "$pkgdir/usr/share/applications/coucou.desktop" + install -Dm644 windows/src-tauri/icons/32x32.png "$pkgdir/usr/share/icons/hicolor/32x32/apps/coucou.png" + install -Dm644 windows/src-tauri/icons/128x128.png "$pkgdir/usr/share/icons/hicolor/128x128/apps/coucou.png" + install -Dm644 windows/src-tauri/icons/128x128@2x.png "$pkgdir/usr/share/icons/hicolor/256x256/apps/coucou.png" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + install -Dm644 LICENSE-ASSETS.md "$pkgdir/usr/share/licenses/$pkgname/LICENSE-ASSETS.md" +} diff --git a/linux/arch/coucou.desktop b/linux/arch/coucou.desktop new file mode 100644 index 000000000..c38778d96 --- /dev/null +++ b/linux/arch/coucou.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Coucou +Comment=Mochi lives at the top of your screen and keeps an eye on your Claude Code sessions +Exec=coucou +Icon=coucou +Terminal=false +Categories=Development;Utility; +StartupNotify=false diff --git a/windows/Cargo.lock b/windows/Cargo.lock index 6278c7525..777a49ea2 100644 --- a/windows/Cargo.lock +++ b/windows/Cargo.lock @@ -610,7 +610,7 @@ dependencies = [ [[package]] name = "coucou" -version = "0.1.1" +version = "0.2.0" dependencies = [ "gtk", "keyring", @@ -621,14 +621,17 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-global-shortcut", "tauri-plugin-single-instance", "tokio", + "webview2-com", "windows 0.61.3", + "windows-core 0.62.2", ] [[package]] name = "coucou-hook" -version = "0.1.1" +version = "0.2.0" dependencies = [ "libc", "serde_json", @@ -1366,6 +1369,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1490,6 +1503,24 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types 0.7.0", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.21", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -2068,6 +2099,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.2", + "serde", + "unicode-segmentation", +] + [[package]] name = "keyboard-types" version = "0.8.3" @@ -2255,7 +2297,7 @@ dependencies = [ "crossbeam-channel", "dpi", "gtk", - "keyboard-types", + "keyboard-types 0.8.3", "objc2", "objc2-app-kit", "objc2-core-foundation", @@ -4048,6 +4090,21 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd2e1f725891a1613af8f25bf918ba3357654b0fd3f2d794c0ce76cbdd92ed2" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.5.0" @@ -5447,6 +5504,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xdg-home" version = "1.3.0" @@ -5457,6 +5531,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.3" diff --git a/windows/Cargo.toml b/windows/Cargo.toml index 9041a677f..ab5711c91 100644 --- a/windows/Cargo.toml +++ b/windows/Cargo.toml @@ -3,7 +3,7 @@ members = ["src-tauri", "hook"] resolver = "2" [workspace.package] -version = "0.1.1" +version = "0.2.0" edition = "2021" license = "MIT" diff --git a/windows/README.md b/windows/README.md index 2b897bd3d..b999b701b 100644 --- a/windows/README.md +++ b/windows/README.md @@ -21,13 +21,18 @@ Approve Claude Code permissions, watch your session work, drop a file, chat with ## Install -The downloadable installer is **temporarily unavailable**. Microsoft Defender -wrongly flags the unsigned installer as malware (`Trojan:Win32/Wacatac.H!ml`, a -machine-learning false positive). A report is under review at Microsoft, and the -installer will be published again once it is cleared and code-signed. +Download **[Coucou-Windows.msi](https://github.com/Louis-CFM/coucou/releases/download/windows-latest/Coucou-Windows.msi)** +(Windows Installer) or **[Coucou-Windows-setup.exe](https://github.com/Louis-CFM/coucou/releases/download/windows-latest/Coucou-Windows-setup.exe)**, +always the newest version, and run it. It installs for the current user only — no admin prompt. -Until then, [build it yourself](#build-it-yourself): it takes a few minutes and -installs for the current user only — no admin prompt. +The installer is not code-signed yet, so Windows SmartScreen may say "Windows +protected your PC": click **More info → Run anyway**. Microsoft Defender once +flagged the installer by mistake (`Trojan:Win32/Wacatac.H!ml`, a machine-learning +false positive); Microsoft reviewed it and removed the detection. If Defender +still blocks it on your PC, update its definitions (`Update-MpSignature` in +PowerShell) and try again. + +You can also [build it yourself](#build-it-yourself). ## Using it @@ -43,14 +48,85 @@ installs for the current user only — no admin prompt. | Click the small island | It opens | | Click Mochi | It gets annoyed. Three times in a row and it goes dizzy | | Rest the pointer on Mochi for two seconds | Hearts | +| Right-click Mochi | The wardrobe: rest the pointer on an outfit to try it on, click to keep it. **Auto** dresses him for the season (witch hat in October, Santa hat in December…) | +| Drag Mochi out of the island | He moves onto your desktop and hangs out there, in his outfit, watching your cursor. Drop him back on the island to bring him home | +| On the desktop: click / right-click / double-click Mochi | Poke him / the wardrobe / he flies home. Drag him to move him | | Drag a file onto the island | Mochi turns into a box, swallows it, then offers to answer questions about it | +| Click a file in the session ticker | Its diff opens in the island; ↗ opens the file in VS Code, ‹ or `Esc` goes back | | `Esc` | Closes the island | -| Tray icon | Open, Settings…, Pause, Quit | +| Tray icon | Open, Weekly recap, Wardrobe…, Settings…, Pause, Quit | +| `Ctrl+Alt+Space` | Opens the chat, from any app | +| `Ctrl+Alt+A` | Jumps to the waiting permission or question | +| `Ctrl+Alt+T` | Brings the session's window forward ("Open terminal") | +| `Ctrl+Alt+→` / `Ctrl+Alt+←` | Next / previous pill | +| `Ctrl+Alt+S` | Mutes or unmutes Mochi | +| `Ctrl+Alt+G` | Opens the wardrobe | +| `Ctrl+Alt+N` | Opens and closes the island (off until you turn it on) | +| In the open island: `Ctrl+→` `Ctrl+←`, `Ctrl+1`–`Ctrl+9` | Switch pills | +| In the open island: `Ctrl+Enter`, `Ctrl+K` | Send, start a new chat | +| In the open island: `Ctrl+,`, `Ctrl+P` | Settings, keep the island open | + +### Keyboard shortcuts + +Every global shortcut can be changed or turned off in **Settings… → Shortcuts**: +click it and press the new keys. A combination another app already holds is +flagged *In use*, and two Coucou shortcuts on the same keys are flagged *Used +twice*. While you record a new one, Coucou lets go of its own so the keys reach +the recorder. + +The defaults are not the Mac's `⌃⌥` letters. On Windows, `Ctrl+Alt` is `AltGr`, +so a global `Ctrl+Alt+E` would swallow every `€` typed on a French or German +keyboard. The defaults were checked against the AltGr layer of the French, +German, Spanish, Italian, Portuguese and Brazilian (ABNT2) layouts — that is why +pill switching uses the arrows rather than `[` `]`, and mute is `S` rather than +`M` (`AltGr+M` is `µ` in German). On top of that, Coucou asks Windows what each +`Ctrl+Alt` combination types on the layouts you have installed and leaves any +that types a character unregistered, flagged *Types “ą”* in Settings: Polish, +for one, puts `ą` on `AltGr+A` and `ś` on `AltGr+S`. The recorder refuses such +a combination too. + +Older Intel graphics drivers rotate the screen on `Ctrl+Alt+←` / `→`; if yours +still does, those two show up as *In use*. Everything else happens on its own: a Claude Code permission request opens the -island with **Deny / Allow**, a finished session shows what it did, and +island with **Deny / Allow**, a question from Claude Code shows its options to +pick from, a finished session shows what it did, and your integrations sit in the coloured pills next to Mochi. +A permission card or a question stays until you answer it: the mouse leaving +never folds it, it comes up even when the island is already open or another +pill is in front, and the pill you were on comes back once you answer. To keep +it for later, fold it with the **⌃** in its corner (or `Esc` in the island): the +island shrinks to its compact size and stays on screen, nothing is answered, and +opening it again shows the card. **Open terminal** brings the window the session +runs in to the front. + +When Mochi lives on the desktop, he flies back to the island with a permission +request or a question and returns to his spot once you have answered; he does a +little jump when a task finishes, and dozes off when nothing has happened for +two minutes and your cursor is elsewhere — asleep, he costs nothing: no cursor +polling, a few frames a second. He remembers his spot between launches; if it +was on a display that is no longer connected, he stays in the island. + +**Live diff.** Every file Claude edits (Edit, MultiEdit, Write) shows up in the +session ticker with its **+N −M** lines; click it for the diff. Same limits as the +Mac: past 200 KB or 4 000 lines only the counts are kept, at most 50 diffs per +session, and they are forgotten an hour after the last edit or when the session +ends. When Claude finishes, the card keeps the first paragraph of its final +answer on one line, still, until the next prompt. + +## Your pills + +**Settings… → Active pills** lists the tools you use, from the same catalog as +the Mac app. Pick your **main tool** — VS Code, Cursor, Codex or Antigravity — +which is always there and doesn't take a slot, then declare up to four more: +agents (Gemini CLI, Copilot CLI, Muse Code, OpenCode, Amp, Hermes, Claude +Desktop), the chat providers (Anthropic, Google AI, OpenAI, Ollama, LM Studio), +and the services under **Integrations**. A pill fed by hooks says whether its +hooks are installed, never asks for a key; a local model server's pill says +whether the chat is connected to it. A session on a pill you didn't +declare still shows up, for as long as it runs. + ## Claude Code The settings window @@ -68,15 +144,128 @@ in time, Coucou stays quiet and Claude Code asks in the terminal as usual. It works from any terminal — Windows Terminal, PowerShell, VS Code, Git Bash. +### Plan usage + +As on the Mac, the island's header can show your plan limits: a small pill +("Claude 73%", green below 50 %, orange up to 80 %, red above) for the 5-hour and +weekly Claude limits, and another for Codex. Click one for the details and the +reset times. Both are off by default; turn them on in **Settings… → Plan usage**. + +- **Claude** (Pro and Max plans): the numbers come from Claude Code's own status + line. **Show in notch** first shows you the diff of the `statusLine` change in + `%USERPROFILE%\.claude\settings.json`, takes a dated backup and writes only + after your click, with the same writer as the hooks: the status line becomes `coucou-hook --statusline`, which + passes only the limits on (300 ms at most) and runs the status line you had + before — kept in `statusline-previous.json` next to the relay — with the same + input, printing what it prints. On Windows that one runs through Git Bash, as + Claude Code runs it (`CLAUDE_CODE_GIT_BASH_PATH`, then the Git for Windows that + `git.exe` on `PATH` belongs to, then the usual install folders); it gets 10 s + and 64 KB of output. **Uninstall relay** puts your status line back. The + numbers arrive with Claude Code's replies. +- **Codex**: nothing is installed. When the pill shows (or is clicked, at most + once a minute) Coucou starts `codex app-server` and asks it + `account/rateLimits/read`, as Codex's `/status` does, then stops it (15 s at + most, never while paused). Codex must be signed in with ChatGPT. + +## Weekly recap + +On Monday from 8 am, the first time Coucou starts, an agent starts working or +you wake the island, a card sums up the past week: time spent, sessions, files +and lines changed, commands run, permissions and questions, your top agent, +top project, busiest day and longest session. **Tray → Weekly recap** opens it +any day. + +**Share image** turns it into a 1080 × 1920 picture with Mochi. **Save image** +writes it to your Pictures folder (Downloads if there is none) as +`Coucou weekly recap YYYY-MM-DD.png`, never over an existing file; **Copy** puts +it on the clipboard. **Hide project names** leaves the project out of the image. + +The history is `recap.json` next to the log, a 12-week rolling window: counts, +the agent and the project folder's name — never a command, a file path, file +contents or a prompt. It never leaves your machine. **Settings → General → +Weekly recap** turns it off or clears it. + +## Languages + +Coucou speaks the same ten languages as the Mac app: English, 简体中文, हिन्दी, +Español, العربية, Français, বাংলা, Português (Brasil), Русский and Bahasa +Indonesia. **Settings… → General → Language** picks one; **System** (the +default) follows your system's language when it is one of these, English +otherwise. The island, the settings window and the tray menu switch at once — +nothing restarts, and the island keeps its sessions, steps and chat. + +In Arabic the island's cards read right to left; Mochi, the pills and the +header stay where they are, and commands, code and file paths stay left to +right. Steps already in a session's ticker keep the language they were written +in, as on the Mac. + +The translations are the Mac's own (`NotchBuddy/Resources/Localizable.xcstrings`, +turned into `src/i18n/strings.json` by `node scripts/gen-strings.mjs`), plus +`src/i18n/extra.json` for what only Windows and Linux show. Both are keyed by +the English text; a string missing in a language shows in English. The Rust +side (tray, errors) embeds the same two files. + ## Chat and keys **Settings… → Claude** takes your Anthropic API key. Keys live in the **Windows Credential Manager**, never on disk and never in the interface — the island can only ask whether a key exists. Same for every integration key. +The chat also talks to **Google AI (Gemini)**, **OpenAI** and **OpenRouter**: +add their keys in **Settings… → Chat providers**, then click the model name +above the chat box to switch provider and model, as on the Mac. The model list +is fetched from the provider only once you pick it and it has a key. Switching +mid-conversation carries the conversation over as plain text, so nothing in one +provider's format is ever sent to another. These providers get no web search +and no tools — they answer, they never act on your PC. + +**Local models**: **Settings… → Local models** connects **Ollama** or **LM +Studio** (leave the address empty for the usual one on this PC; Ollama's +`OLLAMA_HOST` is honoured) or any server that speaks the OpenAI API (vLLM, +llama.cpp…), with an optional key kept in the credential store. Answers stream +in as they are written, and the `` blocks of reasoning models stay +hidden. A text file you dropped goes along inline (24 000 characters at most); +images and PDFs by name only. Settings tells you whether the address is this +PC — nothing leaves it then — and warns before a key would travel over plain +`http://` to another machine. + +Answers from every provider are shown as **Markdown**: headings, lists, bold, +inline code, quotes, and code blocks with a copy button. It is built from text +nodes, never parsed as HTML, and only `http`/`https` links open. Mochi greets +you by your first name when your account has one (the Windows display name or +the Linux GECOS full name; a bare login name is not used). + +To send the Claude chat through an Anthropic-compatible gateway, set +`COUCOU_ANTHROPIC_BASE_URL` (for example `https://gateway.example.com`; +`/v1/messages` is added). It must be `https://`, or `http://` to this PC only. +Claude Code's own `ANTHROPIC_BASE_URL` is deliberately ignored: your key only +goes where you told Coucou to send it. The gateway's host is written to the log +once; the key never is. + No telemetry. The only network requests Coucou makes are to the services you configure yourself. +## GitHub + +With a token in **Settings… → Integrations → GitHub** — a classic token with +the `repo` scope, or a fine-grained one with read access to Pull requests, +Commit statuses and Actions — the GitHub pill shows: + +- **My PRs**: your open pull requests and their CI status. +- **To review**: the pull requests waiting for your review. +- **Default branch CI**: the CI of the default branch of your 10 most recently + pushed repositories. +- Your stars and the **last 7 days of contributions** in the card header; click + them for the past 23 weeks, and hover or click a day for its count. + +Click a row for the list, then an item to open it on github.com. The pill gets +a badge and a sound when the CI of one of your pull requests turns red or green +(fast runs between two checks included), when a default branch breaks, or when +someone requests your review. Pull requests are checked every 5 minutes, every +minute while a CI is running, and as soon as you open the card on data older +than a minute; contributions every 30 minutes. Nothing is fetched while the pill +is off or Coucou is paused. + ## Build it yourself You need [Rust](https://rustup.rs), [Node 20+](https://nodejs.org), and the @@ -93,7 +282,9 @@ npm run pack # builds the installer and drops it in windows/release/ `npm run dev` alone serves the front end in an ordinary browser, which is enough to work on the island's looks. It also serves `dev/upload-preview.html`, which replays the whole file-drop choreography on a loop — the one part of the UI that -otherwise needs a real drag from Explorer to see. Neither page ships in the app. +otherwise needs a real drag from Explorer to see — and `dev/recap-preview.html`, +the weekly recap card and its shared image on a sample week. None of these pages +ships in the app. `npm run pack` leaves two files in `windows/release/`, the same names the release workflow publishes: @@ -107,7 +298,7 @@ Installing is optional — `target/release/coucou.exe` runs on its own. There is window in the taskbar and no console: the island at the top of the screen and the Mochi in the notification area are the whole app, and Quit lives in its menu. -The 28 sounds are the macOS app's own files; they are never duplicated in this +The 29 sounds are the macOS app's own files; they are never duplicated in this folder. The path is declared once, in `SOUNDS_DIR` at the top of `vite.config.ts` — when they move to `shared/sounds/`, change that one line. @@ -123,6 +314,7 @@ npm run icons # regenerates src-tauri/icons from scripts/gen-icons.mjs windows/ src/ island front end (TypeScript, no framework) mochi/ Mochi and the launch greeting, in Canvas 2D + desktop/ Mochi's own little window, when he lives on the desktop island/ state machine, hooks, integrations views/ every island view settings/ the settings window @@ -134,24 +326,60 @@ windows/ ### Log `%LOCALAPPDATA%\Coucou\coucou.log` — hook events, permission decisions, poller -problems. It stays on your machine. +problems. It stays on your machine. The weekly recap's history sits beside it in +`recap.json`. ## Supported agents -The relay (`coucou-hook.exe`) works with any tool that can run a command on hook events. Pass `--agent ` to create a named pill. +Every agent below is installed from **Settings → Agents** with the same steps as +Claude Code: the exact diff, the path of the dated backup, nothing written until +you click, and uninstalling removes only Coucou's entries. A config Coucou cannot +read, or where it finds something it does not expect, is left alone and the +reason is shown. Each agent gets its own pill (`agent_`, the Mac's ids and +colours). The files are the Mac's, under `%USERPROFILE%` on Windows and `~` on +Linux. -| Agent | How to connect | Config file | +| Agent | Installs | Permissions | |---|---|---| -| Claude Code | **Settings → Claude Code → Install hooks** | `%USERPROFILE%\.claude\settings.json` | -| Gemini CLI | `--agent gemini` positional arg | `%USERPROFILE%\.gemini\settings.json` | -| Antigravity | `--agent antigravity` positional arg | `%USERPROFILE%\.config\antigravity\hooks.json` | -| Cursor | hooks installed automatically | `%USERPROFILE%\.claude\settings.json` | -| Codex | `--agent codex` positional arg | `%USERPROFILE%\.codex\hooks.json` | -| Copilot CLI | `--agent copilot` positional arg + camelCase events | `%USERPROFILE%\.copilot\hooks\coucou.json` | -| Muse Code | `--agent muse` positional arg | `%USERPROFILE%\.config\muse\settings.json` | -| Any other | `--agent ` positional arg | your tool's hook config | - -OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integration uses a plugin that calls `/bin/sh` with macOS-specific paths; the plugin installer lives in the Mac app only. +| Claude Code | `.claude\settings.json` (**Settings → Claude Code**) | Allow / Deny and questions in the island | +| Codex | `.codex\hooks.json` — then trust the hooks once with `/hooks` in Codex | Allow / Deny in the island | +| GitHub Copilot CLI | `.copilot\hooks\coucou.json` | Allow / Deny in the island | +| Muse Code | `.config\muse\settings.json` | Allow / Deny in the island | +| Gemini CLI | `.gemini\settings.json` | asked in Gemini CLI | +| Antigravity | `.gemini\config\hooks.json` (a `coucou` hook group) | asked in Antigravity | +| Cursor Agent | `.cursor\hooks.json` — Claude Code in Cursor's terminal also goes on the Cursor pill, through the Claude Code hooks | asked in Cursor | +| Claude Desktop (Windows) | nothing to install: Claude Code sessions from the Claude app are tagged by the relay | asked in the Claude app | +| OpenCode | plugin `.config\opencode\plugins\coucou.js` | asked in OpenCode | +| Amp | plugin `.config\amp\plugins\coucou.ts` | asked in Amp | +| Hermes Agent | plugin `.hermes\plugins\coucou\` — then `hermes plugins enable coucou` once | asked in Hermes | +| Any other | run `coucou-hook --agent []` from your tool's hooks | asked in the tool | + +The relay maps every agent's event and field names onto Claude Code's (Gemini +CLI's `BeforeTool`, Copilot's `preToolUse`, Cursor's `beforeSubmitPrompt`…), and +answers each agent the way it expects. It never lets anything through on its +own: with no click it prints no decision at all (`{}` for the agents that need +JSON, `"ask"` for Copilot, which is fail-closed), so the agent asks in its own +terminal exactly as without Coucou — including when Coucou is closed. + +The plugins start the relay directly, with no shell in between, and never wait +for it. Amp's steps appear as each tool finishes: its "before" hook must return +a verdict, and Coucou never gives one. + +**How each agent runs the relay on Windows.** Hook commands are written for the +shell that runs them: Git Bash for Claude Code (quoted, forward slashes), +PowerShell for Gemini CLI and Copilot CLI (`& '…\coucou-hook.exe'`), `cmd /C` +for Codex. Cursor, Antigravity and Muse Code do not document theirs: the relay +path is written bare when it has no space or special character — which works in +cmd, PowerShell and when started directly — and in double quotes otherwise. +These three are untested on Windows. + +A pill is **connected** when Coucou finds its own entries in the files above — +the same check as Settings → Agents (for Claude Code: a SessionStart hook +running Coucou's relay; the Cursor pill also counts Claude Code's hooks). +Coucou only reads these files, each time the island opens. Permission requests +get the island's card for Claude Code (in any terminal, and in Cursor's), +Codex, Copilot CLI and Muse Code; other agents and Claude Desktop ask in their +own window. ## What's different from the Mac version @@ -159,10 +387,80 @@ OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integr the top edge instead of hiding in a notch. - Permission approval works from **any** terminal; the Mac build only listens to VS Code sessions. -- Not in this version: sending a file by email, dragging Mochi onto a window to - attach it as context, and jumping to a specific terminal window — "Open - terminal" opens the working folder in VS Code when `code` is on your `PATH`. +- "Open terminal" finds the session's window by walking up from the relay's + process to the terminal or editor that runs it. A session in a classic + console window (`cmd.exe` or PowerShell without Windows Terminal) has no such + ancestor — conhost owns that window — so its folder opens in VS Code instead, + as it does when `code` is on your `PATH` and nothing was found. +- No global keyboard shortcuts yet: a waiting card is folded with its **⌃** or + `Esc` in the island, and reopened by clicking the island or Open in the tray. +- Apple Music, the one pill from the Mac catalog with nothing behind it here, + is left out. +- Not in this version: sending a dropped file by email and dragging Mochi onto + a window to attach it as context. On the Mac, email goes through Resend or + Apple Mail's scripting; neither has a safe equivalent that attaches a file + here, and the drop card would need a third button it doesn't have. - Cal.com shows the next bookings as a list rather than the Mac's calendar. +- The Cursor pill carries both Cursor Agent's own hooks and Claude Code running + in Cursor's terminal. +- Hermes: Coucou writes the plugin but does not run the `hermes` CLI, so it is + turned on once by hand. Hermes runs natively on Linux; on Windows it is + untested. +- Plan usage: the user's previous status line runs through Git Bash on Windows + (`/bin/sh` on Linux and Mac); without Git Bash it is not run, rather than + guessed at with `cmd`. The Codex CLI is looked for on `PATH` and in npm's, + Volta's, Bun's and pnpm's folders (and nvm's on Linux). +- The chat's model picker opens inside the chat card instead of a popover, and + it also offers OpenRouter and any OpenAI-compatible server, which the Mac + does not. Google AI, OpenAI and OpenRouter can see an image you dropped (sent + inline), where the Mac sends its name only. +- Live diff: the relay forwards an edit's text whole only once the edit is done + (PostToolUse), up to 256 KB per string and 512 KB per event. A bigger edit + shows its "Edits · file" step without counts rather than wrong ones. The + diff's ↗ needs `code` on your `PATH`; without it, it opens the file's folder — + never the file itself. Counts and diffs come from Claude Code's Edit, + MultiEdit and Write, on whichever pill its session is on (VS Code, Cursor, + Claude Desktop); other agents' edits show as plain steps. +- The GitHub lists are clicked, not walked with the arrow keys, and there is no + iPhone to keep fetching them while the pill is off. +- Keyboard shortcuts use `Ctrl+Alt` where the Mac uses `⌃⌥`, with different + keys (see [Keyboard shortcuts](#keyboard-shortcuts)), and `Ctrl` where the + Mac uses `⌘` inside the island. "Bring the terminal forward" is "Open + terminal" here. Not in this version: sending Mochi to the desktop from the + keyboard (drag him out instead) and attaching the front window (their ids are + kept for later), moving through a card's + list (`⌘↑` `⌘↓` `⌘O`) and the diff (`⌘E`). The island only reads its own + shortcuts while it has the keyboard: in the chat, or after a global shortcut + opened it. **Go to alert** brings up any agent's waiting card on its own pill; + **Toggle the island** folds a waiting card rather than dropping it, like `Esc` + in the island. +- Weekly recap: + - Sharing happens inside the island instead of a separate panel, and Save + writes straight into Pictures (or Downloads) instead of asking where. There + is no system Share sheet; Copy uses the web clipboard. + - Questions are counted when an agent asks one (`AskUserQuestion`), whether + it is then answered in the island or in the terminal. Permissions count the + Allow and Deny clicks on the island's card, for every agent that gets one. + - There is no sleep/wake notification to listen to without a background + loop, so after the machine wakes the Monday card waits for the first agent + to start or for you to hover the island. + - The card is 24 px taller than the Mac's: it also lists top agent, project, + busiest day, longest session, permissions and questions, which the Mac + leaves to the image. A turn cut short by the session ending still counts. + - Tray → Pause doesn't stop the history (it stays on the machine anyway); + switch it off in Settings → General. +- The wardrobe opens with a right-click on Mochi, from the tray menu, or with + its global shortcut (`Ctrl+Alt+G` by default). In the compact island a tall hat + is cut by the top edge of the screen, as it is by the notch on a Mac. +- Languages: chosen in Settings, independently of the system, and applied + without a restart (the Mac's **Restart Coucou** isn't needed). Arabic turns + the island's text right to left but not its layout: Mochi and the pills keep + their sides. +- Mochi on the desktop doesn't dance: there is no music integration to dance + to. Dropping him on a window doesn't attach it to the chat, and the Mac's + ⌃⌥D shortcut isn't there — drag him out, double-click him home. While he + sleeps, the transparent square around him (120 px) takes the first mouse + move, which wakes him and gives the rest back to the desktop. ## Linux @@ -179,22 +477,77 @@ npm run tauri dev # live-reloading development build npm run pack # AppImage, .deb and .rpm in windows/release/ ``` +On Arch Linux, build and install the package from `linux/arch/`: + +```bash +git clone https://github.com/Louis-CFM/coucou.git +cd coucou/linux/arch +makepkg -si +``` + +It needs `webkit2gtk-4.1`, `gtk-layer-shell` and `libayatana-appindicator` +(pulled in as dependencies); store API keys with GNOME Keyring or KWallet. + What changes on Linux: - **The island** is a gtk-layer-shell overlay anchored to the top edge, over any top panel, on compositors that support it: COSMIC, KDE Plasma, Hyprland, Sway - and other wlroots compositors. GNOME has no layer-shell, so there the island - is a regular window. `COUCOU_LAYER_SHELL=0` forces that mode anywhere. + and other wlroots compositors. GNOME has no layer-shell and ignores where a + Wayland window asks to go, so there Coucou runs through XWayland as a dock + window: top centre, on every workspace, still there after Super+D. + `COUCOU_X11=0` keeps the native Wayland window, `COUCOU_DOCK=0` makes it a + utility window instead of a dock. `COUCOU_LAYER_SHELL=0` forces the regular + window anywhere. - **Click-through** is the window's input region, kept equal to the island shape, so the compositor sends every other click to what is underneath. - **Mochi's eyes** follow the pointer only while it is over the island: Wayland - gives no app the cursor position anywhere else. + gives no app the cursor position anywhere else. On the desktop, likewise, + they follow it only while it is over him, and "the cursor is far away" (so + he may fall asleep) means it hasn't been over him for a few seconds. +- **Mochi on the desktop** is a layer-shell surface on the island's display + (KDE Plasma, COSMIC, Hyprland, Sway…), placed with margins and dragged within + that display; on X11 it is an ordinary always-on-top window that goes + anywhere. **GNOME on Wayland** has no layer-shell and lets no app place its + own window, so there Mochi can't leave the island: dragging him does nothing. - **Claude Code hooks** go through `~/.local/share/coucou/bin/coucou-hook` and a Unix socket at `$XDG_RUNTIME_DIR/coucou.sock`. Both ends check that the other - runs as the same user. + runs as the same user. Every other agent uses the same relay, single-quoted + for `sh`, and its config under `~` (see Supported agents). A config that is a + symlink (dotfiles) is written through to its target, with its permissions + kept. +- **Global shortcuts** are X11 key grabs, so they work in an X11 session. + On X11, AltGr is a modifier of its own and never clashes with `Ctrl+Alt`; + combinations the desktop already uses (GNOME's `Ctrl+Alt+T` terminal and + `Ctrl+Alt+←`/`→` workspace switching) show up as *In use*. Wayland has no + key grabs — the GlobalShortcuts portal isn't supported yet — so nothing is + registered there, and **Settings → Shortcuts** lists commands to bind in your + desktop's own keyboard settings instead: + `coucou --shortcut openChat` (or the AppImage's path) runs the action in the + Coucou that is already open. The ids are `toggleIsland`, `openChat`, + `goToAlert`, `jumpToTerminal`, `nextPill`, `prevPill`, `muteToggle` and + `wardrobeToggle`. - **Keys** live in the Secret Service (GNOME Keyring, KWallet). +- **Plan usage**: the status line relay is `~/.local/share/coucou/bin/coucou-hook + --statusline` and runs your previous status line with `/bin/sh -c`, like Claude + Code. Codex is found on `$PATH`, in `~/.local/bin`, npm's global prefix, Volta, + Bun, pnpm, or nvm (newest Node first), since a desktop launch often has a + shorter `$PATH` than your shell. +- **Mochi's greeting** uses the full name in your account's GECOS field + (`chfn` sets it); without one the chat stays neutral. - **Files**: preferences in `~/.config/coucou/`, the log at - `~/.local/share/coucou/coucou.log`. + `~/.local/share/coucou/coucou.log`, the weekly recap history beside it in + `recap.json`. A saved recap image goes to the pictures folder named in + `~/.config/user-dirs.dirs`, else `~/Pictures`, else `~/Downloads`. +- **Languages**: Hindi, Bengali, Chinese and Arabic need fonts that carry those + scripts; Coucou asks for the Noto families (`fonts-noto-core` and + `fonts-noto-cjk` on Debian and Ubuntu, `noto-fonts` and `noto-fonts-cjk` on + Arch). **System** reads the language from the webview, which follows + `LANGUAGE` / `LANG`. +- **Copying the recap image** needs a WebKitGTK with image clipboard support; + where it is missing, the island says so and Save still works. - What the Windows build leaves out, this one does too: sending a file by - email, dragging Mochi onto a window, and jumping to a specific terminal - window — "Open terminal" opens the folder in VS Code. + email and dragging Mochi onto a window. +- **Open terminal** opens the folder in VS Code: Wayland lets no app bring + another app's window forward, and X11 would need a window-manager client this + build doesn't carry. +- No **Claude Desktop** pill: the Claude app has no Linux build. diff --git a/windows/dev/recap-preview.html b/windows/dev/recap-preview.html new file mode 100644 index 000000000..15266e2f1 --- /dev/null +++ b/windows/dev/recap-preview.html @@ -0,0 +1,20 @@ + + + + + Coucou — weekly recap preview + + + +
+
+ + + diff --git a/windows/dev/recap-preview.ts b/windows/dev/recap-preview.ts new file mode 100644 index 000000000..62eda1119 --- /dev/null +++ b/windows/dev/recap-preview.ts @@ -0,0 +1,23 @@ +// The weekly recap card in a real island, and the shared image with and +// without project names, on the sample week of scripts/test-weekly-recap.swift. +// `npm run dev`, then open /dev/recap-preview.html. Not shipped in the app. + +import "../src/style.css"; +import { Island } from "../src/island/island"; +import { Recap } from "../src/recap/recap"; +import { renderShareImage } from "../src/recap/share"; + +const island = new Island(document.getElementById("root")!); +await Recap.open(island); + +const images = document.getElementById("images")!; +const summary = Recap.summary; +if (summary) { + for (const [hide, caption] of [[false, "Shared image"], [true, "Project names hidden"]] as const) { + const img = document.createElement("img"); + img.src = renderShareImage(summary, hide).toDataURL("image/png"); + const figure = document.createElement("figure"); + figure.append(img, caption); + images.append(figure); + } +} diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index 5bb363781..0768a807d 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -1,26 +1,38 @@ -//! coucou-hook — the relay Claude Code runs on every hook event. +//! coucou-hook — the relay Claude Code (and every other agent) runs on each hook +//! event. //! -//! Reads the hook JSON on stdin, adds a little terminal context, and hands it to +//! Reads the hook JSON on stdin, maps the agent's event and field names onto +//! Claude Code's (normalize.rs), adds a little terminal context, and hands it to //! Coucou over the named pipe `\\.\pipe\coucou-` (Windows) or the Unix //! socket `$XDG_RUNTIME_DIR/coucou.sock` (Linux). //! -//! Hard rule (docs/CLAUDE.md): **never block Claude Code.** -//! * If the pipe does not exist — Coucou is closed — we exit 0 immediately with -//! nothing on stdout, and the session carries on untouched. +//! Hard rule (docs/CLAUDE.md): **never block the agent.** +//! * If the pipe does not exist — Coucou is closed — we exit 0 immediately, with +//! only the "no opinion" reply the agent expects (reply.rs), and the session +//! carries on untouched. //! * Every step runs under a deadline enforced by the main thread, so a pipe that //! accepts the connection and then stops reading cannot wedge the session //! either: we abandon the worker and exit. //! * Only `PermissionRequest` waits for an answer, because approving from the -//! island is the whole point. No answer means empty stdout, and Claude Code -//! asks in the terminal exactly as if Coucou were not installed. +//! island is the whole point. No answer means no decision, and the agent asks +//! in its terminal exactly as if Coucou were not installed. //! -//! Usage: `coucou-hook ` (the name is also read from the JSON). +//! Usage: `coucou-hook [--agent ] []` (the event name is also +//! read from the JSON; `--agent` is absent for Claude Code), or +//! `coucou-hook --statusline` as Claude Code's status line command (plan usage, +//! see statusline.rs): it passes the plan limits on and runs the status line the +//! user had before, so that keeps working. use std::io::{Read, Write}; use std::sync::mpsc; use std::time::Duration; -/// Budget for getting a pipe connection. Beyond this Claude Code wins, always. +use serde_json::{Map, Value}; + +mod normalize; +mod reply; + +/// Budget for getting a pipe connection. Beyond this the agent wins, always. const CONNECT_TIMEOUT: Duration = Duration::from_millis(300); /// Whole-run budget for an event nobody waits on: connect and write, no more. const FIRE_AND_FORGET_BUDGET: Duration = Duration::from_secs(2); @@ -29,11 +41,24 @@ const DECISION_BUDGET: Duration = Duration::from_secs(110); /// Fields that are pointless to forward and can be enormous (a whole file read, /// a full command output). The island never shows them. -const DROPPED_FIELDS: &[&str] = &["tool_response", "transcript_path"]; +const DROPPED_FIELDS: &[&str] = &["tool_response", "tool_output", "transcript_path"]; /// Longest string forwarded for any single field; the island truncates to far /// less than this anyway. const MAX_FIELD_LEN: usize = 2_000; +mod statusline; + +/// The live diff needs the whole text of a file edit, once it has happened: +/// PostToolUse of these tools keeps its edit strings far longer than the rest. +const DIFF_TOOLS: &[&str] = &["Edit", "MultiEdit", "Write"]; +/// The `tool_input` keys holding the text being replaced or written. +const DIFF_FIELDS: &[&str] = &["old_string", "new_string", "content"]; +/// Per edit string. The island stops diffing at 200 KB anyway (DiffEngine). +const MAX_DIFF_FIELD_LEN: usize = 256 * 1024; +/// For all edit strings of one event together, so the line stays well under the +/// 1 MiB the app reads from the pipe even once JSON-escaped. +const MAX_DIFF_TOTAL: usize = 512 * 1024; + #[cfg(windows)] mod win; #[cfg(windows)] @@ -44,10 +69,57 @@ mod unix; #[cfg(target_os = "linux")] use unix::connect; +/// What the command line says: `--agent ` and the event name. +struct Args { + agent: String, + event: String, +} + +fn args() -> Args { + let mut agent = String::new(); + let mut event = String::new(); + let mut it = std::env::args().skip(1); + while let Some(arg) = it.next() { + if arg == "--agent" { + agent = it.next().unwrap_or_default(); + } else if event.is_empty() { + event = arg; + } + } + Args { agent, event } +} + +/// One event, ready to forward. +struct Event { + /// The payload as one line of JSON. + line: String, + /// The canonical event name. + name: String, + /// For Claude Code's AskUserQuestion, the question as it was asked. + question: Option, +} + fn main() { - let Some((payload, event)) = read_event() else { std::process::exit(0) }; + if std::env::args().skip(1).any(|a| a == "--statusline") { + statusline::run(); + } + let args = args(); + let mut raw = Vec::new(); + let _ = std::io::stdin().read_to_end(&mut raw); + let env = |key: &str| std::env::var(key).ok(); + let cwd = std::env::current_dir().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(); + + let Some(event) = prepare(&raw, &args, &env, &cwd) else { + // Nothing we could forward. An agent that needs JSON still gets its + // "no opinion" — Copilot is fail-closed and would deny without it. + let name = normalize::event(&args.event); + print(reply::stdout(&args.agent, name, None, None)); + std::process::exit(0); + }; - let waits_for_answer = event == "PermissionRequest"; + // Only an agent whose decisions the island can give waits for one; any other + // would be held for nothing, its decision being thrown away (reply.rs). + let waits_for_answer = event.name == "PermissionRequest" && reply::takes_decisions(&args.agent); let budget = if waits_for_answer { DECISION_BUDGET } else { FIRE_AND_FORGET_BUDGET }; // The worker owns every blocking call. If it overruns the budget we simply @@ -55,99 +127,84 @@ fn main() { // (No catch_unwind here — the release profile is panic = "abort", so it would // be dead code. `talk` is written to have nothing to panic on instead.) let (tx, rx) = mpsc::channel::>(); + let line = event.line.clone(); std::thread::spawn(move || { - let _ = tx.send(talk(&payload, waits_for_answer)); + let _ = tx.send(talk(&line, waits_for_answer)); }); - if let Ok(Some(decision)) = rx.recv_timeout(budget) { - if let Some(json) = decision_json(&decision) { - let mut out = std::io::stdout(); - let _ = writeln!(out, "{json}"); - let _ = out.flush(); - } - } - // Nothing printed: Claude Code asks in the terminal, as if we were not here. + let decision = rx.recv_timeout(budget).ok().flatten(); + print(reply::stdout(&args.agent, &event.name, decision.as_deref(), event.question.as_ref())); std::process::exit(0); } -/// The documented PermissionRequest output. Anything we do not recognise prints -/// nothing at all rather than guessing — silence is the safe answer. -/// See https://code.claude.com/docs/en/hooks -fn decision_json(decision: &str) -> Option { - let behavior = match decision.trim() { - // "always" still answers a plain allow; remembering it is the island's - // business, not Claude Code's. - "allow" | "always" => r#"{"behavior":"allow"}"#.to_string(), - "deny" => r#"{"behavior":"deny","message":"Denied from Coucou"}"#.to_string(), - _ => return None, - }; - Some(format!( - r#"{{"hookSpecificOutput":{{"hookEventName":"PermissionRequest","decision":{behavior}}}}}"# - )) +fn print(line: Option) { + if let Some(line) = line { + let mut out = std::io::stdout(); + let _ = writeln!(out, "{line}"); + let _ = out.flush(); + } } -/// Reads stdin and returns the payload to forward plus the event name. -fn read_event() -> Option<(String, String)> { - let mut raw = Vec::new(); - if std::io::stdin().read_to_end(&mut raw).is_err() || raw.is_empty() { - return None; - } +/// The payload to forward, from the raw stdin bytes. `env` reads an environment +/// variable and `cwd` is the working directory, so tests stay pure. +fn prepare(raw: &[u8], args: &Args, env: &dyn Fn(&str) -> Option, cwd: &str) -> Option { // Some shells hand us a UTF-8 BOM; serde_json would choke on it. - if raw.starts_with(&[0xEF, 0xBB, 0xBF]) { - raw.drain(..3); - } - - let mut payload = serde_json::from_slice::(&raw).ok()?; + let raw = raw.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(raw); + let mut payload = serde_json::from_slice::(raw).ok()?; let map = payload.as_object_mut()?; - // Parse argv: "coucou-hook.exe [--agent ] []" - // --agent tags the payload with coucou_agent so the app routes to the right pill. - // Absent or invalid names are validated and discarded by the app, not here. - let mut agent = String::new(); - let mut arg_event = String::new(); - { - let mut it = std::env::args().skip(1); - while let Some(arg) = it.next() { - if arg == "--agent" { - agent = it.next().unwrap_or_default(); - } else if arg_event.is_empty() { - arg_event = arg; - } - } + // Which agent this hook was installed for, so the app routes it to the right + // pill. Absent means Claude Code, so existing hook commands keep working + // unchanged; invalid names are discarded by the app, not here. A Claude Code + // session started from the Claude desktop app is tagged `claude-desktop`. + if let Some(tag) = agent_tag(&args.agent, env) { + map.insert("coucou_agent".into(), Value::String(tag)); } - // Which agent this hook was installed for. Absent means Claude Code, - // so existing hook commands keep working unchanged. - if !agent.is_empty() { - map.insert("coucou_agent".into(), serde_json::Value::String(agent)); + // Claude Code in Cursor's terminal goes on the Cursor pill (Mac #120). + if !map.contains_key("term_editor") { + if let Some(editor) = term_editor(env) { + map.insert("term_editor".into(), Value::String(editor.into())); + } } - let event = map + + let raw_event = map .get("hook_event_name") - .and_then(|v| v.as_str()) - .map(str::to_string) + .and_then(Value::as_str) .filter(|s| !s.is_empty()) - .unwrap_or(arg_event); - map.insert("hook_event_name".into(), serde_json::Value::String(event.clone())); + .map(str::to_string) + .unwrap_or_else(|| args.event.clone()); + normalize::fields(map, env); + let name = normalize::refine(normalize::event(&raw_event), map); + map.insert("hook_event_name".into(), Value::String(name.clone())); for field in DROPPED_FIELDS { map.remove(*field); } - let cwd_missing = map - .get("cwd") - .and_then(|v| v.as_str()) - .map(str::is_empty) - .unwrap_or(true); - if cwd_missing { - if let Ok(cwd) = std::env::current_dir() { - map.insert( - "cwd".into(), - serde_json::Value::String(cwd.to_string_lossy().to_string()), - ); - } + if map.get("cwd").and_then(Value::as_str).map(str::is_empty).unwrap_or(true) && !cwd.is_empty() { + map.insert("cwd".into(), Value::String(cwd.to_string())); } - // Which terminal the session runs in. Unlike macOS, Coucou here accepts - // events from every terminal, so this is context only — never a filter. + add_terminal_context(map, env); + + // Kept whole: what goes back to Claude Code must be its own input, not the + // shortened copy the island is shown. + let question = (map.get("tool_name").and_then(Value::as_str) == Some("AskUserQuestion")) + .then(|| map.get("tool_input").cloned()) + .flatten(); + + // Every string is capped, except the edit text of a finished Edit / + // MultiEdit / Write, which the live diff needs whole (within its own limits). + truncate_payload(&mut payload, &name); + + let mut line = payload.to_string(); + line.push('\n'); + Some(Event { line, name, question }) +} + +/// Which terminal the session runs in. Unlike macOS, Coucou here accepts events +/// from every terminal, so this is context only — never a filter. +fn add_terminal_context(map: &mut Map, env: &dyn Fn(&str) -> Option) { for (key, var) in [ ("term_program", "TERM_PROGRAM"), ("wt_session", "WT_SESSION"), @@ -156,38 +213,123 @@ fn read_event() -> Option<(String, String)> { ("session_pid", "CLAUDE_CODE_SSE_PORT"), ] { if !map.contains_key(key) { - let value = std::env::var(var).unwrap_or_default(); - map.insert(key.into(), serde_json::Value::String(value)); + map.insert(key.into(), Value::String(env(var).unwrap_or_default())); } } +} + +/// The `coucou_agent` tag: `--agent` when given, otherwise `claude-desktop` for +/// a Claude Code session started from the Claude desktop app, which says so in +/// CLAUDE_CODE_ENTRYPOINT — the same rule as the Mac's relay (#191). Nothing +/// for a plain Claude Code session. +fn agent_tag(arg: &str, env: &dyn Fn(&str) -> Option) -> Option { + if !arg.is_empty() { + return Some(arg.to_string()); + } + (env("CLAUDE_CODE_ENTRYPOINT").as_deref() == Some("claude-desktop")) + .then(|| "claude-desktop".to_string()) +} - truncate_strings(&mut payload); +/// `cursor` when the session runs in Cursor's integrated terminal. Cursor sets +/// TERM_PROGRAM=vscode like VS Code does, so it is told apart by its own trace +/// variable, or by its executable behind VS Code's git helper. +fn term_editor(env: &dyn Fn(&str) -> Option) -> Option<&'static str> { + if env("CURSOR_TRACE_ID").is_some_and(|v| !v.is_empty()) { + return Some("cursor"); + } + let helper = env("VSCODE_GIT_ASKPASS_NODE").unwrap_or_default(); + let exe = helper.rsplit(['/', '\\']).next().unwrap_or_default().to_ascii_lowercase(); + exe.starts_with("cursor").then_some("cursor") +} - let mut line = payload.to_string(); - line.push('\n'); - Some((line, event)) +/// Caps the strings of a payload: every field to MAX_FIELD_LEN, except the edit +/// strings of a finished Edit / MultiEdit / Write, which the live diff needs +/// whole. If even those had to be cut, `coucou_diff_truncated` tells the island +/// not to show counts it cannot trust. +fn truncate_payload(payload: &mut serde_json::Value, event: &str) { + let keeps_diff = event == "PostToolUse" + && payload + .get("tool_name") + .and_then(|v| v.as_str()) + .is_some_and(|tool| DIFF_TOOLS.contains(&tool)); + let input = if keeps_diff { + payload.as_object_mut().and_then(|map| map.remove("tool_input")) + } else { + None + }; + + truncate_strings(payload); + + if let Some(mut input) = input { + let mut budget = MAX_DIFF_TOTAL; + let mut cut_any = false; + cap_diff_strings(&mut input, &mut budget, &mut cut_any); + if let Some(map) = payload.as_object_mut() { + map.insert("tool_input".into(), input); + if cut_any { + map.insert("coucou_diff_truncated".into(), serde_json::Value::Bool(true)); + } + } + } } -/// Caps every string in the payload. A single Write can carry a whole file. -fn truncate_strings(value: &mut serde_json::Value) { +/// `tool_input` of a diff tool: edit strings share MAX_DIFF_TOTAL, each capped at +/// MAX_DIFF_FIELD_LEN; any other string gets the ordinary cap. +fn cap_diff_strings(value: &mut serde_json::Value, budget: &mut usize, cut_any: &mut bool) { match value { - serde_json::Value::String(s) => { - if s.len() > MAX_FIELD_LEN { - // Cut on a char boundary; a lone byte index can split UTF-8. - let mut end = MAX_FIELD_LEN; - while end > 0 && !s.is_char_boundary(end) { - end -= 1; + serde_json::Value::Object(map) => { + for (key, v) in map.iter_mut() { + match v { + serde_json::Value::String(s) if DIFF_FIELDS.contains(&key.as_str()) => { + let limit = MAX_DIFF_FIELD_LEN.min(*budget); + if cut(s, limit) { + *cut_any = true; + } + *budget = budget.saturating_sub(s.len()); + } + _ => cap_diff_strings(v, budget, cut_any), } - s.truncate(end); - s.push('…'); } } - serde_json::Value::Array(items) => items.iter_mut().for_each(truncate_strings), - serde_json::Value::Object(map) => map.values_mut().for_each(truncate_strings), + serde_json::Value::Array(items) => { + for item in items { + cap_diff_strings(item, budget, cut_any); + } + } + serde_json::Value::String(s) => { + cut(s, MAX_FIELD_LEN); + } + _ => {} + } +} + +/// Caps every string in the payload. A single Write can carry a whole file. +fn truncate_strings(value: &mut Value) { + match value { + Value::String(s) => { + cut(s, MAX_FIELD_LEN); + } + Value::Array(items) => items.iter_mut().for_each(truncate_strings), + Value::Object(map) => map.values_mut().for_each(truncate_strings), _ => {} } } +/// Shortens `s` to at most `max` bytes plus an ellipsis; true if it was cut. +fn cut(s: &mut String, max: usize) -> bool { + if s.len() <= max { + return false; + } + // Cut on a char boundary; a lone byte index can split UTF-8. + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s.truncate(end); + s.push('…'); + true +} + /// Connect, send, and — for a permission request — wait for the island's word. fn talk(payload: &str, waits_for_answer: bool) -> Option { let mut pipe = connect()?; @@ -223,26 +365,45 @@ fn talk(payload: &str, waits_for_answer: bool) -> Option { mod tests { use super::*; + fn run(raw: &str, agent: &str, event: &str) -> (Value, Event) { + let args = Args { agent: agent.into(), event: event.into() }; + let ev = prepare(raw.as_bytes(), &args, &|_| None, "/home/me/here").expect("forwarded"); + let v = serde_json::from_str(ev.line.trim_end()).unwrap(); + (v, ev) + } + + fn env_of(vars: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| v.to_string()) + } + + #[test] + fn claude_desktop_sessions_are_tagged_and_an_explicit_agent_wins() { + let desktop = env_of(&[("CLAUDE_CODE_ENTRYPOINT", "claude-desktop")]); + assert_eq!(agent_tag("", &desktop).as_deref(), Some("claude-desktop")); + assert_eq!(agent_tag("gemini", &desktop).as_deref(), Some("gemini")); + assert_eq!(agent_tag("", &env_of(&[("CLAUDE_CODE_ENTRYPOINT", "cli")])), None); + assert_eq!(agent_tag("", &env_of(&[])), None); + } + #[test] - fn decision_json_matches_the_documented_shape() { + fn cursor_is_told_apart_from_vs_code() { + assert_eq!(term_editor(&env_of(&[("CURSOR_TRACE_ID", "abc")])), Some("cursor")); assert_eq!( - decision_json("allow").unwrap(), - r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}"# + term_editor(&env_of(&[( + "VSCODE_GIT_ASKPASS_NODE", + r"C:\Users\me\AppData\Local\Programs\cursor\Cursor.exe" + )])), + Some("cursor") ); assert_eq!( - decision_json("deny").unwrap(), - r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied from Coucou"}}}"# + term_editor(&env_of(&[( + "VSCODE_GIT_ASKPASS_NODE", + r"C:\Users\me\AppData\Local\Programs\Microsoft VS Code\Code.exe" + )])), + None ); - // "always" is an island concept; Claude Code just gets an allow. - assert!(decision_json("always").unwrap().contains(r#""behavior":"allow""#)); - } - - #[test] - fn anything_unrecognised_prints_nothing() { - assert!(decision_json("").is_none()); - assert!(decision_json("maybe").is_none()); - // The shape the app used to send must not be mistaken for a decision. - assert!(decision_json(r#"{"permissionDecision":"allow"}"#).is_none()); + assert_eq!(term_editor(&env_of(&[("CURSOR_TRACE_ID", "")])), None); + assert_eq!(term_editor(&env_of(&[("TERM_PROGRAM", "vscode")])), None); } #[test] @@ -253,4 +414,129 @@ mod tests { assert!(s.len() <= MAX_FIELD_LEN + 4); assert!(s.ends_with('…')); } + + #[test] + fn claude_code_payloads_are_forwarded_as_they_are() { + let (v, ev) = run(r#"{"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":"/p"}"#, "", "PreToolUse"); + assert_eq!(ev.name, "PreToolUse"); + assert!(v.get("coucou_agent").is_none()); + assert_eq!(v["cwd"], "/p"); + assert_eq!(v["tool_name"], "Bash"); + } + + #[test] + fn an_agents_event_is_tagged_and_renamed() { + // Gemini CLI: its own name in the payload, ours on the command line. + let (v, ev) = run(r#"{"hook_event_name":"BeforeTool","toolCall":{"name":"shell","args":{"CommandLine":"ls"}}}"#, "gemini", "PreToolUse"); + assert_eq!(ev.name, "PreToolUse"); + assert_eq!(v["hook_event_name"], "PreToolUse"); + assert_eq!(v["coucou_agent"], "gemini"); + assert_eq!(v["tool_input"]["command"], "ls"); + assert_eq!(v["cwd"], "/home/me/here"); + + // Copilot CLI sends no event name: the command line's camelCase one is used. + let (_, ev) = run(r#"{"toolName":"bash"}"#, "copilot", "permissionRequest"); + assert_eq!(ev.name, "PermissionRequest"); + + // Cursor: a stop that failed, and its tool output left behind. + let (v, ev) = run(r#"{"hook_event_name":"stop","status":"error","tool_output":"huge"}"#, "cursor", ""); + assert_eq!(ev.name, "StopFailure"); + assert!(v.get("tool_output").is_none()); + } + + #[test] + fn a_question_is_kept_whole_and_only_for_ask_user_question() { + let long = "x".repeat(3000); + let raw = format!(r#"{{"hook_event_name":"PermissionRequest","tool_name":"AskUserQuestion","tool_input":{{"questions":[{{"question":"{long}"}}]}}}}"#); + let (v, ev) = run(&raw, "", ""); + assert_eq!(ev.question.unwrap()["questions"][0]["question"].as_str().unwrap().len(), 3000); + assert!(v["tool_input"]["questions"][0]["question"].as_str().unwrap().ends_with('…')); + let (_, ev) = run(r#"{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"ls"}}"#, "", ""); + assert!(ev.question.is_none()); + } + + #[test] + fn what_cannot_be_read_is_not_forwarded() { + let args = Args { agent: "copilot".into(), event: "preToolUse".into() }; + for raw in ["", "not json", "[1,2]"] { + assert!(prepare(raw.as_bytes(), &args, &|_| None, "/").is_none()); + } + // A BOM is not a reason to drop the event. + let mut bom = vec![0xEF, 0xBB, 0xBF]; + bom.extend_from_slice(br#"{"hook_event_name":"Stop"}"#); + assert!(prepare(&bom, &args, &|_| None, "/").is_some()); + } + + #[test] + fn the_live_diff_rule_holds_through_the_normalised_relay() { + let big = "z".repeat(10_000); + // Claude Code's finished Edit: kept whole. + let raw = format!(r#"{{"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{{"old_string":"{big}","new_string":"a"}}}}"#); + let (v, _) = run(&raw, "", ""); + assert_eq!(v["tool_input"]["old_string"].as_str().unwrap().len(), big.len()); + // The same edit before it happens, or an agent's event renamed onto + // PreToolUse: the ordinary cap. + let raw = format!(r#"{{"hook_event_name":"PreToolUse","tool_name":"Edit","tool_input":{{"old_string":"{big}"}}}}"#); + let (v, _) = run(&raw, "", ""); + assert!(v["tool_input"]["old_string"].as_str().unwrap().len() <= MAX_FIELD_LEN + 4); + let raw = format!(r#"{{"hook_event_name":"BeforeTool","tool_name":"Edit","tool_input":{{"content":"{big}"}}}}"#); + let (v, ev) = run(&raw, "gemini", ""); + assert_eq!(ev.name, "PreToolUse"); + assert!(v["tool_input"]["content"].as_str().unwrap().len() <= MAX_FIELD_LEN + 4); + } + + #[test] + fn a_finished_edit_keeps_its_text_whole_for_the_live_diff() { + let big = "line\n".repeat(4_000); // 20 KB, well past MAX_FIELD_LEN + let mut v = serde_json::json!({ + "tool_name": "Edit", + "tool_input": { "file_path": "/p/a.ts", "old_string": big, "new_string": big }, + "cwd": "x".repeat(4_000), + }); + truncate_payload(&mut v, "PostToolUse"); + assert_eq!(v["tool_input"]["old_string"].as_str().unwrap().len(), big.len()); + assert_eq!(v["tool_input"]["new_string"].as_str().unwrap().len(), big.len()); + // Everything else keeps the ordinary cap, and nothing says "cut". + assert!(v["cwd"].as_str().unwrap().len() <= MAX_FIELD_LEN + 4); + assert!(v.get("coucou_diff_truncated").is_none()); + + let mut multi = serde_json::json!({ + "tool_name": "MultiEdit", + "tool_input": { "edits": [{ "old_string": big, "new_string": "x" }] }, + }); + truncate_payload(&mut multi, "PostToolUse"); + assert_eq!(multi["tool_input"]["edits"][0]["old_string"].as_str().unwrap().len(), big.len()); + } + + #[test] + fn edits_are_still_capped_before_they_happen_and_for_other_tools() { + let big = "é".repeat(4_000); + for (event, tool) in [("PreToolUse", "Edit"), ("PermissionRequest", "Write"), ("PostToolUse", "Bash")] { + let mut v = serde_json::json!({ "tool_name": tool, "tool_input": { "content": big } }); + truncate_payload(&mut v, event); + assert!(v["tool_input"]["content"].as_str().unwrap().len() <= MAX_FIELD_LEN + 4, "{event} {tool}"); + } + } + + #[test] + fn an_edit_beyond_the_budget_is_cut_and_flagged() { + let huge = "x".repeat(MAX_DIFF_FIELD_LEN + 10); + let mut v = serde_json::json!({ + "tool_name": "Write", + "tool_input": { "file_path": "/p/big.txt", "content": huge }, + }); + truncate_payload(&mut v, "PostToolUse"); + assert!(v["tool_input"]["content"].as_str().unwrap().len() <= MAX_DIFF_FIELD_LEN + 4); + assert_eq!(v["coucou_diff_truncated"], serde_json::Value::Bool(true)); + + // Together, the edit strings never pass the shared budget. + let half = "y".repeat(MAX_DIFF_FIELD_LEN - 1); + let edits: Vec<_> = (0..4) + .map(|_| serde_json::json!({ "old_string": half, "new_string": half })) + .collect(); + let mut multi = serde_json::json!({ "tool_name": "MultiEdit", "tool_input": { "edits": edits } }); + truncate_payload(&mut multi, "PostToolUse"); + assert!(multi.to_string().len() < MAX_DIFF_TOTAL + 64 * 1024); + assert_eq!(multi["coucou_diff_truncated"], serde_json::Value::Bool(true)); + } } diff --git a/windows/hook/src/normalize.rs b/windows/hook/src/normalize.rs new file mode 100644 index 000000000..7d0c30525 --- /dev/null +++ b/windows/hook/src/normalize.rs @@ -0,0 +1,267 @@ +//! Every agent's hook payload, mapped onto the events and fields the island +//! knows (Claude Code's). Same table as `normalize_event` / +//! `normalize_tool_fields` in the Mac relay (HookServer.swift), plus Cursor's +//! events. + +use serde_json::{Map, Value}; + +/// The canonical name of an agent's event. Unknown names pass through. +pub fn event(name: &str) -> &str { + match name { + // Gemini CLI + "BeforeTool" | "BeforeToolSelection" => "PreToolUse", + "AfterTool" | "AfterModel" => "PostToolUse", + "BeforeAgent" => "UserPromptSubmit", + "AfterAgent" => "Stop", + "startup" => "SessionStart", + "exit" => "SessionEnd", + // Antigravity + "PreInvocation" => "UserPromptSubmit", + "PostInvocation" => "PostToolUse", + // snake_case relays + "pre_tool_use" => "PreToolUse", + "post_tool_use" => "PostToolUse", + "user_prompt_submit" => "UserPromptSubmit", + "session_start" => "SessionStart", + "session_end" => "SessionEnd", + "stop" => "Stop", + // Copilot CLI (camelCase) — Cursor shares most of these + "sessionStart" => "SessionStart", + "userPromptSubmitted" => "UserPromptSubmit", + "agentStop" => "Stop", + "notification" => "Notification", + "preToolUse" => "PreToolUse", + "postToolUse" => "PostToolUse", + "permissionRequest" => "PermissionRequest", + "sessionEnd" => "SessionEnd", + // Cursor + "beforeSubmitPrompt" => "UserPromptSubmit", + "postToolUseFailure" => "PostToolUseFailure", + "subagentStart" => "SubagentStart", + "subagentStop" => "SubagentStop", + other => other, + } +} + +/// Keys of Antigravity's `toolCall.args` the island reads under another name. +const ARG_ALIASES: &[(&str, &str)] = &[ + ("CommandLine", "command"), + ("FilePath", "file_path"), + ("Path", "path"), + ("Url", "url"), + ("Query", "query"), + ("Pattern", "pattern"), +]; + +fn non_empty_str<'a>(map: &'a Map, key: &str) -> Option<&'a str> { + map.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()) +} + +/// Fills `tool_name`, `tool_input`, `session_id` and `cwd` from wherever the +/// agent put them. A field the payload already has is never overwritten. +/// `env` reads an environment variable (a parameter so tests stay pure). +pub fn fields(map: &mut Map, env: &dyn Fn(&str) -> Option) { + let tool_call = map.get("toolCall").and_then(Value::as_object).cloned(); + + if !map.contains_key("tool_name") { + // Copilot: toolName. Gemini CLI / Antigravity: toolCall.name. Others: tool. + let name = non_empty_str(map, "toolName") + .or_else(|| tool_call.as_ref().and_then(|t| non_empty_str(t, "name"))) + .or_else(|| non_empty_str(map, "tool")) + .map(str::to_string); + if let Some(name) = name { + map.insert("tool_name".into(), Value::String(name)); + } + } + + if !map.contains_key("tool_input") { + // Copilot sends toolArgs as an object, or as the JSON text of one. + let args = match map.get("toolArgs") { + Some(Value::Object(o)) => Some(o.clone()), + Some(Value::String(s)) => serde_json::from_str::(s) + .ok() + .and_then(|v| v.as_object().cloned()), + _ => None, + }; + let input = args.or_else(|| { + let mut flat = tool_call.as_ref()?.get("args")?.as_object()?.clone(); + for (from, to) in ARG_ALIASES { + if let Some(v) = flat.get(*from).cloned() { + flat.insert((*to).to_string(), v); + } + } + Some(flat) + }); + if let Some(input) = input { + map.insert("tool_input".into(), Value::Object(input)); + } + } + + if non_empty_str(map, "session_id").is_none() { + let sid = ["conversationId", "conversation_id", "sessionId", "GEMINI_SESSION_ID"] + .iter() + .find_map(|k| non_empty_str(map, k)) + .map(str::to_string) + .or_else(|| env("GEMINI_SESSION_ID").filter(|s| !s.is_empty())); + if let Some(sid) = sid { + map.insert("session_id".into(), Value::String(sid)); + } + } + + if non_empty_str(map, "cwd").is_none() { + // Copilot: workdir. Gemini CLI / Antigravity: workspacePaths. Cursor: workspace_roots. + let cwd = non_empty_str(map, "workdir").map(str::to_string).or_else(|| { + ["workspacePaths", "workspace_roots"].iter().find_map(|k| { + map.get(*k)?.as_array()?.first()?.as_str().filter(|s| !s.is_empty()).map(str::to_string) + }) + }); + if let Some(cwd) = cwd { + map.insert("cwd".into(), Value::String(cwd)); + } + } +} + +/// The event once the payload is known: a stop that reports an error (Cursor's +/// `status: "error"`) is a StopFailure. +pub fn refine(event: &str, map: &Map) -> String { + if event == "Stop" && map.get("status").and_then(Value::as_str) == Some("error") { + return "StopFailure".into(); + } + event.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn obj(v: Value) -> Map { + v.as_object().unwrap().clone() + } + + fn no_env(_: &str) -> Option { + None + } + + #[test] + fn every_agents_event_names_map_onto_claude_codes() { + for (raw, canonical) in [ + ("BeforeTool", "PreToolUse"), + ("BeforeToolSelection", "PreToolUse"), + ("AfterTool", "PostToolUse"), + ("AfterModel", "PostToolUse"), + ("BeforeAgent", "UserPromptSubmit"), + ("AfterAgent", "Stop"), + ("startup", "SessionStart"), + ("exit", "SessionEnd"), + ("PreInvocation", "UserPromptSubmit"), + ("PostInvocation", "PostToolUse"), + ("pre_tool_use", "PreToolUse"), + ("post_tool_use", "PostToolUse"), + ("user_prompt_submit", "UserPromptSubmit"), + ("session_start", "SessionStart"), + ("session_end", "SessionEnd"), + ("stop", "Stop"), + ("sessionStart", "SessionStart"), + ("userPromptSubmitted", "UserPromptSubmit"), + ("agentStop", "Stop"), + ("notification", "Notification"), + ("preToolUse", "PreToolUse"), + ("postToolUse", "PostToolUse"), + ("permissionRequest", "PermissionRequest"), + ("sessionEnd", "SessionEnd"), + ("beforeSubmitPrompt", "UserPromptSubmit"), + ("postToolUseFailure", "PostToolUseFailure"), + ("subagentStart", "SubagentStart"), + ("subagentStop", "SubagentStop"), + ] { + assert_eq!(event(raw), canonical, "{raw}"); + } + // Claude Code's own names, and anything unknown, pass through. + for same in ["PreToolUse", "PermissionRequest", "Stop", "Interrupt", "somethingNew"] { + assert_eq!(event(same), same); + } + } + + #[test] + fn antigravity_and_gemini_tool_calls_become_tool_name_and_input() { + let mut m = obj(json!({ + "conversationId": "conv-1", + "workspacePaths": ["C:\\Projects\\test"], + "toolCall": { "name": "run_command", "args": { "CommandLine": "cargo check" } } + })); + fields(&mut m, &no_env); + assert_eq!(m["tool_name"], "run_command"); + assert_eq!(m["tool_input"]["command"], "cargo check"); + assert_eq!(m["tool_input"]["CommandLine"], "cargo check"); + assert_eq!(m["session_id"], "conv-1"); + assert_eq!(m["cwd"], "C:\\Projects\\test"); + } + + #[test] + fn copilot_tool_name_args_and_workdir_are_read() { + let mut m = obj(json!({ + "toolName": "bash", + "toolArgs": "{\"command\":\"npm test\"}", + "sessionId": "s-9", + "workdir": "/home/me/app" + })); + fields(&mut m, &no_env); + assert_eq!(m["tool_name"], "bash"); + assert_eq!(m["tool_input"]["command"], "npm test"); + assert_eq!(m["session_id"], "s-9"); + assert_eq!(m["cwd"], "/home/me/app"); + + let mut m = obj(json!({ "toolName": "edit", "toolArgs": { "path": "a.rs" } })); + fields(&mut m, &no_env); + assert_eq!(m["tool_input"]["path"], "a.rs"); + } + + #[test] + fn cursor_conversation_and_workspace_become_session_and_cwd() { + let mut m = obj(json!({ "conversation_id": "c-1", "workspace_roots": ["/w/proj"] })); + fields(&mut m, &no_env); + assert_eq!(m["session_id"], "c-1"); + assert_eq!(m["cwd"], "/w/proj"); + } + + #[test] + fn what_the_payload_already_says_is_never_overwritten() { + let mut m = obj(json!({ + "tool_name": "Bash", "tool_input": { "command": "ls" }, + "session_id": "keep", "cwd": "/keep", + "toolName": "other", "toolArgs": { "command": "rm" }, + "conversationId": "no", "workdir": "/no" + })); + fields(&mut m, &|_| Some("env".into())); + assert_eq!(m["tool_name"], "Bash"); + assert_eq!(m["tool_input"]["command"], "ls"); + assert_eq!(m["session_id"], "keep"); + assert_eq!(m["cwd"], "/keep"); + } + + #[test] + fn gemini_session_comes_from_the_environment_last() { + let mut m = obj(json!({})); + fields(&mut m, &|k| (k == "GEMINI_SESSION_ID").then(|| "g-7".to_string())); + assert_eq!(m["session_id"], "g-7"); + } + + #[test] + fn odd_shapes_are_ignored_not_trusted() { + let mut m = obj(json!({ "toolCall": "nope", "toolArgs": "not json", "workspacePaths": "x", "tool": 3 })); + fields(&mut m, &no_env); + assert!(!m.contains_key("tool_name")); + assert!(!m.contains_key("tool_input")); + assert!(!m.contains_key("cwd")); + } + + #[test] + fn a_stop_with_an_error_status_is_a_failure() { + assert_eq!(refine("Stop", &obj(json!({ "status": "error" }))), "StopFailure"); + for status in ["completed", "aborted"] { + assert_eq!(refine("Stop", &obj(json!({ "status": status }))), "Stop"); + } + assert_eq!(refine("PreToolUse", &obj(json!({ "status": "error" }))), "PreToolUse"); + } +} diff --git a/windows/hook/src/reply.rs b/windows/hook/src/reply.rs new file mode 100644 index 000000000..9da86a714 --- /dev/null +++ b/windows/hook/src/reply.rs @@ -0,0 +1,269 @@ +//! What the relay prints back to the agent — the only place that knows each +//! agent's stdout contract. Same replies as the Mac relay (HookServer.swift). +//! +//! The one rule that matters: **nothing that allows anything is ever printed +//! without a decision a human clicked.** With no decision the reply is silence, +//! an empty `{}`, or Copilot's explicit "ask", and every agent then asks in its +//! own terminal exactly as if Coucou were not installed. + +use serde_json::{json, Map, Value}; + +/// The agents whose permission requests the island can answer: Claude Code +/// (no `--agent`), Codex, Copilot CLI and Muse Code. Any decision for another +/// agent is ignored here too, whatever the app sent. +pub fn takes_decisions(agent: &str) -> bool { + matches!(agent, "" | "codex" | "copilot" | "muse") +} + +/// Agents that read a JSON object on stdout after every hook and get `{}` — +/// "no opinion" — when there is nothing to say. +fn wants_json(agent: &str) -> bool { + matches!(agent, "gemini" | "antigravity" | "copilot" | "muse") +} + +/// The line to print for `event` from `agent`, given the island's `decision` +/// (`None`: nobody clicked) and, for Claude Code's AskUserQuestion, the +/// question as it was asked. +pub fn stdout(agent: &str, event: &str, decision: Option<&str>, question: Option<&Value>) -> Option { + if event != "PermissionRequest" { + return wants_json(agent).then(|| "{}".to_string()); + } + let decision = decision.filter(|_| takes_decisions(agent)); + match agent { + // Copilot CLI and Muse Code read a bare permissionDecision. Copilot is + // fail-closed: it must always get valid JSON, so silence is an "ask". + "copilot" | "muse" => { + let word = match decision.map(str::trim) { + Some("allow" | "always") => Some("allow"), + Some("deny") => Some("deny"), + _ => None, + }; + match word { + Some(word) => Some(json!({ "permissionDecision": word }).to_string()), + None if agent == "copilot" => Some(json!({ "permissionDecision": "ask" }).to_string()), + None => None, + } + } + // Claude Code and Codex share the documented hookSpecificOutput. Only + // Claude Code asks questions. + "" => decision.and_then(|d| decision_json(d, question)), + _ => decision.and_then(|d| decision_json(d, None)), + } +} + +/// The documented PermissionRequest output. Anything we do not recognise prints +/// nothing at all rather than guessing — silence is the safe answer. +/// See https://code.claude.com/docs/en/hooks +/// +/// `question` is the AskUserQuestion input, when that is what is being asked: +/// the island may then answer it, which Claude Code takes as the same input +/// with an `answers` map added. Nothing else of the input can be changed from +/// the island, and no other tool's input can be changed at all. +pub fn decision_json(decision: &str, question: Option<&Value>) -> Option { + if decision.trim_start().starts_with('{') { + let reply = serde_json::from_str::(decision).ok()?; + let answers = reply.get("answers")?.as_object()?; + let question = question?; + if !answers_fit(question, answers) { + return None; + } + let mut input = question.as_object()?.clone(); + input.insert("answers".into(), Value::Object(answers.clone())); + return Some( + json!({ + "hookSpecificOutput": { + "hookEventName": "PermissionRequest", + "decision": { "behavior": "allow", "updatedInput": input }, + } + }) + .to_string(), + ); + } + let behavior = match decision.trim() { + // "always" still answers a plain allow; remembering it is the island's + // business, not the agent's. + "allow" | "always" => r#"{"behavior":"allow"}"#.to_string(), + "deny" => r#"{"behavior":"deny","message":"Denied from Coucou"}"#.to_string(), + _ => return None, + }; + Some(format!( + r#"{{"hookSpecificOutput":{{"hookEventName":"PermissionRequest","decision":{behavior}}}}}"# + )) +} + +/// True when `answers` answers exactly the questions Claude Code asked: one entry +/// per question, keyed by its text; a single-select answer is one of its option +/// labels (a string), a multi-select answer a non-empty list of distinct labels +/// (Claude Code 2.1.136+ takes the list, as the Mac sends it). Same rule as +/// `QuestionPayload.accepts` on macOS. +fn answers_fit(question: &Value, answers: &Map) -> bool { + let Some(items) = question.get("questions").and_then(|q| q.as_array()) else { return false }; + if items.is_empty() || items.len() != answers.len() { + return false; + } + items.iter().all(|item| { + let Some(text) = item.get("question").and_then(|q| q.as_str()) else { return false }; + let labels: Vec<&str> = item + .get("options") + .and_then(|o| o.as_array()) + .map(|opts| opts.iter().filter_map(|o| o.get("label").and_then(|l| l.as_str())).collect()) + .unwrap_or_default(); + let multi = item.get("multiSelect").and_then(|m| m.as_bool()).unwrap_or(false); + match answers.get(text) { + Some(Value::String(pick)) if !multi => labels.contains(&pick.as_str()), + Some(Value::Array(picks)) if multi => { + let picks: Vec<&str> = picks.iter().filter_map(|p| p.as_str()).collect(); + let mut seen = picks.clone(); + seen.sort_unstable(); + seen.dedup(); + !picks.is_empty() && seen.len() == picks.len() && picks.iter().all(|p| labels.contains(p)) + } + _ => false, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const AGENTS: &[&str] = &[ + "", "gemini", "antigravity", "cursor", "codex", "copilot", "muse", "opencode", "amp", + "hermes", "claude-desktop", "my-tool", + ]; + const EVENTS: &[&str] = &[ + "SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure", + "PermissionRequest", "Notification", "Stop", "StopFailure", "SessionEnd", "Interrupt", + "SubagentStart", "SubagentStop", "", + ]; + + /// Whatever a reply says, does it let anything through? + fn allows(out: &str) -> bool { + let v: Value = serde_json::from_str(out).expect("every reply is JSON"); + let text = v.to_string(); + text.contains("allow") || v.get("decision").is_some() || v.get("permission").is_some() || v.get("continue").is_some() + } + + #[test] + fn nothing_is_ever_allowed_without_a_decision() { + for agent in AGENTS { + for event in EVENTS { + if let Some(out) = stdout(agent, event, None, None) { + assert!(!allows(&out), "{agent:?} {event} printed {out} with nobody clicking"); + } + // A decline, a timeout or garbage from the app is no decision either. + for not_a_decision in ["", "ask", "maybe", "{\"permissionDecision\":\"allow\"}"] { + if let Some(out) = stdout(agent, event, Some(not_a_decision), None) { + assert!(!allows(&out), "{agent:?} {event} {not_a_decision:?} printed {out}"); + } + } + } + } + } + + #[test] + fn a_decision_only_counts_on_a_permission_request_from_an_agent_that_takes_one() { + for agent in AGENTS { + for event in EVENTS.iter().filter(|e| **e != "PermissionRequest") { + if let Some(out) = stdout(agent, event, Some("allow"), None) { + assert!(!allows(&out), "{agent:?} {event}: {out}"); + } + } + let out = stdout(agent, "PermissionRequest", Some("allow"), None); + assert_eq!(out.as_deref().is_some_and(allows), takes_decisions(agent), "{agent:?}: {out:?}"); + } + } + + #[test] + fn each_agent_gets_its_own_reply_shape() { + let allow = r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}"#; + let deny = r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied from Coucou"}}}"#; + for agent in ["", "codex"] { + assert_eq!(stdout(agent, "PermissionRequest", Some("allow"), None).unwrap(), allow); + assert_eq!(stdout(agent, "PermissionRequest", Some("always"), None).unwrap(), allow); + assert_eq!(stdout(agent, "PermissionRequest", Some("deny"), None).unwrap(), deny); + assert_eq!(stdout(agent, "PermissionRequest", None, None), None); + assert_eq!(stdout(agent, "PreToolUse", None, None), None); + } + for agent in ["copilot", "muse"] { + assert_eq!(stdout(agent, "PermissionRequest", Some("allow"), None).unwrap(), r#"{"permissionDecision":"allow"}"#); + assert_eq!(stdout(agent, "PermissionRequest", Some("deny"), None).unwrap(), r#"{"permissionDecision":"deny"}"#); + assert_eq!(stdout(agent, "PreToolUse", None, None).unwrap(), "{}"); + } + // Copilot is fail-closed: no decision is an explicit "ask", never silence. + assert_eq!(stdout("copilot", "PermissionRequest", None, None).unwrap(), r#"{"permissionDecision":"ask"}"#); + assert_eq!(stdout("muse", "PermissionRequest", None, None), None); + // Gemini CLI and Antigravity read "{}" as "no opinion" — Antigravity's + // PreToolUse included: the tool is never allowed on Coucou's say-so. + for agent in ["gemini", "antigravity"] { + for event in ["PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"] { + assert_eq!(stdout(agent, event, None, None).unwrap(), "{}"); + } + } + // Cursor: silence, which Cursor reads as "carry on as usual". + for event in ["PreToolUse", "UserPromptSubmit", "Stop"] { + assert_eq!(stdout("cursor", event, None, None), None); + } + // Hermes approvals are not supported: its decisions are never relayed. + assert_eq!(stdout("hermes", "PermissionRequest", Some("allow"), None), None); + } + + #[test] + fn decision_json_matches_the_documented_shape() { + assert_eq!( + decision_json("allow", None).unwrap(), + r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}"# + ); + assert!(decision_json("always", None).unwrap().contains(r#""behavior":"allow""#)); + } + + #[test] + fn anything_unrecognised_prints_nothing() { + assert!(decision_json("", None).is_none()); + assert!(decision_json("maybe", None).is_none()); + // The shape the app used to send must not be mistaken for a decision. + assert!(decision_json(r#"{"permissionDecision":"allow"}"#, None).is_none()); + } + + #[test] + fn an_answered_question_goes_back_as_the_same_input_plus_answers() { + let question = json!({ + "questions": [{ "question": "Which one?", "options": [{ "label": "A" }, { "label": "B" }] }] + }); + let out = stdout("", "PermissionRequest", Some(r#"{"answers":{"Which one?":"B"}}"#), Some(&question)).unwrap(); + let v: Value = serde_json::from_str(&out).unwrap(); + let decision = &v["hookSpecificOutput"]["decision"]; + assert_eq!(decision["behavior"], "allow"); + assert_eq!(decision["updatedInput"]["questions"], question["questions"]); + assert_eq!(decision["updatedInput"]["answers"]["Which one?"], "B"); + // Only Claude Code asks questions: an answer for anyone else is nothing. + assert!(stdout("codex", "PermissionRequest", Some(r#"{"answers":{"Which one?":"B"}}"#), Some(&question)).is_none()); + } + + #[test] + fn answers_are_only_accepted_for_a_question() { + assert!(decision_json(r#"{"answers":{"q":"a"}}"#, None).is_none()); + let question = json!({ "questions": [] }); + assert!(decision_json(r#"{"answers":{}}"#, Some(&question)).is_none()); + assert!(decision_json(r#"{"answers":{"q":1}}"#, Some(&question)).is_none()); + assert!(decision_json(r#"{"command":"rm -rf /"}"#, Some(&question)).is_none()); + } + + #[test] + fn answers_must_match_the_questions_asked() { + let q = json!({ "questions": [ + { "question": "Which one?", "options": [{ "label": "A" }, { "label": "B" }] }, + { "question": "Extras?", "multiSelect": true, + "options": [{ "label": "Tests" }, { "label": "Docs" }, { "label": "Lint" }] } + ]}); + let ok = |a: &str| decision_json(a, Some(&q)).is_some(); + assert!(ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests","Docs"]}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"C","Extras?":["Tests"]}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests"],"Other?":"x"}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A"}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":["A"],"Extras?":["Tests"]}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":"Tests"}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":[]}}"#)); + assert!(!ok(r#"{"answers":{"Which one?":"A","Extras?":["Tests","Tests"]}}"#)); + } +} diff --git a/windows/hook/src/statusline.rs b/windows/hook/src/statusline.rs new file mode 100644 index 000000000..2d135eb64 --- /dev/null +++ b/windows/hook/src/statusline.rs @@ -0,0 +1,338 @@ +//! `coucou-hook --statusline`: Claude Code's status line command, for the plan +//! usage pill. +//! +//! Claude Code hands its status line command the session's JSON on stdin, and +//! whatever that command prints becomes the status line. Coucou only wants the +//! plan limits (`rate_limits`) out of it: they go on to the app in the +//! background and nothing else of the input does. If the user had a status line +//! of their own, it is run the way Claude Code runs it — `/bin/sh -c` on Linux, +//! Git Bash's `bash -c` on Windows — with the same input, and what it prints is +//! passed on untouched, so it keeps working. +//! +//! Never blocking Claude Code: the app gets [`APP_BUDGET`] at most, the user's +//! own status line [`PREVIOUS_BUDGET`] (then it is killed and prints nothing), and +//! no more than [`MAX_OUTPUT`] bytes of what it prints are kept. + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; + +use crate::{talk, truncate_strings, CONNECT_TIMEOUT}; + +/// How long the app may take to take the limits. Same 300 ms as a hook connect. +const APP_BUDGET: Duration = CONNECT_TIMEOUT; +/// How long the user's own status line may take before we give up on it (the +/// Mac's relay allows the same). +const PREVIOUS_BUDGET: Duration = Duration::from_secs(10); +/// What is kept of the user's status line output. A status line is a line or +/// two; anything past this is drained and dropped so the command never stalls +/// on a full pipe. +const MAX_OUTPUT: usize = 64 * 1024; +/// After the command has exited, how long its output may still take to arrive +/// (a background child of it can hold the pipe open forever). +const DRAIN_GRACE: Duration = Duration::from_millis(200); + +/// Saved by the app next to this program when the relay took the status line. +const PREVIOUS_FILE: &str = "statusline-previous.json"; + +pub fn run() -> ! { + let mut raw = Vec::new(); + let _ = std::io::stdin().read_to_end(&mut raw); + if raw.starts_with(&[0xEF, 0xBB, 0xBF]) { + raw.drain(..3); + } + + // The limits go on in the background, never delaying the status line. + let sent = serde_json::from_slice::(&raw) + .ok() + .and_then(|v| payload(v.as_object()?)) + .map(|line| { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(talk(&line, false)); + }); + rx + }); + + if let Some(command) = previous_command() { + if let Some(out) = run_previous(&command, &raw, PREVIOUS_BUDGET) { + let mut stdout = std::io::stdout(); + let _ = stdout.write_all(&out); + let _ = stdout.flush(); + } + } + if let Some(rx) = sent { + let _ = rx.recv_timeout(APP_BUDGET); + } + std::process::exit(0); +} + +/// The one line the app gets from a status line call: the limits and which +/// session they came from, nothing else (no cwd, no model, no transcript). +/// Without limits — API-key users — there is nothing to send. +pub fn payload(map: &serde_json::Map) -> Option { + let limits = map.get("rate_limits").filter(|v| v.is_object())?; + let mut value = serde_json::json!({ + "hook_event_name": "StatusLine", + "session_id": map.get("session_id").cloned().unwrap_or(serde_json::Value::Null), + "rate_limits": limits, + }); + truncate_strings(&mut value); + let mut line = value.to_string(); + line.push('\n'); + Some(line) +} + +/// The command of the status line the user had before the relay took its place. +fn previous_command() -> Option { + let path = std::env::current_exe().ok()?.with_file_name(PREVIOUS_FILE); + let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(path).ok()?).ok()?; + saved.get("command")?.as_str().filter(|c| !c.trim().is_empty()).map(str::to_string) +} + +/// The shell Claude Code runs a status line command with. +#[cfg(unix)] +fn shell(command: &str) -> Option { + let mut c = Command::new("/bin/sh"); + c.args(["-c", command]); + Some(c) +} + +/// On Windows Claude Code runs status line commands through Git Bash, so a +/// command written for it (`~/bin/line.sh`, `$HOME`, pipes into `jq`…) only +/// works there. No Git Bash found: nothing is run rather than guessing with +/// `cmd`, which would mangle such a command. +#[cfg(windows)] +fn shell(command: &str) -> Option { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let bash = git_bash_candidates(|k| std::env::var_os(k), |p| p.is_file()).into_iter().next()?; + let mut c = Command::new(bash); + c.args(["-c", command]).creation_flags(CREATE_NO_WINDOW); + Some(c) +} + +/// Where Git Bash may be, best first, keeping only those `exists` accepts: +/// Claude Code's own override (`CLAUDE_CODE_GIT_BASH_PATH`), the Git for Windows +/// that `git.exe` on PATH belongs to, the usual install folders, then any +/// `bash.exe` on PATH — except Windows' own WSL launcher (System32, WindowsApps), +/// which would run the command in Linux instead. +#[cfg_attr(not(windows), allow(dead_code))] +fn git_bash_candidates( + var: impl Fn(&str) -> Option, + exists: impl Fn(&Path) -> bool, +) -> Vec { + let mut out: Vec = Vec::new(); + if let Some(p) = var("CLAUDE_CODE_GIT_BASH_PATH").filter(|p| !p.is_empty()) { + out.push(PathBuf::from(p)); + } + let path_dirs: Vec = var("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(); + // …\Git\cmd\git.exe, …\Git\bin\git.exe or …\Git\mingw64\bin\git.exe. + for dir in path_dirs.iter().filter(|d| exists(&d.join("git.exe"))) { + if let Some(root) = dir.parent() { + out.push(root.join("bin").join("bash.exe")); + if let Some(up) = root.parent() { + out.push(up.join("bin").join("bash.exe")); + } + } + } + for base in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] { + if let Some(dir) = var(base).filter(|p| !p.is_empty()) { + out.push(PathBuf::from(dir).join("Git").join("bin").join("bash.exe")); + } + } + if let Some(dir) = var("LOCALAPPDATA").filter(|p| !p.is_empty()) { + out.push(PathBuf::from(dir).join("Programs").join("Git").join("bin").join("bash.exe")); + } + for dir in &path_dirs { + let windows_own = dir.components().any(|c| { + let c = c.as_os_str().to_string_lossy(); + c.eq_ignore_ascii_case("system32") || c.eq_ignore_ascii_case("windowsapps") + }); + if !windows_own { + out.push(dir.join("bash.exe")); + } + } + let mut seen = Vec::new(); + out.retain(|p| { + let fresh = !seen.contains(p) && exists(p); + seen.push(p.clone()); + fresh + }); + out +} + +/// Runs `command` through the status line shell with `input` on stdin and +/// returns what it printed (at most [`MAX_OUTPUT`] bytes), or nothing if it could +/// not start or ran past `budget` (then it is killed). +pub fn run_previous(command: &str, input: &[u8], budget: Duration) -> Option> { + let mut child = shell(command)? + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + // Feed and drain on their own threads: a long input or output cannot wedge us. + let mut stdin = child.stdin.take()?; + let data = input.to_vec(); + std::thread::spawn(move || { + let _ = stdin.write_all(&data); + // Dropped here: the command sees the end of its input. + }); + let mut stdout = child.stdout.take()?; + let kept = Arc::new(Mutex::new(Vec::new())); + let (done_tx, done_rx) = mpsc::channel::<()>(); + { + let kept = Arc::clone(&kept); + std::thread::spawn(move || { + let mut chunk = [0u8; 8192]; + loop { + match stdout.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => { + let mut buf = kept.lock().unwrap_or_else(|e| e.into_inner()); + let room = MAX_OUTPUT.saturating_sub(buf.len()); + buf.extend_from_slice(&chunk[..n.min(room)]); + } + } + } + let _ = done_tx.send(()); + }); + } + + let deadline = Instant::now() + budget; + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } + // Whatever arrived; a child process of the command holding the pipe open + // does not get to hold us too. + let _ = done_rx.recv_timeout(DRAIN_GRACE); + let out = std::mem::take(&mut *kept.lock().unwrap_or_else(|e| e.into_inner())); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::ffi::OsString; + + #[test] + fn only_the_limits_and_the_session_go_to_the_app() { + let input = serde_json::json!({ + "session_id": "s1", "cwd": "/secret", "model": { "id": "x" }, + "transcript_path": "/home/me/.claude/x.jsonl", + "rate_limits": { "five_hour": { "used_percentage": 42, "resets_at": 1 } } + }); + let line = payload(input.as_object().unwrap()).unwrap(); + assert!(line.ends_with('\n')); + let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap(); + assert_eq!(v["hook_event_name"], "StatusLine"); + assert_eq!(v["session_id"], "s1"); + assert_eq!(v["rate_limits"]["five_hour"]["used_percentage"], 42); + assert_eq!(v.as_object().unwrap().len(), 3); + } + + #[test] + fn without_limits_nothing_is_sent() { + // API-key users get no rate_limits. + assert!(payload(serde_json::json!({ "session_id": "s1" }).as_object().unwrap()).is_none()); + assert!(payload(serde_json::json!({ "rate_limits": "x" }).as_object().unwrap()).is_none()); + } + + #[cfg(unix)] + #[test] + fn the_users_own_status_line_gets_the_same_input_and_its_output_comes_back() { + let out = run_previous("cat; echo ' tail'", b"{\"a\":1}", Duration::from_secs(5)).unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), "{\"a\":1} tail\n"); + } + + #[cfg(unix)] + #[test] + fn a_status_line_that_hangs_is_killed_and_prints_nothing() { + let started = Instant::now(); + assert!(run_previous("sleep 5", b"{}", Duration::from_millis(200)).is_none()); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + #[cfg(unix)] + #[test] + fn a_flood_of_output_is_cut_and_never_stalls_the_command() { + let started = Instant::now(); + let out = run_previous("head -c 1000000 /dev/zero", b"", Duration::from_secs(5)).unwrap(); + assert_eq!(out.len(), MAX_OUTPUT); + assert!(started.elapsed() < Duration::from_secs(3)); + } + + #[cfg(unix)] + #[test] + fn a_background_child_holding_the_pipe_does_not_hold_us() { + let started = Instant::now(); + let out = run_previous("echo hi; (sleep 5 &) ", b"", Duration::from_secs(5)).unwrap(); + assert_eq!(String::from_utf8(out).unwrap(), "hi\n"); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + fn env(pairs: &[(&str, OsString)]) -> impl Fn(&str) -> Option { + let map: HashMap = pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(); + move |k| map.get(k).cloned() + } + + fn path_var(dirs: &[&str]) -> OsString { + std::env::join_paths(dirs.iter().map(PathBuf::from)).unwrap() + } + + #[test] + fn git_bash_is_found_the_way_claude_code_finds_it() { + let files = [ + "/override/bash.exe", + "/git/cmd/git.exe", + "/git/bin/bash.exe", + "/pf/Git/bin/bash.exe", + "/wsl/System32/bash.exe", + "/msys/usr/bin/bash.exe", + ]; + let exists = |p: &Path| files.iter().any(|f| Path::new(f) == p); + + // Claude Code's own override wins. + let all = git_bash_candidates( + env(&[ + ("CLAUDE_CODE_GIT_BASH_PATH", "/override/bash.exe".into()), + ("PATH", path_var(&["/wsl/System32", "/git/cmd", "/msys/usr/bin"])), + ("ProgramFiles", "/pf".into()), + ]), + exists, + ); + assert_eq!( + all, + vec![ + PathBuf::from("/override/bash.exe"), + PathBuf::from("/git/bin/bash.exe"), + PathBuf::from("/pf/Git/bin/bash.exe"), + PathBuf::from("/msys/usr/bin/bash.exe"), + ] + ); + + // The WSL launcher in System32 is never taken for Git Bash. + let none = git_bash_candidates(env(&[("PATH", path_var(&["/wsl/System32"]))]), exists); + assert!(none.is_empty()); + + // A missing override falls through to git.exe's own install. + let found = git_bash_candidates( + env(&[("CLAUDE_CODE_GIT_BASH_PATH", "/nope/bash.exe".into()), ("PATH", path_var(&["/git/cmd"]))]), + exists, + ); + assert_eq!(found, vec![PathBuf::from("/git/bin/bash.exe")]); + } +} diff --git a/windows/mochi.html b/windows/mochi.html new file mode 100644 index 000000000..659dd8d50 --- /dev/null +++ b/windows/mochi.html @@ -0,0 +1,33 @@ + + + + + + Mochi + + + + + + + diff --git a/windows/package-lock.json b/windows/package-lock.json index 5e06f8bcb..3ff160731 100644 --- a/windows/package-lock.json +++ b/windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "coucou-windows", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coucou-windows", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@tauri-apps/api": "^2" }, @@ -1306,9 +1306,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/windows/package.json b/windows/package.json index 659b85e49..b029fac0b 100644 --- a/windows/package.json +++ b/windows/package.json @@ -1,7 +1,7 @@ { "name": "coucou-windows", "private": true, - "version": "0.1.1", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", diff --git a/windows/scripts/gen-strings.mjs b/windows/scripts/gen-strings.mjs new file mode 100644 index 000000000..6678d5b9e --- /dev/null +++ b/windows/scripts/gen-strings.mjs @@ -0,0 +1,132 @@ +// Generates src/i18n/strings.json from the Mac app's string catalog +// (NotchBuddy/Resources/Localizable.xcstrings), so Windows and Linux show the +// Mac's own translations. +// +// node scripts/gen-strings.mjs writes src/i18n/strings.json +// node scripts/gen-strings.mjs --check fails if the file is out of date +// +// Every entry is keyed by its English text, as the Mac's are (a Mac key such as +// "step.reads" is replaced by its English value, "Reads"). Format specifiers +// become named or numbered placeholders: "%@" and "%lld" → "{0}", "{1}"… +// ("%1$@" keeps its number), renamed where RENAMES says so. Plural entries keep +// their categories ({ "one": …, "other": … }) and are keyed by the English +// "other" form. English is the key itself, so it is not stored. +// +// Strings the Mac does not have (Windows and Linux only) live in +// src/i18n/extra.json, written by hand; see src/i18n/i18n.ts. + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const SOURCE = resolve(here, "../../NotchBuddy/Resources/Localizable.xcstrings"); +const OUTPUT = resolve(here, "../src/i18n/strings.json"); + +/** The languages Coucou ships, in the Mac's picker order. English is the source. */ +export const LANGUAGES = ["en", "zh-Hans", "hi", "es", "ar", "fr", "bn", "pt-BR", "ru", "id"]; + +/** + * Names for the placeholders of the Mac strings the Windows code uses, by Mac + * key: the n-th specifier becomes `{names[n]}` instead of `{n}`. + */ +const RENAMES = { + "Auto-close · %llds": ["seconds"], + "Chat with %@": ["name"], + "Claude is reading %@…": ["name"], + "Connected · %@": ["detail"], + "Key configured · %@": ["model"], + "Open %@": ["name"], + "Playing · %@": ["title"], + "Uploading %@": ["name"], + "Watching %lld of %lld": ["count", "total"], + "file.ready %@": ["name"], + "mail.with %@": ["name"], + "plan.hours-ago %lld": ["n"], + "plan.mins-ago %lld": ["n"], + "plan.reset-in-hm %lld %lld": ["h", "m"], + "plan.reset-in-m %lld": ["m"], + "settings.slots-used %lld": ["count"], + "status.local.connected %lld": ["count"], + "status.local.no-models %@": ["name"], + "status.local.unreachable %1$@ %2$@": ["name", "url"], +}; + +/** "%@ at %lld" → "{0} at {1}"; "%2$@" → "{1}"; "%%" → "%". */ +export function convertFormat(text, names = []) { + let next = 0; + return text.replace(/%(?:(\d+)\$)?(@|lld|ld|d|lu|u|f|%)/g, (_m, pos, spec) => { + if (spec === "%") return "%"; + const index = pos ? Number(pos) - 1 : next++; + return `{${names[index] ?? index}}`; + }); +} + +function unitValue(loc) { + return loc?.stringUnit?.value; +} + +/** A localization as a string, or as plural categories. */ +function readLocalization(loc, names) { + const plural = loc?.variations?.plural; + if (plural) { + const out = {}; + for (const [category, v] of Object.entries(plural)) { + const value = unitValue(v); + if (typeof value === "string") out[category] = convertFormat(value, names); + } + return Object.keys(out).length ? out : undefined; + } + const value = unitValue(loc); + return typeof value === "string" ? convertFormat(value, names) : undefined; +} + +export function generate(catalog) { + const strings = {}; + for (const [macKey, entry] of Object.entries(catalog.strings ?? {})) { + const names = RENAMES[macKey] ?? []; + const locs = entry.localizations ?? {}; + // A Mac key with no English localization is its own English text. + const en = readLocalization(locs.en, names) ?? convertFormat(macKey, names); + const key = typeof en === "string" ? en : en.other ?? Object.values(en)[0]; + const out = {}; + for (const lang of LANGUAGES) { + if (lang === "en") continue; + const value = readLocalization(locs[lang], names); + if (value !== undefined) out[lang] = value; + } + // The English plural forms are needed to pick "one" in English too. + if (typeof en !== "string") out.en = en; + strings[key] = out; + } + const sorted = Object.fromEntries(Object.entries(strings).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); + return { + _generated: "By scripts/gen-strings.mjs from NotchBuddy/Resources/Localizable.xcstrings. Do not edit: run `node scripts/gen-strings.mjs`.", + languages: LANGUAGES, + strings: sorted, + }; +} + +function main() { + const catalog = JSON.parse(readFileSync(SOURCE, "utf8")); + const text = `${JSON.stringify(generate(catalog), null, 1)}\n`; + if (process.argv.includes("--check")) { + let current = ""; + try { + current = readFileSync(OUTPUT, "utf8"); + } catch { + // missing: out of date + } + if (current !== text) { + console.error("src/i18n/strings.json is out of date: run `node scripts/gen-strings.mjs`."); + process.exit(1); + } + console.log("src/i18n/strings.json is up to date."); + return; + } + writeFileSync(OUTPUT, text); + const count = Object.keys(JSON.parse(text).strings).length; + console.log(`Wrote ${count} strings to src/i18n/strings.json`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml index f581867d9..741b00810 100644 --- a/windows/src-tauri/Cargo.toml +++ b/windows/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-single-instance = "2" tauri-plugin-autostart = "2" +tauri-plugin-global-shortcut = "2" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } tokio = { version = "1", features = ["net", "io-util", "sync", "time", "rt"] } @@ -24,11 +25,18 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus [target.'cfg(windows)'.dependencies] keyring = { version = "3", features = ["windows-native"] } +# The island's file drop reads paths straight from WebView2 (webview_drop.rs). +# Same versions as wry, so nothing new is compiled. +webview2-com = "0.39" +wv2_core = { package = "windows-core", version = "0.62" } windows = { version = "0.61", features = [ "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", + # "Open terminal": the relay's process tree leads to the session's window. + "Win32_System_Diagnostics_ToolHelp", "Win32_System_Ole", + "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", diff --git a/windows/src-tauri/capabilities/default.json b/windows/src-tauri/capabilities/default.json index 38b8f5dfe..c869302e7 100644 --- a/windows/src-tauri/capabilities/default.json +++ b/windows/src-tauri/capabilities/default.json @@ -2,6 +2,6 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "What Coucou's windows are allowed to do. Everything that touches the OS goes through Coucou's own commands.", - "windows": ["island", "settings"], + "windows": ["island", "settings", "mochi"], "permissions": ["core:default", "autostart:default"] } diff --git a/windows/src-tauri/src/agent_hooks.rs b/windows/src-tauri/src/agent_hooks.rs new file mode 100644 index 000000000..dd80cd0fa --- /dev/null +++ b/windows/src-tauri/src/agent_hooks.rs @@ -0,0 +1,143 @@ +// Whether each hook-driven pill is connected. +// +// A pill fed by hook events has no key: it is connected once the agent's config +// routes its events to coucou-hook (Mac #183 — the idle card used to say "Key +// not configured" for these). Only reads; nothing here ever writes. +// +// Claude Code is read here, with the Mac's own rule. Every other agent is read +// by agents.rs — the code that writes their configs is the one source of truth +// for where those files are and what Coucou's entries look like, so the pill +// and Settings → Agents always agree. + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde_json::Value; + + +/// Port of `coucouHooksPresent(inSettings:)` (ClaudeHookDetection.swift): true +/// when a parsed `~/.claude/settings.json` routes Claude Code's SessionStart +/// events to Coucou. The command text is what tells: Coucou's relay here is +/// `coucou-hook`, the Mac's is `~/.claude/coucou/nb-hook` (or NotchBuddy in the +/// App Store build), so a settings file shared between machines reads the same. +pub fn claude_hooks_present(settings: &Value) -> bool { + let Some(groups) = settings + .get("hooks") + .and_then(|h| h.get("SessionStart")) + .and_then(Value::as_array) + else { + return false; + }; + groups.iter().any(|group| { + group.get("hooks").and_then(Value::as_array).is_some_and(|hooks| { + hooks.iter().any(|hook| { + hook.get("command") + .and_then(Value::as_str) + .is_some_and(|c| c.contains("NotchBuddy") || c.contains("coucou")) + }) + }) + }) +} + +/// A JSON file as a value; anything missing or unreadable is "nothing there". +fn read_json(path: &PathBuf) -> Value { + std::fs::read(path) + .ok() + .and_then(|bytes| { + let text = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(&bytes).to_vec(); + serde_json::from_slice(&text).ok() + }) + .unwrap_or(Value::Null) +} + +/// Pill ID → connected, for every hook-driven pill. +pub fn status() -> HashMap { + let claude = claude_hooks_present(&read_json(&crate::hooks::settings_path())); + let mut out = HashMap::new(); + out.insert("integration_claude".to_string(), claude); + for agent in crate::agents::list() { + out.insert(format!("agent_{}", agent.id), agent.installed); + } + // The Cursor pill also carries Claude Code run in Cursor's terminal, which + // Claude Code's own hooks report. + if claude { + out.insert("agent_cursor".to_string(), true); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn settings(json: &str) -> Value { + serde_json::from_str(json).unwrap_or(Value::Null) + } + + // The ten cases of tests/ClaudeHookDetectionTests.swift, plus this build's relay. + + #[test] + fn the_hook_coucou_writes_is_installed() { + assert!(claude_hooks_present(&settings( + r#"{"hooks":{"SessionStart":[{"hooks":[ + {"type":"command","command":"\"C:/Users/me/AppData/Local/Coucou/bin/coucou-hook.exe\" SessionStart"}]}]}}"# + ))); + assert!(claude_hooks_present(&settings( + r#"{"hooks":{"SessionStart":[{"hooks":[ + {"type":"command","command":"'/home/me/.local/share/coucou/bin/coucou-hook' SessionStart"}]}]}}"# + ))); + } + + #[test] + fn the_mac_builds_hooks_count_too() { + assert!(claude_hooks_present(&settings( + r#"{"hooks":{"SessionStart":[{"hooks":[ + {"type":"command","command":"$HOME/.claude/coucou/nb-hook"}]}]}}"# + ))); + assert!(claude_hooks_present(&settings( + r#"{"hooks":{"SessionStart":[{"hooks":[ + {"type":"command","command":"/Applications/NotchBuddy.app/.../nb-hook"}]}]}}"# + ))); + } + + #[test] + fn our_hook_next_to_somebody_elses_is_installed() { + assert!(claude_hooks_present(&settings( + r#"{"hooks":{"SessionStart":[ + {"hooks":[{"type":"command","command":"/usr/local/bin/other-tool"}]}, + {"hooks":[{"type":"command","command":"$HOME/.claude/coucou/nb-hook"}]}]}}"# + ))); + } + + #[test] + fn other_tools_only_is_not_installed() { + // The case that showed "Key not configured" on macOS. + assert!(!claude_hooks_present(&settings( + r#"{"hooks":{"SessionStart":[{"hooks":[ + {"type":"command","command":"$HOME/.vibe-island/bin/vibe-island-bridge"}, + {"type":"command","command":"python3 /Users/me/.claude/skills/harness/hook.py"}]}]}}"# + ))); + } + + #[test] + fn hooks_for_other_events_do_not_count() { + assert!(!claude_hooks_present(&settings( + r#"{"hooks":{"PreToolUse":[{"hooks":[ + {"type":"command","command":"$HOME/.claude/coucou/nb-hook"}]}]}}"# + ))); + } + + #[test] + fn malformed_or_empty_settings_never_read_as_installed() { + for json in [ + "{}", + r#"{"hooks":{"SessionStart":[]}}"#, + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command"}]}]}}"#, + r#"{"hooks":{"SessionStart":"not-an-array"}}"#, + r#"{"hooks":"not-an-object"}"#, + "not json", + ] { + assert!(!claude_hooks_present(&settings(json)), "{json}"); + } + } +} diff --git a/windows/src-tauri/src/agents.rs b/windows/src-tauri/src/agents.rs new file mode 100644 index 000000000..76c4436b5 --- /dev/null +++ b/windows/src-tauri/src/agents.rs @@ -0,0 +1,1255 @@ +// Hook and plugin installers for every agent other than Claude Code (hooks.rs). +// +// Each agent is a list of file edits handed to config_file.rs, which applies the +// CLAUDE.md rules once for all of them: strict read, refuse unexpected types, +// diff, dated backup that must succeed, fingerprint check, atomic write. What +// lives here is only *what* changes in each agent's config, and the command +// line that starts the relay in the shell that agent uses. +// +// Formats and paths follow the Mac app (NotchBuddy/Sources/App/HookServer.swift) +// so a config shared between machines (dotfiles) looks the same everywhere. + +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde_json::{json, Map, Value}; + +use crate::config_file::{self, FileEdit, Plan}; +use crate::{platform, settings}; + +/// Marker that identifies a Coucou entry: the relay's file name. +const MARKER: &str = "coucou-hook"; + +// ── The relay command line ──────────────────────────────────────────────────── + +/// The shell an agent runs its hook commands through on Windows. On Linux every +/// agent uses `sh` (or bash), so there is only one way to quote. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Shell { + /// Git Bash on Windows (Claude Code), `sh` on Linux. + Sh, + /// `powershell -Command` (Gemini CLI, Copilot CLI's `powershell` field). + PowerShell, + /// `cmd /C`, or a process started directly (Codex, Cursor, Antigravity, Muse). + Cmd, +} + +/// Where the relay is and which OS the command is for. Kept apart from the +/// real install so tests can build both the Windows and the Linux command. +pub struct Relay { + pub exe: String, + pub windows: bool, +} + +impl Relay { + pub fn current() -> Self { + Self { + exe: settings::hook_exe_path().to_string_lossy().to_string(), + windows: cfg!(windows), + } + } + + /// The relay followed by `args`, as one command line for `shell`. + pub fn command(&self, shell: Shell, args: &str) -> String { + if !self.windows { + return format!("{} {args}", sh_quote(&self.exe)); + } + let exe = match shell { + // Git Bash reads backslashes as escapes: forward slashes, quoted. + Shell::Sh => format!("\"{}\"", self.exe.replace('\\', "/")), + // A quoted string alone is an expression in PowerShell: `&` runs it. + // Inside single quotes only `'` is special, and it doubles. + Shell::PowerShell => format!("& '{}'", self.exe.replace('\'', "''")), + // cmd /C keeps the quotes of a lone quoted program name; a plain path + // goes unquoted so it also works if the agent turns out to use + // PowerShell. A Windows path cannot contain `"`. + Shell::Cmd if is_plain(&self.exe) => self.exe.clone(), + Shell::Cmd => format!("\"{}\"", self.exe), + }; + format!("{exe} {args}") + } +} + +/// The relay command for the running OS, for `args`. +pub fn relay_command(shell: Shell, args: &str) -> String { + Relay::current().command(shell, args) +} + +/// `s` as one single-quoted POSIX shell word: `'` becomes `'\''`, nothing else +/// is special inside single quotes. +fn sh_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', r"'\''")) +} + +/// A path no Windows shell would split or interpret. +fn is_plain(path: &str) -> bool { + path.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '\\' | ':' | '.' | '_' | '-')) +} + +// ── Agents ──────────────────────────────────────────────────────────────────── + +type JsonChange = Box Result, String>>; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Agent { + Gemini, + Antigravity, + Cursor, + Codex, + Copilot, + Muse, + OpenCode, + Amp, + Hermes, +} + +impl Agent { + pub const ALL: &'static [Agent] = &[ + Agent::Codex, + Agent::Copilot, + Agent::Muse, + Agent::Gemini, + Agent::Antigravity, + Agent::Cursor, + Agent::OpenCode, + Agent::Amp, + Agent::Hermes, + ]; + + /// The `--agent` name; the pill is `agent_` (PillCatalog.swift). + pub fn id(self) -> &'static str { + match self { + Agent::Gemini => "gemini", + Agent::Antigravity => "antigravity", + Agent::Cursor => "cursor", + Agent::Codex => "codex", + Agent::Copilot => "copilot", + Agent::Muse => "muse", + Agent::OpenCode => "opencode", + Agent::Amp => "amp", + Agent::Hermes => "hermes", + } + } + + pub fn from_id(id: &str) -> Option { + Self::ALL.iter().copied().find(|a| a.id() == id) + } + + /// The files this agent's install touches, under `home`. + fn files(self, home: &Path) -> Vec { + match self { + Agent::Gemini => vec![home.join(".gemini").join("settings.json")], + Agent::Antigravity => vec![home.join(".gemini").join("config").join("hooks.json")], + Agent::Cursor => vec![home.join(".cursor").join("hooks.json")], + Agent::Codex => vec![home.join(".codex").join("hooks.json")], + Agent::Copilot => vec![home.join(".copilot").join("hooks").join("coucou.json")], + Agent::Muse => vec![home.join(".config").join("muse").join("settings.json")], + // OpenCode and Amp read ~/.config on Windows too. + Agent::OpenCode => vec![home.join(".config").join("opencode").join("plugins").join("coucou.js")], + Agent::Amp => vec![home.join(".config").join("amp").join("plugins").join("coucou.ts")], + Agent::Hermes => { + let dir = home.join(".hermes").join("plugins").join("coucou"); + vec![dir.join("__init__.py"), dir.join("plugin.yaml")] + } + } + } + + /// The edits that install (or remove) Coucou for this agent. + fn edits(self, home: &Path, relay: &Relay, install: bool) -> Vec> { + let files = self.files(home); + if let Some(contents) = self.plugin(relay) { + return files.into_iter().zip(contents).map(|(path, text)| plugin_edit(path, text, install)).collect(); + } + let path = files[0].clone(); + let label = path.display().to_string(); + let change = self.json_change(relay, install); + vec![FileEdit { path, edit: config_file::json_edit(label, change) }] + } + + /// For an agent that loads a plugin rather than running hook commands, the + /// contents of each of its files (same order as `files`). + fn plugin(self, relay: &Relay) -> Option> { + // A JSON string is also a valid JavaScript, TypeScript and Python one. + let hook = serde_json::to_string(&relay.exe).unwrap_or_default(); + match self { + Agent::OpenCode => Some(vec![OPENCODE_PLUGIN.replace("{HOOK}", &hook)]), + Agent::Amp => Some(vec![AMP_PLUGIN.replace("{HOOK}", &hook)]), + Agent::Hermes => Some(vec![HERMES_PLUGIN.replace("{HOOK}", &hook), HERMES_PLUGIN_YAML.to_string()]), + _ => None, + } + } + + /// The change to this agent's JSON config: the new object, or `None` to + /// remove a file that is Coucou's alone. Command lines are built here, up + /// front, so the change itself is pure. + fn json_change(self, relay: &Relay, install: bool) -> JsonChange { + match (self, install) { + (Agent::Gemini, false) => Box::new(|v| groups_uninstall(v, "gemini").map(Some)), + (Agent::Gemini, true) => { + let commands: Vec<(String, String, u64)> = GEMINI_EVENTS + .iter() + .map(|(event, said, timeout)| { + let command = relay.command(Shell::PowerShell, &format!("--agent gemini {said}")); + (event.to_string(), command, *timeout) + }) + .collect(); + Box::new(move |v| gemini_install(v, &commands).map(Some)) + } + (Agent::Antigravity, false) => Box::new(|v| antigravity_uninstall(v).map(Some)), + (Agent::Antigravity, true) => { + let block = antigravity_block(relay); + Box::new(move |v| antigravity_install(v, &block).map(Some)) + } + (Agent::Cursor, false) => Box::new(|v| groups_uninstall(v, "cursor").map(Some)), + (Agent::Cursor, true) => { + let command = relay.command(Shell::Cmd, "--agent cursor"); + Box::new(move |v| cursor_install(v, &command).map(Some)) + } + (Agent::Codex, false) => Box::new(|v| groups_uninstall(v, "codex").map(Some)), + (Agent::Codex, true) => { + let command = relay.command(Shell::Cmd, "--agent codex"); + Box::new(move |v| codex_install(v, &command).map(Some)) + } + (Agent::Copilot, false) => Box::new(copilot_uninstall), + (Agent::Copilot, true) => { + let entries: Vec<(String, Value)> = COPILOT_EVENTS + .iter() + .map(|(event, timeout)| (event.to_string(), copilot_entry(relay, event, *timeout))) + .collect(); + Box::new(move |v| copilot_install(v, &entries).map(Some)) + } + (Agent::Muse, false) => Box::new(|v| groups_uninstall(v, "muse").map(Some)), + (Agent::Muse, true) => { + let commands: Vec<(String, String, u64)> = MUSE_EVENTS + .iter() + .map(|(event, seconds)| { + let command = relay.command(Shell::Cmd, &format!("--agent muse {event}")); + (event.to_string(), command, seconds * 1000) + }) + .collect(); + Box::new(move |v| muse_install(v, &commands).map(Some)) + } + // Plugins are whole files (see `plugin`), never merged into JSON. + (Agent::OpenCode | Agent::Amp | Agent::Hermes, _) => { + Box::new(|_| Err(crate::i18n::t("This agent takes a plugin, not hook entries."))) + } + } + } + + /// True when the agent's config already routes to Coucou. Never fails: a + /// file we cannot read just reads as "not installed". + fn installed(self, home: &Path) -> bool { + let files = self.files(home); + if matches!(self, Agent::OpenCode | Agent::Amp | Agent::Hermes) { + return std::fs::read_to_string(&files[0]).is_ok_and(|text| is_our_plugin(&text)); + } + let json = || { + config_file::read(&files[0]) + .ok() + .flatten() + .and_then(|b| config_file::parse_json(Some(&b), "").ok()) + .unwrap_or_else(|| json!({})) + }; + match self { + Agent::Gemini => groups_have_ours(&json(), "gemini"), + Agent::Antigravity => json().get("coucou").is_some_and(antigravity_is_ours), + Agent::Cursor => groups_have_ours(&json(), "cursor"), + Agent::Codex => groups_have_ours(&json(), "codex"), + Agent::Copilot => json() + .get("hooks") + .and_then(Value::as_object) + .is_some_and(|h| h.values().filter_map(Value::as_array).flatten().any(copilot_entry_is_ours)), + Agent::Muse => groups_have_ours(&json(), "muse"), + Agent::OpenCode | Agent::Amp | Agent::Hermes => false, + } + } + + /// Whether the island can answer this agent's permission requests. Must + /// match `takes_decisions` in the relay (hook/src/reply.rs). + fn approvals(self) -> bool { + matches!(self, Agent::Codex | Agent::Copilot | Agent::Muse) + } +} + +// ── Strings shown in Settings ───────────────────────────────────────────────── + +// English keys, shown in the interface language (i18n.rs). Agent names are not translated. + +impl Agent { + pub fn name(self) -> &'static str { + match self { + Agent::Gemini => "Gemini CLI", + Agent::Antigravity => "Antigravity", + Agent::Cursor => "Cursor Agent", + Agent::Codex => "Codex", + Agent::Copilot => "GitHub Copilot CLI", + Agent::Muse => "Muse Code", + Agent::OpenCode => "OpenCode", + Agent::Amp => "Amp", + Agent::Hermes => "Hermes Agent", + } + } + + /// What to do once the file is written, shown with the result. + fn note(self) -> String { + use crate::i18n::t; + match self { + Agent::Gemini => t("Start a new Gemini CLI session to pick the hooks up."), + Agent::Antigravity => t("Start a new Antigravity conversation to pick the hooks up."), + Agent::Cursor => t("Restart Cursor to pick the hooks up."), + Agent::Codex => t("Codex runs new hooks only once you trust them: start Codex and review them once with /hooks."), + Agent::Copilot => t("Start a new Copilot CLI session to pick the hooks up."), + Agent::Muse => t("Start a new Muse Code session to pick the hooks up."), + Agent::OpenCode => t("Restart OpenCode to load the plugin."), + Agent::Amp => t("Restart Amp to load the plugin."), + Agent::Hermes => t("Turn it on once with `hermes plugins enable coucou`, then start a new Hermes session."), + } + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentStatus { + pub id: &'static str, + pub name: &'static str, + pub installed: bool, + /// The file (or files, one per line) Coucou writes. + pub path: String, + pub hook_ready: bool, + /// The island can allow or deny this agent's permission requests. + pub approvals: bool, + pub note: String, +} + +fn find(id: &str) -> Result { + Agent::from_id(id).ok_or_else(|| crate::i18n::tf("Unknown agent \"{id}\".", &[("id", id)])) +} + +pub fn list() -> Vec { + let home = platform::home_dir(); + let hook_ready = settings::hook_exe_path().exists(); + Agent::ALL + .iter() + .map(|&a| AgentStatus { + id: a.id(), + name: a.name(), + installed: a.installed(&home), + path: a.files(&home).iter().map(|p| p.display().to_string()).collect::>().join("\n"), + hook_ready, + approvals: a.approvals(), + note: a.note(), + }) + .collect() +} + +pub fn preview(id: &str, install: bool) -> Result { + let agent = find(id)?; + config_file::preview(&agent.edits(&platform::home_dir(), &Relay::current(), install)) +} + +/// Only ever called from an explicit click. Returns the backups taken, one per line. +pub fn apply(id: &str, install: bool, fingerprint: &str) -> Result { + let agent = find(id)?; + apply_in(agent, &platform::home_dir(), &Relay::current(), install, fingerprint) +} + +fn apply_in(agent: Agent, home: &Path, relay: &Relay, install: bool, fingerprint: &str) -> Result { + let backups = config_file::apply(&agent.edits(home, relay, install), fingerprint)?; + // Hermes loads every folder under plugins/: an empty `coucou` one goes too. + if agent == Agent::Hermes && !install { + if let Some(dir) = agent.files(home)[0].parent() { + let _ = std::fs::remove_dir(dir); + } + } + Ok(backups.iter().map(|p| p.display().to_string()).collect::>().join("\n")) +} + +// ── Shared JSON helpers ─────────────────────────────────────────────────────── + +fn unexpected(what: &str) -> String { + crate::i18n::tf("{what} has an unexpected type — Coucou has not touched it.", &[("what", what)]) +} + +/// `root[key]` as an object to edit: absent is empty, anything else is refused. +fn object_at(root: &Map, key: &str) -> Result, String> { + match root.get(key) { + None => Ok(Map::new()), + Some(Value::Object(m)) => Ok(m.clone()), + Some(_) => Err(unexpected(&format!("\"{key}\""))), + } +} + +/// `hooks[event]` as a list to edit: absent is empty, anything else is refused. +fn list_at(hooks: &Map, event: &str) -> Result, String> { + match hooks.get(event) { + None => Ok(Vec::new()), + Some(Value::Array(list)) => Ok(list.clone()), + Some(_) => Err(unexpected(&format!("\"hooks\".\"{event}\""))), + } +} + +/// A command line written by Coucou for `agent`. +fn is_our_command(command: Option<&Value>, agent: &str) -> bool { + command + .and_then(Value::as_str) + .is_some_and(|c| c.contains(MARKER) && c.contains(&format!("--agent {agent}"))) +} + +/// Claude-style groups (`{"matcher"?, "hooks": [{"command"}]}`, or a legacy +/// flat `{"command"}`) without Coucou's entries for `agent`. A group left +/// empty goes; everything else stays exactly as it was. +fn without_ours_in_groups(groups: &[Value], agent: &str) -> Vec { + groups + .iter() + .filter_map(|group| { + if is_our_command(group.get("command"), agent) { + return None; + } + let Some(inner) = group.get("hooks").and_then(Value::as_array) else { + return Some(group.clone()); + }; + let kept: Vec = + inner.iter().filter(|h| !is_our_command(h.get("command"), agent)).cloned().collect(); + if kept.len() == inner.len() { + return Some(group.clone()); + } + if kept.is_empty() { + return None; + } + let mut group = group.clone(); + group["hooks"] = Value::Array(kept); + Some(group) + }) + .collect() +} + +/// `root` with one Coucou group per event, built by `group(event)`. Earlier +/// Coucou entries for `agent` are replaced; nobody else's are touched. +fn groups_install( + root: &Value, + agent: &str, + events: impl IntoIterator, +) -> Result, String> { + let mut root = root.as_object().cloned().unwrap_or_default(); + let mut hooks = object_at(&root, "hooks")?; + for (event, group) in events { + let mut list = without_ours_in_groups(&list_at(&hooks, &event)?, agent); + list.push(group); + hooks.insert(event, Value::Array(list)); + } + root.insert("hooks".into(), Value::Object(hooks)); + Ok(root) +} + +/// `root` without any Coucou group for `agent`; an event left empty goes, and +/// so does `hooks` when nothing is left in it. +fn groups_uninstall(root: &Value, agent: &str) -> Result { + let mut root = root.as_object().cloned().unwrap_or_default(); + if !root.contains_key("hooks") { + return Ok(Value::Object(root)); + } + let hooks = object_at(&root, "hooks")?; + let mut out = Map::new(); + for (event, value) in hooks { + match value.as_array() { + Some(list) => { + let kept = without_ours_in_groups(list, agent); + if !kept.is_empty() { + out.insert(event, Value::Array(kept)); + } + } + None => { + out.insert(event, value); + } + } + } + if out.is_empty() { + root.remove("hooks"); + } else { + root.insert("hooks".into(), Value::Object(out)); + } + Ok(Value::Object(root)) +} + +fn groups_have_ours(root: &Value, agent: &str) -> bool { + let ours = |group: &Value| { + is_our_command(group.get("command"), agent) + || group + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|inner| inner.iter().any(|h| is_our_command(h.get("command"), agent))) + }; + root.get("hooks") + .and_then(Value::as_object) + .is_some_and(|hooks| hooks.values().filter_map(Value::as_array).flatten().any(ours)) +} + +// ── Gemini CLI — ~/.gemini/settings.json ────────────────────────────────────── +// +// Claude-style groups with a `*` matcher and timeouts in milliseconds. The +// event name the relay should report goes on the command line. On Windows, +// Gemini CLI runs hooks with `powershell -Command`. AfterModel is left out: it +// fires on every response chunk and would flood the island. + +const GEMINI_EVENTS: &[(&str, &str, u64)] = &[ + ("SessionStart", "SessionStart", 10000), + ("SessionEnd", "SessionEnd", 10000), + ("BeforeTool", "PreToolUse", 5000), + ("AfterTool", "PostToolUse", 5000), + ("BeforeAgent", "UserPromptSubmit", 5000), + ("AfterAgent", "Stop", 5000), +]; + +fn gemini_install(root: &Value, commands: &[(String, String, u64)]) -> Result { + let events = commands.iter().map(|(event, command, timeout)| { + ( + event.clone(), + json!({ "matcher": "*", "hooks": [{ "type": "command", "command": command, "timeout": timeout }] }), + ) + }); + groups_install(root, "gemini", events).map(Value::Object) +} + +// ── Antigravity — ~/.gemini/config/hooks.json ───────────────────────────────── +// +// Hooks are named groups at the top level; Coucou's is `coucou`. Tool events +// take matcher groups, lifecycle events take handlers directly; timeouts are +// in seconds. Merge and removal come from #298 (kobaltgit). The relay answers +// PreToolUse with "{}" — no decision — never with an allow: Antigravity's own +// permission rules stay in charge. + +const ANTIGRAVITY_TOOL_EVENTS: &[&str] = &["PreToolUse", "PostToolUse"]; +const ANTIGRAVITY_LIFECYCLE_EVENTS: &[&str] = &["PreInvocation", "PostInvocation", "Stop"]; + +fn antigravity_block(relay: &Relay) -> Value { + let handler = |event: &str| { + json!({ + "type": "command", + "command": relay.command(Shell::Cmd, &format!("--agent antigravity {event}")), + "timeout": 10, + }) + }; + let mut block = Map::new(); + for event in ANTIGRAVITY_TOOL_EVENTS { + block.insert((*event).into(), json!([{ "matcher": "*", "hooks": [handler(event)] }])); + } + for event in ANTIGRAVITY_LIFECYCLE_EVENTS { + block.insert((*event).into(), json!([handler(event)])); + } + Value::Object(block) +} + +/// A `coucou` group Coucou wrote (it runs the relay as `--agent antigravity`). +fn antigravity_is_ours(group: &Value) -> bool { + let text = group.to_string(); + text.contains(MARKER) && text.contains("--agent antigravity") +} + +fn antigravity_install(root: &Value, block: &Value) -> Result { + let mut root = root.as_object().cloned().unwrap_or_default(); + if root.get("coucou").is_some_and(|g| !antigravity_is_ours(g)) { + return Err(crate::i18n::t("A hook group named \"coucou\" that Coucou did not write is already there — Coucou has not touched it.")); + } + root.insert("coucou".into(), block.clone()); + Ok(Value::Object(root)) +} + +/// Removes Coucou's group, and only if it is Coucou's. +fn antigravity_uninstall(root: &Value) -> Result { + let mut root = root.as_object().cloned().unwrap_or_default(); + if root.get("coucou").is_some_and(antigravity_is_ours) { + root.remove("coucou"); + } + Ok(Value::Object(root)) +} + +// ── Cursor Agent — ~/.cursor/hooks.json ─────────────────────────────────────── +// +// `{"version": 1, "hooks": {"": [{"command": …}]}}`, camelCase events +// (from #231, BeyondBirthday07). The relay maps them, turns a stop with +// `status: "error"` into StopFailure, and prints nothing back: no permission is +// ever given on Cursor's behalf. Every session lands on the one `agent_cursor` +// pill. + +const CURSOR_EVENTS: &[&str] = &[ + "sessionStart", + "sessionEnd", + "beforeSubmitPrompt", + "preToolUse", + "postToolUse", + "postToolUseFailure", + "stop", +]; + +fn cursor_install(root: &Value, command: &str) -> Result { + let events = CURSOR_EVENTS.iter().map(|e| (e.to_string(), json!({ "command": command }))); + let mut root = groups_install(root, "cursor", events)?; + root.entry("version").or_insert(json!(1)); + Ok(Value::Object(root)) +} + +// ── Codex — ~/.codex/hooks.json ─────────────────────────────────────────────── +// +// Claude-style groups without a matcher, timeouts in seconds; Codex sends the +// event name in the payload. PermissionRequest waits for the island (120 s, +// with a status line shown in Codex meanwhile) and is answered with Claude +// Code's hookSpecificOutput — only after a click. Codex runs hook commands with +// `cmd /C` on Windows and `$SHELL -lc` elsewhere, and only once the user has +// trusted them with /hooks. + +const CODEX_EVENTS: &[(&str, u64)] = &[ + ("SessionStart", 10), + ("UserPromptSubmit", 10), + ("PreToolUse", 10), + ("PermissionRequest", 120), + ("PostToolUse", 10), + ("Stop", 10), + ("SubagentStart", 10), + ("SubagentStop", 10), + ("Interrupt", 3), + ("SessionEnd", 3), +]; + +const CODEX_WAITING: &str = "Waiting for your answer in the island (Coucou)"; + +fn codex_install(root: &Value, command: &str) -> Result { + let events = CODEX_EVENTS.iter().map(|(event, timeout)| { + let mut hook = json!({ "type": "command", "command": command, "timeout": timeout }); + if *event == "PermissionRequest" { + hook["statusMessage"] = json!(CODEX_WAITING); + } + (event.to_string(), json!({ "hooks": [hook] })) + }); + groups_install(root, "codex", events).map(Value::Object) +} + +// ── GitHub Copilot CLI — ~/.copilot/hooks/coucou.json ───────────────────────── +// +// A file of Coucou's own in Copilot's hooks folder: camelCase events, each +// entry `{"type": "command", "bash": …, "timeoutSec": N}`, plus `powershell` +// on Windows, where Copilot runs that one. The event goes on the command line +// because Copilot does not put it in the payload. Copilot is fail-closed on +// permissionRequest: the relay always answers it with valid JSON — "ask" when +// nobody clicked. Removing the last of Coucou's entries removes the file. + +const COPILOT_EVENTS: &[(&str, u64)] = &[ + ("sessionStart", 10), + ("userPromptSubmitted", 10), + ("preToolUse", 10), + ("permissionRequest", 120), + ("postToolUse", 10), + ("agentStop", 10), + ("sessionEnd", 3), + ("notification", 10), +]; + +fn copilot_entry(relay: &Relay, event: &str, timeout: u64) -> Value { + let args = format!("--agent copilot {event}"); + let mut entry = json!({ "type": "command", "bash": relay.command(Shell::Sh, &args), "timeoutSec": timeout }); + if relay.windows { + entry["powershell"] = json!(relay.command(Shell::PowerShell, &args)); + } + entry +} + +fn copilot_entry_is_ours(entry: &Value) -> bool { + ["bash", "powershell", "command"].iter().any(|k| is_our_command(entry.get(*k), "copilot")) +} + +fn copilot_install(root: &Value, entries: &[(String, Value)]) -> Result { + let mut root = root.as_object().cloned().unwrap_or_default(); + let mut hooks = object_at(&root, "hooks")?; + for (event, entry) in entries { + let mut list = list_at(&hooks, event)?; + list.retain(|e| !copilot_entry_is_ours(e)); + list.push(entry.clone()); + hooks.insert(event.clone(), Value::Array(list)); + } + root.insert("hooks".into(), Value::Object(hooks)); + root.insert("version".into(), json!(1)); + Ok(Value::Object(root)) +} + +fn copilot_uninstall(root: &Value) -> Result, String> { + let mut root = root.as_object().cloned().unwrap_or_default(); + if root.contains_key("hooks") { + let mut hooks = object_at(&root, "hooks")?; + for list in hooks.values_mut() { + if let Value::Array(entries) = list { + entries.retain(|e| !copilot_entry_is_ours(e)); + } + } + hooks.retain(|_, v| !v.as_array().is_some_and(Vec::is_empty)); + if hooks.is_empty() { + root.remove("hooks"); + } else { + root.insert("hooks".into(), Value::Object(hooks)); + } + } + // Nothing of anyone else's left: the file was Coucou's, and goes. + if root.keys().all(|k| k == "version") { + return Ok(None); + } + Ok(Some(Value::Object(root))) +} + +// ── Muse Code — ~/.config/muse/settings.json ────────────────────────────────── +// +// Claude-style groups with a `*` matcher, PascalCase events, timeouts in +// milliseconds. Permission requests get the island's card and a bare +// permissionDecision back. + +const MUSE_EVENTS: &[(&str, u64)] = &[ + ("SessionStart", 10), + ("UserPromptSubmit", 5), + ("PreToolUse", 5), + ("PermissionRequest", 120), + ("PostToolUse", 5), + ("Stop", 5), + ("SessionEnd", 3), +]; + +fn muse_install(root: &Value, commands: &[(String, String, u64)]) -> Result { + let fresh = root.as_object().is_none_or(Map::is_empty); + let events = commands.iter().map(|(event, command, timeout)| { + ( + event.clone(), + json!({ "matcher": "*", "hooks": [{ "type": "command", "command": command, "timeout": timeout }] }), + ) + }); + let mut root = groups_install(root, "muse", events)?; + if fresh { + root.insert("schema_version".into(), json!(1)); + } + Ok(Value::Object(root)) +} + +// ── Plugins: OpenCode, Amp, Hermes ──────────────────────────────────────────── +// +// These agents load code rather than run hook commands. The Mac's plugins +// start `/bin/sh` on a script at a macOS path; these start the relay itself, +// at this machine's path, with no shell in between. Every one is +// fire-and-forget: the agent never waits on Coucou, and if Coucou is closed +// nothing happens. None of them ever answers a permission: Hermes keeps its +// approvals (as on the Mac), and Amp's steps come from `tool.result` so the +// plugin never has to return a verdict from `tool.call`. + +/// Every plugin file Coucou writes says so; only such a file is replaced or removed. +const GENERATED: &str = "generated by Coucou"; + +fn is_our_plugin(text: &str) -> bool { + text.contains(GENERATED) +} + +fn plugin_edit(path: PathBuf, content: String, install: bool) -> FileEdit<'static> { + let label = path.display().to_string(); + let name = label.clone(); + let edit = config_file::text_edit(label, move |current| match (install, current) { + (_, Some(text)) if !is_our_plugin(text) => { + Err(crate::i18n::tf("{name} wasn't written by Coucou — Coucou has not touched it.", &[("name", &name.to_string())])) + } + (true, _) => Ok(Some(content.clone())), + (false, _) => Ok(None), + }); + FileEdit { path, edit } +} + +const OPENCODE_PLUGIN: &str = r#"// Coucou plugin for OpenCode — generated by Coucou. +// Forwards OpenCode's events to Coucou's relay (coucou-hook), fire-and-forget: +// OpenCode never waits on it, and nothing happens when Coucou is closed. +import { spawn } from 'node:child_process'; + +const HOOK = {HOOK}; +const EVENT_MAP = { + 'session.created': 'SessionStart', + 'session.idle': 'Stop', + 'session.error': 'StopFailure', + 'session.deleted': 'SessionEnd', +}; + +function forward(hook_event_name, payload) { + try { + const p = spawn(HOOK, ['--agent', 'opencode'], { + stdio: ['pipe', 'ignore', 'ignore'], + detached: process.platform !== 'win32', + windowsHide: true, + }); + p.on('error', () => {}); + p.stdin.on('error', () => {}); + p.stdin.end(JSON.stringify({ hook_event_name, ...payload }) + '\n'); + p.unref(); + } catch {} +} + +export const CoucouPlugin = async ({ directory } = {}) => ({ + event: async ({ event }) => { + const hook_event_name = EVENT_MAP[event?.type]; + if (!hook_event_name) return; + const props = event.properties || {}; + forward(hook_event_name, { + session_id: props.sessionID || props.info?.id || '', + cwd: directory || '', + }); + }, + 'tool.execute.before': async (input, output) => { + forward('PreToolUse', { + session_id: input?.sessionID || '', + cwd: directory || '', + tool_name: typeof input?.tool === 'string' ? input.tool : '', + tool_input: output?.args ?? null, + }); + }, + 'tool.execute.after': async (input) => { + forward('PostToolUse', { + session_id: input?.sessionID || '', + cwd: directory || '', + tool_name: typeof input?.tool === 'string' ? input.tool : '', + }); + }, +}); +"#; + +const AMP_PLUGIN: &str = r#"// Coucou plugin for Amp — generated by Coucou. +// Forwards Amp's events to Coucou's relay (coucou-hook), fire-and-forget and +// display only: Amp never waits on it, and it never decides anything for Amp. +import { spawn } from 'node:child_process'; + +const HOOK = {HOOK}; + +function forward(hook_event_name: string, fields: Record): void { + try { + const p = spawn(HOOK, ['--agent', 'amp'], { + stdio: ['pipe', 'ignore', 'ignore'], + detached: process.platform !== 'win32', + windowsHide: true, + }); + p.on('error', () => {}); + p.stdin?.on('error', () => {}); + p.stdin?.end(JSON.stringify({ hook_event_name, ...fields }) + '\n'); + p.unref(); + } catch {} +} + +export default function (amp: any): void { + const session = (e: any) => ({ session_id: e?.thread?.id ?? '' }); + amp.on('session.start', (e: any) => { forward('SessionStart', session(e)); }); + amp.on('agent.start', (e: any) => { + forward('UserPromptSubmit', { ...session(e), prompt: typeof e?.message === 'string' ? e.message : '' }); + }); + // A step per tool once it has run: listening to tool.call would mean + // returning a verdict for Amp, and Coucou never makes one. + amp.on('tool.result', (e: any) => { + forward('PreToolUse', { ...session(e), tool_name: typeof e?.tool === 'string' ? e.tool : '', tool_input: e?.input ?? null }); + }); + amp.on('agent.end', (e: any) => { forward('Stop', { ...session(e), status: e?.status ?? '' }); }); +} +"#; + +const HERMES_PLUGIN: &str = r#"# Coucou plugin for Hermes Agent — generated by Coucou. +# Session and tool events go to Coucou's relay (coucou-hook), fire-and-forget: +# Hermes never waits on it, and keeps every approval decision to itself. +import json, os, subprocess, threading + +HOOK = {HOOK} +_current_session_id = '' + + +def _fire(fields): + """Non-blocking: start the relay and return at once. Reaps it in a thread.""" + def _run(): + try: + extra = {'start_new_session': True} if os.name != 'nt' else { + 'creationflags': getattr(subprocess, 'CREATE_NO_WINDOW', 0)} + p = subprocess.Popen([HOOK, '--agent', 'hermes'], stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **extra) + p.stdin.write(json.dumps(fields).encode() + b'\n') + p.stdin.close() + p.wait(timeout=5) + except Exception: + pass + threading.Thread(target=_run, daemon=True).start() + + +def register(ctx): + def on_session_start(**kwargs): + global _current_session_id + sid = kwargs.get('session_id', '') + _current_session_id = sid + platform = kwargs.get('platform', 'cli') or 'cli' + _fire({'hook_event_name': 'SessionStart', 'session_id': sid, 'platform': platform}) + + def on_session_end(**kwargs): + # Stop comes from post_llm_call, which has the last answer. + if kwargs.get('interrupted'): + _fire({'hook_event_name': 'StopFailure', 'session_id': kwargs.get('session_id', '')}) + + def post_llm_call(**kwargs): + _fire({'hook_event_name': 'Stop', + 'session_id': kwargs.get('session_id', '') or _current_session_id, + 'last_assistant_message': kwargs.get('assistant_response', '')}) + + def pre_tool_call(**kwargs): + _fire({'hook_event_name': 'PreToolUse', + 'session_id': kwargs.get('session_id', '') or _current_session_id, + 'tool_name': kwargs.get('tool_name', ''), + 'tool_input': kwargs.get('args') or {}}) + + def post_tool_call(**kwargs): + _fire({'hook_event_name': 'PostToolUse', + 'session_id': kwargs.get('session_id', '') or _current_session_id, + 'tool_name': kwargs.get('tool_name', '')}) + + def pre_approval_request(**kwargs): + # Observer only: Hermes asks and decides; the island just says so. + _fire({'hook_event_name': 'PreToolUse', + 'session_id': kwargs.get('session_key', '') or _current_session_id, + 'tool_name': '⏳ Approval pending in Hermes', + 'tool_input': {'command': kwargs.get('command', ''), + 'description': kwargs.get('description', '')}}) + + ctx.register_hook('on_session_start', on_session_start) + ctx.register_hook('on_session_end', on_session_end) + ctx.register_hook('post_llm_call', post_llm_call) + ctx.register_hook('pre_tool_call', pre_tool_call) + ctx.register_hook('post_tool_call', post_tool_call) + ctx.register_hook('pre_approval_request', pre_approval_request) +"#; + +const HERMES_PLUGIN_YAML: &str = r#"name: coucou +version: "1.0" +description: Coucou island integration — generated by Coucou +"#; + +#[cfg(test)] +mod tests { + use super::*; + use crate::config_file::tests::scratch; + + fn linux() -> Relay { + Relay { exe: "/home/me/.local/share/coucou/bin/coucou-hook".into(), windows: false } + } + + fn windows(exe: &str) -> Relay { + Relay { exe: exe.into(), windows: true } + } + + const WIN: &str = r"C:\Users\me\AppData\Local\Coucou\bin\coucou-hook.exe"; + const WIN_SPACE: &str = r"C:\Users\Jane O'Neil\AppData\Local\Coucou\bin\coucou-hook.exe"; + + /// Installs then uninstalls `agent` in a fresh home holding `existing` in + /// its first file, and returns (installed file, uninstalled file). + fn round_trip(agent: Agent, existing: Option<&str>) -> (PathBuf, Value, Option) { + // Tests run in parallel: every round trip gets a home of its own. + static RUN: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let run = RUN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let home = scratch(&format!("agent-{}-{run}", agent.id())); + let file = agent.files(&home)[0].clone(); + if let Some(text) = existing { + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, text).unwrap(); + } + let relay = linux(); + let plan = config_file::preview(&agent.edits(&home, &relay, true)).unwrap(); + assert!(plan.diff.contains(MARKER), "{}", plan.diff); + apply_in(agent, &home, &relay, true, &plan.fingerprint).unwrap(); + assert!(agent.installed(&home), "{agent:?} should read as installed"); + let installed = read_json(&file); + + let plan = config_file::preview(&agent.edits(&home, &relay, false)).unwrap(); + apply_in(agent, &home, &relay, false, &plan.fingerprint).unwrap(); + assert!(!agent.installed(&home), "{agent:?} should read as removed"); + let removed = file.exists().then(|| read_json(&file)); + (home, installed, removed) + } + + fn read_json(path: &Path) -> Value { + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() + } + + #[test] + fn the_command_is_quoted_for_the_shell_that_runs_it() { + assert_eq!( + linux().command(Shell::Cmd, "--agent codex"), + "'/home/me/.local/share/coucou/bin/coucou-hook' --agent codex" + ); + let w = windows(WIN); + assert_eq!( + w.command(Shell::Sh, "Stop"), + "\"C:/Users/me/AppData/Local/Coucou/bin/coucou-hook.exe\" Stop" + ); + assert_eq!( + w.command(Shell::PowerShell, "--agent gemini Stop"), + format!("& '{WIN}' --agent gemini Stop") + ); + // A plain path is left bare, so cmd and PowerShell both run it. + assert_eq!(w.command(Shell::Cmd, "--agent cursor"), format!("{WIN} --agent cursor")); + + let spaced = windows(WIN_SPACE); + assert_eq!(spaced.command(Shell::Cmd, "x"), format!("\"{WIN_SPACE}\" x")); + assert_eq!( + spaced.command(Shell::PowerShell, "x"), + r"& 'C:\Users\Jane O''Neil\AppData\Local\Coucou\bin\coucou-hook.exe' x" + ); + } + + #[test] + fn the_hook_path_is_one_shell_word_whatever_it_contains() { + assert_eq!(sh_quote("/home/a b/x"), "'/home/a b/x'"); + assert_eq!(sh_quote(r#"/h/$(id)`x`\"y"#), r#"'/h/$(id)`x`\"y'"#); + assert_eq!(sh_quote("/h/it's"), r"'/h/it'\''s'"); + } + + #[test] + fn every_agent_id_is_a_valid_pill_name() { + for agent in Agent::ALL { + let id = agent.id(); + assert!(id.len() <= 24 && id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')); + assert_eq!(Agent::from_id(id), Some(*agent)); + } + assert!(find("claude").is_err()); + } + + #[test] + fn gemini_keeps_foreign_hooks_and_removes_only_its_own() { + let existing = r#"{"theme":"dark","hooks":{"BeforeTool":[{"matcher":"other","hooks":[{"type":"command","command":"custom.exe"}]}]}}"#; + let (home, installed, removed) = round_trip(Agent::Gemini, Some(existing)); + assert_eq!(installed["theme"], "dark"); + let before_tool = installed["hooks"]["BeforeTool"].as_array().unwrap(); + assert_eq!(before_tool.len(), 2); + assert_eq!(before_tool[0]["matcher"], "other"); + assert_eq!(before_tool[1]["matcher"], "*"); + assert_eq!(before_tool[1]["hooks"][0]["timeout"], 5000); + let cmd = before_tool[1]["hooks"][0]["command"].as_str().unwrap(); + assert!(cmd.ends_with("--agent gemini PreToolUse"), "{cmd}"); + assert!(installed["hooks"]["AfterModel"].is_null()); + assert_eq!(removed.unwrap(), serde_json::from_str::(existing).unwrap()); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn installing_twice_leaves_one_entry_per_event() { + let once = gemini_install(&json!({}), &[("BeforeTool".into(), "'x/coucou-hook' --agent gemini PreToolUse".into(), 5000)]).unwrap(); + let twice = gemini_install(&once, &[("BeforeTool".into(), "'y/coucou-hook' --agent gemini PreToolUse".into(), 5000)]).unwrap(); + assert_eq!(twice["hooks"]["BeforeTool"].as_array().unwrap().len(), 1); + } + + #[test] + fn unexpected_types_are_refused_for_every_json_agent() { + for agent in Agent::ALL { + let home = scratch(&format!("odd-{}", agent.id())); + let file = agent.files(&home)[0].clone(); + if file.extension().is_some_and(|e| e == "json") { + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + let shaped: &[&str] = match agent { + Agent::Antigravity => &[r#"{"coucou":"nope"}"#], + _ => &[r#"{"hooks":"nope"}"#, r#"{"hooks":[1]}"#], + }; + for odd in shaped.iter().copied().chain(["[1,2]", "{ broken", "\"text\""]) { + std::fs::write(&file, odd).unwrap(); + let edits = agent.edits(&home, &linux(), true); + assert!(config_file::preview(&edits).is_err(), "{agent:?} accepted {odd}"); + assert_eq!(std::fs::read_to_string(&file).unwrap(), odd); + } + } + let _ = std::fs::remove_dir_all(home); + } + } + + #[test] + fn antigravity_gets_its_own_named_group_and_nothing_else_changes() { + let existing = r#"{"my-guard":{"PreToolUse":[{"matcher":"run_command","hooks":[{"command":"/bin/guard"}]}]}}"#; + let (home, installed, removed) = round_trip(Agent::Antigravity, Some(existing)); + assert_eq!(installed["my-guard"]["PreToolUse"][0]["matcher"], "run_command"); + let ours = &installed["coucou"]; + for event in ANTIGRAVITY_TOOL_EVENTS { + assert_eq!(ours[event][0]["matcher"], "*"); + let cmd = ours[event][0]["hooks"][0]["command"].as_str().unwrap(); + assert!(cmd.ends_with(&format!("--agent antigravity {event}")), "{cmd}"); + } + for event in ANTIGRAVITY_LIFECYCLE_EVENTS { + assert_eq!(ours[event][0]["timeout"], 10); + assert!(ours[event][0]["command"].as_str().unwrap().contains(MARKER)); + } + assert_eq!(removed.unwrap(), serde_json::from_str::(existing).unwrap()); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn a_coucou_group_someone_else_wrote_is_neither_replaced_nor_removed() { + let theirs = json!({ "coucou": { "Stop": [{ "command": "/bin/notify" }] } }); + assert!(antigravity_install(&theirs, &antigravity_block(&linux())).is_err()); + assert_eq!(antigravity_uninstall(&theirs).unwrap(), theirs); + } + + #[test] + fn on_windows_antigravity_runs_the_relay_path_quoted_only_when_needed() { + let block = antigravity_block(&windows(WIN)); + assert_eq!(block["Stop"][0]["command"], format!("{WIN} --agent antigravity Stop")); + let block = antigravity_block(&windows(WIN_SPACE)); + assert_eq!(block["Stop"][0]["command"], format!("\"{WIN_SPACE}\" --agent antigravity Stop")); + } + + #[test] + fn cursor_uses_its_native_format_and_keeps_the_users_hooks() { + let existing = r#"{"version":1,"hooks":{"afterFileEdit":[{"command":"./format.sh"}],"stop":[{"command":"./notify.sh"}]}}"#; + let (home, installed, removed) = round_trip(Agent::Cursor, Some(existing)); + assert_eq!(installed["version"], 1); + for event in CURSOR_EVENTS { + let last = installed["hooks"][event].as_array().unwrap().last().unwrap().clone(); + assert!(last["command"].as_str().unwrap().ends_with("--agent cursor"), "{event}"); + } + assert_eq!(installed["hooks"]["stop"][0]["command"], "./notify.sh"); + assert_eq!(installed["hooks"]["afterFileEdit"][0]["command"], "./format.sh"); + assert_eq!(removed.unwrap(), serde_json::from_str::(existing).unwrap()); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn a_new_cursor_file_gets_its_version() { + let after = cursor_install(&json!({}), "'x/coucou-hook' --agent cursor").unwrap(); + assert_eq!(after["version"], 1); + // A version the user set is theirs. + let after = cursor_install(&json!({ "version": 2 }), "c").unwrap(); + assert_eq!(after["version"], 2); + } + + #[test] + fn codex_hooks_match_the_macs_and_leave_the_rest_alone() { + let existing = r#"{"description":"mine","hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"guard.sh"}]}]}}"#; + let (home, installed, removed) = round_trip(Agent::Codex, Some(existing)); + assert_eq!(installed["description"], "mine"); + for (event, timeout) in CODEX_EVENTS { + let ours = installed["hooks"][event].as_array().unwrap().last().unwrap()["hooks"][0].clone(); + assert_eq!(ours["timeout"], *timeout, "{event}"); + assert!(ours["command"].as_str().unwrap().ends_with("--agent codex"), "{event}"); + assert_eq!(ours.get("statusMessage").is_some(), *event == "PermissionRequest"); + } + assert_eq!(installed["hooks"]["PreToolUse"][0]["matcher"], "Bash"); + assert_eq!(removed.unwrap(), serde_json::from_str::(existing).unwrap()); + assert!(Agent::Codex.approvals()); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn on_windows_codex_gets_a_command_cmd_can_run() { + let after = codex_install(&json!({}), &windows(WIN_SPACE).command(Shell::Cmd, "--agent codex")).unwrap(); + assert_eq!(after["hooks"]["Stop"][0]["hooks"][0]["command"], format!("\"{WIN_SPACE}\" --agent codex")); + } + + #[test] + fn copilot_gets_a_file_of_its_own_which_goes_when_coucou_leaves() { + let (home, installed, removed) = round_trip(Agent::Copilot, None); + assert_eq!(installed["version"], 1); + for (event, timeout) in COPILOT_EVENTS { + let entry = &installed["hooks"][event][0]; + assert_eq!(entry["timeoutSec"], *timeout); + assert!(entry["bash"].as_str().unwrap().ends_with(&format!("--agent copilot {event}"))); + // No PowerShell line on Linux. + assert!(entry.get("powershell").is_none()); + } + assert!(removed.is_none(), "the file should be gone"); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn copilot_keeps_what_someone_else_added_to_the_file() { + let existing = r#"{"version":1,"hooks":{"sessionStart":[{"type":"command","bash":"echo hi","timeoutSec":5}]}}"#; + let (home, installed, removed) = round_trip(Agent::Copilot, Some(existing)); + assert_eq!(installed["hooks"]["sessionStart"].as_array().unwrap().len(), 2); + assert_eq!(removed.unwrap(), serde_json::from_str::(existing).unwrap()); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn on_windows_copilot_gets_a_powershell_line_too() { + let entry = copilot_entry(&windows(WIN), "preToolUse", 10); + assert_eq!(entry["powershell"], format!("& '{WIN}' --agent copilot preToolUse")); + assert_eq!( + entry["bash"], + "\"C:/Users/me/AppData/Local/Coucou/bin/coucou-hook.exe\" --agent copilot preToolUse" + ); + } + + #[test] + fn muse_hooks_use_milliseconds_and_a_schema_version_for_a_new_file() { + let (home, installed, removed) = round_trip(Agent::Muse, None); + assert_eq!(installed["schema_version"], 1); + assert_eq!(installed["hooks"]["PermissionRequest"][0]["hooks"][0]["timeout"], 120_000); + assert_eq!(installed["hooks"]["PreToolUse"][0]["matcher"], "*"); + assert!(installed["hooks"]["Stop"][0]["hooks"][0]["command"].as_str().unwrap().ends_with("--agent muse Stop")); + // Only Coucou's hooks go; the file and its schema_version stay. + assert_eq!(removed.unwrap(), json!({ "schema_version": 1 })); + let _ = std::fs::remove_dir_all(home); + + let existing = r#"{"model":"m","hooks":{"Stop":[{"matcher":"*","hooks":[{"type":"command","command":"say done"}]}]}}"#; + let (home, installed, removed) = round_trip(Agent::Muse, Some(existing)); + assert!(installed.get("schema_version").is_none()); + assert_eq!(removed.unwrap(), serde_json::from_str::(existing).unwrap()); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn only_codex_copilot_and_muse_take_approvals() { + let with: Vec<&str> = Agent::ALL.iter().filter(|a| a.approvals()).map(|a| a.id()).collect(); + assert_eq!(with, ["codex", "copilot", "muse"]); + } + + #[test] + fn plugins_call_the_relay_directly_and_go_away_whole() { + for agent in [Agent::OpenCode, Agent::Amp, Agent::Hermes] { + let home = scratch(&format!("plugin-{}", agent.id())); + let relay = linux(); + let plan = config_file::preview(&agent.edits(&home, &relay, true)).unwrap(); + apply_in(agent, &home, &relay, true, &plan.fingerprint).unwrap(); + assert!(agent.installed(&home), "{agent:?}"); + let main = std::fs::read_to_string(&agent.files(&home)[0]).unwrap(); + // The relay itself, at this machine's path, as a string literal: no + // shell, no macOS path. + assert!(main.contains(r#""/home/me/.local/share/coucou/bin/coucou-hook""#), "{agent:?}"); + assert!(!main.contains("/bin/sh") && !main.contains("nb-hook"), "{agent:?}"); + assert!(main.contains(&format!("'--agent', '{}'", agent.id())), "{agent:?}"); + // Never a verdict for the agent. + assert!(!main.contains("action: 'allow'") && !main.contains("register_approval_transport")); + + let plan = config_file::preview(&agent.edits(&home, &relay, false)).unwrap(); + apply_in(agent, &home, &relay, false, &plan.fingerprint).unwrap(); + assert!(!agent.installed(&home)); + for file in agent.files(&home) { + assert!(!file.exists(), "{}", file.display()); + } + let _ = std::fs::remove_dir_all(home); + } + } + + #[test] + fn a_plugin_file_coucou_did_not_write_is_left_alone() { + let home = scratch("plugin-foreign"); + let file = Agent::OpenCode.files(&home)[0].clone(); + std::fs::create_dir_all(file.parent().unwrap()).unwrap(); + std::fs::write(&file, "export const Mine = async () => ({});\n").unwrap(); + for install in [true, false] { + let err = config_file::preview(&Agent::OpenCode.edits(&home, &linux(), install)).unwrap_err(); + assert!(err.contains("wasn't written by Coucou"), "{err}"); + } + assert_eq!(std::fs::read_to_string(&file).unwrap(), "export const Mine = async () => ({});\n"); + let _ = std::fs::remove_dir_all(home); + } + + #[test] + fn a_windows_path_is_a_valid_string_in_every_plugin() { + let plugins = Agent::Hermes.plugin(&windows(WIN_SPACE)).unwrap(); + let literal = serde_json::to_string(WIN_SPACE).unwrap(); + assert!(plugins[0].contains(&format!("HOOK = {literal}"))); + assert!(literal.contains(r"C:\\Users\\Jane O'Neil")); + // plugin.yaml is ours too, so removing it is allowed. + assert!(is_our_plugin(&plugins[1])); + } + + #[test] + fn another_tools_entry_for_an_event_with_the_wrong_shape_is_refused() { + let odd = json!({ "hooks": { "BeforeTool": { "matcher": "x" } } }); + assert!(gemini_install(&odd, &[("BeforeTool".into(), "c".into(), 1)]).is_err()); + } +} diff --git a/windows/src-tauri/src/chat.rs b/windows/src-tauri/src/chat.rs new file mode 100644 index 000000000..834e2ef86 --- /dev/null +++ b/windows/src-tauri/src/chat.rs @@ -0,0 +1,367 @@ +// The chat, whoever answers it: the conversation, the system prompt, and which +// provider a turn goes to. Each provider's own wire format lives in its module: +// claude.rs (Anthropic), openai_compat.rs (OpenAI, Google AI, OpenRouter) and +// local_chat.rs (Ollama, LM Studio, any OpenAI-compatible server). +// +// API keys never leave the credential store and file bytes never cross the IPC +// boundary: the island sends the question and gets the answer's text back. + +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tauri::AppHandle; + +use crate::settings::Settings; +use crate::{claude, local_chat, openai_compat, secrets}; + +pub const ANTHROPIC: &str = "anthropic"; + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum ChatContext { + File { name: String, path: String }, + Window { app_name: String, title: String, url: Option }, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChatReply { + pub text: String, +} + +/// A model a provider offers, for the picker in the chat view. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ModelInfo { + pub id: String, + pub label: String, +} + +// ── Conversation ────────────────────────────────────────────────────────────── + +/// The conversation is kept twice: in the wire format of the provider that +/// answered last (Claude's carries web search blocks no other provider would +/// understand), and as plain text turns. Switching provider mid-conversation +/// rebuilds the history from the plain turns, so nothing in one provider's +/// format is ever sent to another. +#[derive(Default)] +pub struct Chat { + inner: Mutex, +} + +#[derive(Default)] +struct Conversation { + /// Bumped by every reset, so an answer that lands after "New chat" is dropped. + epoch: u64, + /// The provider `native` belongs to. + owner: Option, + native: Vec, + /// `{"role", "content": text}` turns, the same whoever answered. + plain: Vec, +} + +/// What a provider needs to build one turn. +pub struct Turn { + epoch: u64, + provider: String, + /// No earlier turn: the file or window context rides along with this one. + pub first: bool, + /// The earlier turns, in this provider's format. + pub history: Vec, +} + +impl Chat { + pub fn reset(&self) { + let mut c = self.inner.lock().unwrap(); + let epoch = c.epoch + 1; + *c = Conversation { epoch, ..Default::default() }; + } + + /// Starts a turn with `provider`, converting the history if another + /// provider answered the previous turns. + pub fn begin(&self, provider: &str) -> Turn { + let mut c = self.inner.lock().unwrap(); + if c.owner.as_deref() != Some(provider) { + c.native = c.plain.clone(); + c.owner = Some(provider.to_string()); + } + Turn { + epoch: c.epoch, + provider: provider.to_string(), + first: c.plain.is_empty(), + history: c.native.clone(), + } + } + + /// Records a finished turn: the user message and the answer in the + /// provider's format, and their plain text. Only a successful turn is + /// recorded, so the history always matches what the model saw. + pub fn commit(&self, turn: &Turn, user: Value, assistant: Value, user_text: &str, answer: &str) { + let mut c = self.inner.lock().unwrap(); + if c.epoch != turn.epoch || c.owner.as_deref() != Some(turn.provider.as_str()) { + return; + } + c.native.push(user); + c.native.push(assistant); + c.plain.push(json!({ "role": "user", "content": user_text })); + c.plain.push(json!({ "role": "assistant", "content": answer })); + } +} + +// ── System prompt ───────────────────────────────────────────────────────────── + +/// Mochi's instructions. Greets the user by their first name when the account +/// has one worth using (identity.rs), and only claims web search where the +/// provider runs it (Claude). +pub fn system_prompt(web_search: bool) -> String { + system_prompt_for(crate::identity::first_name(), web_search) +} + +fn system_prompt_for(first_name: Option<&str>, web_search: bool) -> String { + let opening = match first_name { + Some(name) => format!("You are Mochi, {name}'s personal AI assistant living at the top of their screen."), + None => "You are Mochi, a personal AI assistant living at the top of the user's screen.".to_string(), + }; + let abilities = if web_search { + "You have web search access and can help with absolutely anything — research, coding, finding places, recommendations, tasks, questions." + } else { + "You can help with absolutely anything — research, coding, recommendations, tasks, questions. You have no web access: say so when something needs current information." + }; + format!( + "{opening} {abilities} \ +Respond in the user's language. Be thorough and complete — use as much detail as the task requires. \ +Use light Markdown when it helps: short paragraphs, bullet lists, **bold**, `inline code` and fenced code blocks. Avoid tables and big headings: the chat window is small." + ) +} + +/// The window context line, as ClaudeService.chat() writes it. +pub fn window_line(app_name: &str, title: &str, url: Option<&str>) -> String { + let mut text = format!("Context — App: {app_name}, Window: {title}"); + if let Some(url) = url { + text.push_str(&format!(", URL: {url}")); + } + text +} + +/// The plain-text record of what the user asked, context included. +pub fn plain_question(first: bool, context: Option<&ChatContext>, query: &str) -> String { + match context.filter(|_| first) { + Some(ChatContext::File { name, .. }) => format!("File: {name}\n\n{query}"), + Some(ChatContext::Window { app_name, title, url }) => { + format!("{}\n\n{query}", window_line(app_name, title, url.as_deref())) + } + None => query.to_string(), + } +} + +// ── Which provider ──────────────────────────────────────────────────────────── + +/// The model chosen for `provider`, or its default. +pub fn model_for(settings: &Settings, provider: &str) -> String { + if provider == ANTHROPIC { + let m = settings.model.trim(); + return if m.is_empty() { claude::DEFAULT_MODEL.to_string() } else { m.to_string() }; + } + settings + .chat_models + .get(provider) + .map(|m| m.trim().to_string()) + .filter(|m| !m.is_empty()) + .or_else(|| openai_compat::provider(provider).map(|p| p.default_model.to_string())) + .unwrap_or_default() +} + +/// A file rides along only if it is one of Coucou's own copies of a dropped +/// file (files.rs puts them in the inbox). The page names the path, so without +/// this any file the user can read could be sent to a chat provider. +fn checked_context(context: ChatContext) -> Result { + match context { + ChatContext::File { name, path } => { + let inbox = crate::files::inbox_dir(); + if !is_inside(&inbox, std::path::Path::new(&path)) { + return Err(crate::i18n::t("Only a file dropped on the island can be sent with a question.")); + } + Ok(ChatContext::File { name, path }) + } + other => Ok(other), + } +} + +/// True when `path` is a regular file directly inside `dir`, both resolved +/// (no `..`, no symlink pointing out of it). +fn is_inside(dir: &std::path::Path, path: &std::path::Path) -> bool { + let (Ok(dir), Ok(file)) = (dir.canonicalize(), path.canonicalize()) else { return false }; + file.parent() == Some(dir.as_path()) + && std::fs::symlink_metadata(&file).map(|m| m.is_file()).unwrap_or(false) +} + +/// One chat turn with the provider chosen in the settings. +pub async fn send( + app: &AppHandle, + chat: &Chat, + settings: &Settings, + query: String, + context: Option, +) -> Result { + let context = context.map(checked_context).transpose()?; + let provider = settings.chat_provider.as_str(); + let model = model_for(settings, provider); + if provider == ANTHROPIC || provider.is_empty() { + return claude::send(chat, &model, query, context).await; + } + if let Some(p) = openai_compat::provider(provider) { + return openai_compat::send(chat, p, &model, query, context).await; + } + if let Some(server) = local_chat::server(settings, provider) { + return local_chat::send(app, chat, &server, &model, query, context).await; + } + Err(format!("Unknown chat provider: {provider}")) +} + +/// The models `provider` offers. Asked only when the user opens the picker on +/// that provider, and only once it has a key (or, for a local server, an +/// address): nothing is sent anywhere before that. +pub async fn models(settings: &Settings, provider: &str) -> Result, String> { + let no_key = || crate::i18n::t("No API key — add it in Settings."); + if provider == ANTHROPIC { + let key = secrets::get(claude::KEY).ok_or_else(no_key)?; + return claude::models(&key).await; + } + if let Some(p) = openai_compat::provider(provider) { + let key = secrets::get(p.key).ok_or_else(no_key)?; + return openai_compat::models(p, &key).await; + } + if let Some(server) = local_chat::server(settings, provider) { + return local_chat::models(&server).await; + } + Err(format!("Unknown chat provider: {provider}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn turn_texts(history: &[Value]) -> Vec<(String, Value)> { + history + .iter() + .map(|m| (m["role"].as_str().unwrap().to_string(), m["content"].clone())) + .collect() + } + + #[test] + fn a_turn_is_recorded_only_once_it_succeeds() { + let chat = Chat::default(); + let t = chat.begin("anthropic"); + assert!(t.first); + assert!(t.history.is_empty()); + // A failed turn records nothing: the next one is still the first. + let t = chat.begin("anthropic"); + assert!(t.first); + chat.commit(&t, json!({"role":"user","content":[{"type":"text","text":"hi"}]}), json!({"role":"assistant","content":[{"type":"text","text":"hello"}]}), "hi", "hello"); + let t = chat.begin("anthropic"); + assert!(!t.first); + assert_eq!(t.history.len(), 2); + assert!(t.history[0]["content"].is_array()); + } + + #[test] + fn switching_provider_never_sends_claude_blocks_to_another_one() { + let chat = Chat::default(); + let t = chat.begin("anthropic"); + let blocks = json!([ + {"type":"server_tool_use","id":"srvtoolu_1","name":"web_search","input":{"query":"x"}}, + {"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":[]}, + {"type":"text","text":"Found it."} + ]); + chat.commit(&t, json!({"role":"user","content":[{"type":"text","text":"look"}]}), json!({"role":"assistant","content":blocks}), "look", "Found it."); + + let t = chat.begin("openai"); + assert!(!t.first); + assert_eq!( + turn_texts(&t.history), + vec![("user".into(), json!("look")), ("assistant".into(), json!("Found it."))] + ); + let raw = serde_json::to_string(&t.history).unwrap(); + assert!(!raw.contains("web_search"), "{raw}"); + + // And back: Claude gets the plain turns too, including OpenAI's answer. + chat.commit(&t, json!({"role":"user","content":"more"}), json!({"role":"assistant","content":"Sure."}), "more", "Sure."); + let t = chat.begin("anthropic"); + assert_eq!(t.history.len(), 4); + assert_eq!(t.history[3], json!({"role":"assistant","content":"Sure."})); + assert!(!serde_json::to_string(&t.history).unwrap().contains("web_search")); + } + + #[test] + fn an_answer_that_lands_after_a_reset_or_a_switch_is_dropped() { + let chat = Chat::default(); + let t = chat.begin("openai"); + chat.reset(); + chat.commit(&t, json!({"role":"user","content":"q"}), json!({"role":"assistant","content":"a"}), "q", "a"); + assert!(chat.begin("openai").first); + + let t = chat.begin("openai"); + let _other = chat.begin("google"); + chat.commit(&t, json!({"role":"user","content":"q"}), json!({"role":"assistant","content":"a"}), "q", "a"); + assert!(chat.begin("google").first); + } + + #[test] + fn the_prompt_greets_by_first_name_and_claims_web_search_only_for_claude() { + let p = system_prompt_for(Some("Louis"), true); + assert!(p.starts_with("You are Mochi, Louis's personal AI assistant living at the top of their screen.")); + assert!(p.contains("web search access")); + assert!(p.contains("light Markdown")); + let p = system_prompt_for(None, false); + assert!(p.starts_with("You are Mochi, a personal AI assistant living at the top of the user's screen.")); + assert!(!p.contains("web search")); + assert!(p.contains("no web access")); + } + + #[test] + fn context_goes_with_the_first_question_only() { + let file = ChatContext::File { name: "a.txt".into(), path: "/x/a.txt".into() }; + assert_eq!(plain_question(true, Some(&file), "why?"), "File: a.txt\n\nwhy?"); + assert_eq!(plain_question(false, Some(&file), "why?"), "why?"); + let win = ChatContext::Window { app_name: "Code".into(), title: "main.rs".into(), url: None }; + assert_eq!(plain_question(true, Some(&win), "q"), "Context — App: Code, Window: main.rs\n\nq"); + assert_eq!(window_line("Edge", "Docs", Some("https://x.dev")), "Context — App: Edge, Window: Docs, URL: https://x.dev"); + } + + #[test] + fn the_model_comes_from_the_settings_or_the_provider_default() { + let mut s = Settings::default(); + assert_eq!(model_for(&s, "anthropic"), claude::DEFAULT_MODEL); + assert_eq!(model_for(&s, "openai"), openai_compat::provider("openai").unwrap().default_model); + assert_eq!(model_for(&s, "ollama"), ""); + s.chat_models.insert("openai".into(), " gpt-x ".into()); + s.chat_models.insert("ollama".into(), "llama3.2".into()); + s.model = "claude-haiku-4-5".into(); + assert_eq!(model_for(&s, "openai"), "gpt-x"); + assert_eq!(model_for(&s, "ollama"), "llama3.2"); + assert_eq!(model_for(&s, "anthropic"), "claude-haiku-4-5"); + } + + #[test] + fn only_files_in_the_inbox_ride_along() { + let base = std::env::temp_dir().join(format!("coucou-chat-ctx-{}", std::process::id())); + let inbox = base.join("inbox"); + std::fs::create_dir_all(inbox.join("sub")).unwrap(); + std::fs::write(inbox.join("a.txt"), b"a").unwrap(); + std::fs::write(base.join("secret.txt"), b"s").unwrap(); + std::fs::write(inbox.join("sub").join("b.txt"), b"b").unwrap(); + assert!(is_inside(&inbox, &inbox.join("a.txt"))); + assert!(!is_inside(&inbox, &base.join("secret.txt"))); + assert!(!is_inside(&inbox, &inbox.join("..").join("secret.txt"))); + assert!(!is_inside(&inbox, &inbox.join("sub").join("b.txt"))); + assert!(!is_inside(&inbox, &inbox.join("missing.txt"))); + assert!(!is_inside(&inbox, &inbox)); + #[cfg(unix)] + { + std::os::unix::fs::symlink(base.join("secret.txt"), inbox.join("link.txt")).unwrap(); + assert!(!is_inside(&inbox, &inbox.join("link.txt"))); + } + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/windows/src-tauri/src/claude.rs b/windows/src-tauri/src/claude.rs index 060a57c64..8b47c359a 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -2,16 +2,26 @@ // chat with web search, and files sent as document/image/text blocks. // // Everything happens here rather than in the island: the API key never leaves -// the Credential Manager, and file bytes never cross the IPC boundary. +// the credential store, and file bytes never cross the IPC boundary. +// +// COUCOU_ANTHROPIC_BASE_URL points the chat at an Anthropic-compatible gateway +// instead (issue #180). Only that variable is read: Claude Code's own +// ANTHROPIC_BASE_URL may name a proxy the user never meant to hand this key to. -use std::sync::Mutex; +use std::sync::OnceLock; +use std::time::Duration; -use serde::{Deserialize, Serialize}; +use reqwest::Url; use serde_json::{json, Value}; -use crate::secrets; +use crate::chat::{self, Chat, ChatContext, ChatReply, ModelInfo}; +use crate::i18n::{t, tf}; +use crate::{net, secrets}; -const ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; +/// Credential store entry of the Anthropic API key. +pub const KEY: &str = "anthropic-api-key"; +const DEFAULT_ENDPOINT: &str = "https://api.anthropic.com/v1/messages"; +const BASE_URL_VAR: &str = "COUCOU_ANTHROPIC_BASE_URL"; const ANTHROPIC_VERSION: &str = "2023-06-01"; /// Server-side fallback: on a policy decline the API retries the same request on /// a fallback model inside the same call, so the island never shows a dead end. @@ -22,69 +32,45 @@ const MAX_INLINE_TEXT: u64 = 200_000; pub const DEFAULT_MODEL: &str = "claude-opus-5"; -const SYSTEM_PROMPT: &str = "You are Mochi, a personal AI assistant living at the top of the user's screen. \ -You have web search access and can help with absolutely anything — research, coding, finding places, recommendations, tasks, questions. \ -Respond in the user's language. Be thorough and complete — use as much detail as the task requires. \ -No markdown formatting (no **, no ##, no bullet dashes). Use plain text with line breaks."; - -#[derive(Default)] -pub struct Chat { - /// Full multi-turn history, including tool_use / tool_result blocks. - messages: Mutex>, -} - -impl Chat { - pub fn reset(&self) { - self.messages.lock().unwrap().clear(); - } - - fn is_empty(&self) -> bool { - self.messages.lock().unwrap().is_empty() - } - - fn push(&self, message: Value) { - self.messages.lock().unwrap().push(message); - } - - fn pop(&self) { - self.messages.lock().unwrap().pop(); - } - - fn snapshot(&self) -> Vec { - self.messages.lock().unwrap().clone() - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum ChatContext { - File { name: String, path: String }, - Window { app_name: String, title: String, url: Option }, +/// The Messages endpoint: Anthropic's, or the gateway in COUCOU_ANTHROPIC_BASE_URL. +/// Read once; the gateway's host (never the key) goes to the log once. +fn endpoint() -> Result { + static ENDPOINT: OnceLock> = OnceLock::new(); + ENDPOINT + .get_or_init(|| { + let raw = std::env::var(BASE_URL_VAR).ok().filter(|v| !v.trim().is_empty()); + let resolved = match raw { + None => Ok(Url::parse(DEFAULT_ENDPOINT).expect("valid default endpoint")), + Some(raw) => net::anthropic_endpoint(&raw), + }; + match &resolved { + Ok(url) if url.as_str() != DEFAULT_ENDPOINT => crate::log::line(format!( + "chat: Claude requests go to {} ({BASE_URL_VAR})", + net::host_for_log(url) + )), + Err(err) => crate::log::line(format!("chat: {err}")), + _ => {} + } + resolved + }) + .clone() } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ChatReply { - pub text: String, +/// The model list endpoint next to the Messages one. +fn models_endpoint(messages: &Url) -> Url { + let mut url = messages.clone(); + let path = url.path().trim_end_matches("/messages").to_string(); + url.set_path(&format!("{path}/models")); + url.set_query(Some("limit=100")); + url } -/// One chat turn. Returns the assistant's text, or a message the island shows -/// in the note view. -pub async fn send( - chat: &Chat, - model: &str, - query: String, - context: Option, -) -> Result { - let key = secrets::get("anthropic-api-key") - .ok_or_else(|| "API key missing. Open settings.".to_string())?; - +/// The user's content blocks for one turn. File / window context rides along +/// with the first message only, exactly like ClaudeService.chat(). +fn user_content(first: bool, context: Option<&ChatContext>, query: &str) -> Vec { let mut content: Vec = Vec::new(); - - // File / window context rides along with the first message only, exactly - // like ClaudeService.chat(). - if chat.is_empty() { - match &context { + if first { + match context { Some(ChatContext::File { name, path }) => { if let Some(block) = file_block(path) { content.push(block); @@ -92,79 +78,91 @@ pub async fn send( content.push(json!({ "type": "text", "text": format!("File: {name}") })); } Some(ChatContext::Window { app_name, title, url }) => { - let mut text = format!("Context — App: {app_name}, Window: {title}"); - if let Some(url) = url { - text.push_str(&format!(", URL: {url}")); - } - content.push(json!({ "type": "text", "text": text })); + let line = chat::window_line(app_name, title, url.as_deref()); + content.push(json!({ "type": "text", "text": line })); } None => {} } } content.push(json!({ "type": "text", "text": query })); + content +} - chat.push(json!({ "role": "user", "content": content })); - - let body = json!({ +fn request_body(model: &str, system: &str, history: &[Value], user: &Value) -> Value { + let mut messages = history.to_vec(); + messages.push(user.clone()); + json!({ "model": model, "max_tokens": MAX_TOKENS, - "system": SYSTEM_PROMPT, + "system": system, "tools": [{ "type": "web_search_20260209", "name": "web_search", "max_uses": 5 }], "fallbacks": "default", - "messages": chat.snapshot(), - }); - - let response = match call(&key, &body).await { - Ok(v) => v, - Err(err) => { - chat.pop(); // keep the history consistent with what the model saw - return Err(err); - } - }; + "messages": messages, + }) +} +/// The assistant's full text from a Messages API content array — the same as +/// claudeResponseText() on the Mac (#67). Web search answers interleave text +/// with tool blocks, and citations split a sentence across adjacent text +/// blocks: every text block is kept, joined as is, and only the whole trimmed. +pub fn response_text(content: &[Value]) -> Option { + let text: String = content + .iter() + .filter(|b| b.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .collect(); + let text = text.trim(); + (!text.is_empty()).then(|| text.to_string()) +} + +/// The content blocks to keep in the history and the text to show, or why there are none. +fn interpret(response: &Value) -> Result<(Vec, String), String> { // A policy decline comes back as HTTP 200 with stop_reason "refusal". if response.get("stop_reason").and_then(Value::as_str) == Some("refusal") { - chat.pop(); let why = response - .get("stop_details") - .and_then(|d| d.get("explanation")) + .pointer("/stop_details/explanation") .and_then(Value::as_str) - .unwrap_or("Claude declined this one."); - return Err(why.to_string()); + .map(str::to_string) + .unwrap_or_else(|| t("Claude declined this one.")); + return Err(why); } + let blocks = response + .get("content") + .and_then(Value::as_array) + .cloned() + .ok_or_else(|| t("Unexpected API response."))?; + let text = response_text(&blocks).ok_or_else(|| t("No response text."))?; + Ok((blocks, text)) +} - let Some(blocks) = response.get("content").and_then(Value::as_array).cloned() else { - chat.pop(); - return Err("Unexpected API response.".into()); - }; +/// One chat turn. Returns the assistant's text, or a message the island shows +/// in the note view. +pub async fn send( + chat: &Chat, + model: &str, + query: String, + context: Option, +) -> Result { + let key = secrets::get(KEY).ok_or_else(|| t("API key missing. Open settings."))?; + let endpoint = endpoint()?; - // Store the whole content — tool_use / tool_result blocks included — so the - // next turn has the right context. - chat.push(json!({ "role": "assistant", "content": blocks.clone() })); + let turn = chat.begin(chat::ANTHROPIC); + let user = json!({ "role": "user", "content": user_content(turn.first, context.as_ref(), &query) }); + let body = request_body(model, &chat::system_prompt(true), &turn.history, &user); - let text = blocks - .iter() - .filter(|b| b.get("type").and_then(Value::as_str) == Some("text")) - .filter_map(|b| b.get("text").and_then(Value::as_str)) - .collect::>() - .join("\n") - .trim() - .to_string(); + let response = call(&endpoint, &key, &body).await?; + let (blocks, text) = interpret(&response)?; - if text.is_empty() { - return Err("No response text.".into()); - } + // Store the whole content — tool_use / tool_result blocks included — so the + // next turn has the right context. + let plain = chat::plain_question(turn.first, context.as_ref(), &query); + chat.commit(&turn, user, json!({ "role": "assistant", "content": blocks }), &plain, &text); Ok(ChatReply { text }) } -async fn call(key: &str, body: &Value) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(90)) - .build() - .map_err(|e| e.to_string())?; - - let response = client - .post(ENDPOINT) +async fn call(endpoint: &Url, key: &str, body: &Value) -> Result { + let response = net::client(endpoint, Duration::from_secs(90))? + .post(endpoint.clone()) .header("x-api-key", key) .header("anthropic-version", ANTHROPIC_VERSION) .header("anthropic-beta", FALLBACK_BETA) @@ -172,24 +170,49 @@ async fn call(key: &str, body: &Value) -> Result { .json(body) .send() .await - .map_err(|e| format!("Network error: {e}"))?; + .map_err(|e| tf("Network error: {error}", &[("error", &e.to_string())]))?; let status = response.status(); - let text = response.text().await.map_err(|e| e.to_string())?; if !status.is_success() { // Surface the API's own message, which is what makes a bad key obvious. - let detail = serde_json::from_str::(&text) - .ok() - .and_then(|v| { - v.get("error") - .and_then(|e| e.get("message")) - .and_then(Value::as_str) - .map(str::to_string) - }) - .unwrap_or_else(|| text.chars().take(200).collect()); - return Err(format!("Claude API {status}: {detail}")); + let body = net::read_capped(response, net::MAX_ERROR_BODY).await.unwrap_or_default(); + return Err(format!("Claude API {status}: {}", net::error_detail(&body))); + } + let bytes = net::read_capped(response, net::MAX_BODY).await?; + serde_json::from_slice(&bytes).map_err(|e| tf("Bad API response: {error}", &[("error", &e.to_string())])) +} + +/// The models on the user's Anthropic account, newest first, as the API lists them. +pub async fn models(key: &str) -> Result, String> { + let url = models_endpoint(&endpoint()?); + let response = net::client(&url, Duration::from_secs(10))? + .get(url.clone()) + .header("x-api-key", key) + .header("anthropic-version", ANTHROPIC_VERSION) + .send() + .await + .map_err(|e| tf("Network error: {error}", &[("error", &e.to_string())]))?; + let status = response.status(); + if !status.is_success() { + let body = net::read_capped(response, net::MAX_ERROR_BODY).await.unwrap_or_default(); + return Err(format!("Claude API {status}: {}", net::error_detail(&body))); } - serde_json::from_str(&text).map_err(|e| format!("Bad API response: {e}")) + let bytes = net::read_capped(response, net::MAX_BODY).await?; + let json: Value = serde_json::from_slice(&bytes).map_err(|_| t("Unexpected API response."))?; + Ok(parse_models(&json)) +} + +fn parse_models(json: &Value) -> Vec { + json.get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|m| { + let id = m.get("id")?.as_str()?.to_string(); + let label = m.get("display_name").and_then(Value::as_str).unwrap_or(&id).to_string(); + Some(ModelInfo { id, label }) + }) + .collect() } /// PDF → document block, image → image block, text/code → inline text. @@ -227,7 +250,7 @@ fn file_block(path: &str) -> Option { } /// Small standalone base64 encoder — not worth another dependency. -/// Also used for Stripe's basic auth. +/// Also used for Stripe's basic auth and the images sent to other providers. pub(crate) fn base64_for(bytes: &[u8]) -> String { base64(bytes) } @@ -248,7 +271,7 @@ fn base64(bytes: &[u8]) -> String { #[cfg(test)] mod tests { - use super::base64; + use super::*; #[test] fn base64_matches_rfc4648_vectors() { @@ -260,4 +283,93 @@ mod tests { assert_eq!(base64(b"fooba"), "Zm9vYmE="); assert_eq!(base64(b"foobar"), "Zm9vYmFy"); } + + fn texts(parts: &[&str]) -> Vec { + parts.iter().map(|t| json!({ "type": "text", "text": t })).collect() + } + + #[test] + fn every_text_block_is_kept_in_order_without_added_separators() { + assert_eq!(response_text(&texts(&["Simple answer."])).as_deref(), Some("Simple answer.")); + let content = vec![ + json!({"type":"text","text":"I'll look that up.\n"}), + json!({"type":"server_tool_use","id":"srvtoolu_01","name":"web_search","input":{"query":"test"}}), + json!({"type":"web_search_tool_result","tool_use_id":"srvtoolu_01","content":[]}), + json!({"type":"text","text":"The answer is 42."}), + ]; + assert_eq!(response_text(&content).as_deref(), Some("I'll look that up.\nThe answer is 42.")); + // An empty leading block does not hide the answer. + let content = vec![ + json!({"type":"text","text":""}), + json!({"type":"web_search_tool_result","tool_use_id":"b","content":[]}), + json!({"type":"text","text":"Here is the actual answer."}), + ]; + assert_eq!(response_text(&content).as_deref(), Some("Here is the actual answer.")); + // Citations split a sentence across blocks: no newline is inserted. + assert_eq!( + response_text(&texts(&["Paris is the ", "capital", " of France."])).as_deref(), + Some("Paris is the capital of France.") + ); + // Search result text never leaks into the answer. + let content = vec![ + json!({"type":"text","text":"Answer."}), + json!({"type":"web_search_tool_result","tool_use_id":"a","content":[{"type":"web_search_result","title":"Page","text":"Leak"}]}), + ]; + assert_eq!(response_text(&content).as_deref(), Some("Answer.")); + assert_eq!(response_text(&[json!({"type":"server_tool_use","id":"x"})]), None); + assert_eq!(response_text(&texts(&["", " \n"])), None); + } + + #[test] + fn a_refusal_or_an_empty_answer_is_an_error() { + let refusal = json!({"stop_reason":"refusal","stop_details":{"explanation":"Not this."},"content":[]}); + assert_eq!(interpret(&refusal).unwrap_err(), "Not this."); + assert_eq!(interpret(&json!({"stop_reason":"refusal"})).unwrap_err(), "Claude declined this one."); + assert_eq!(interpret(&json!({"id":"x"})).unwrap_err(), "Unexpected API response."); + assert_eq!(interpret(&json!({"content":[]})).unwrap_err(), "No response text."); + let (blocks, text) = interpret(&json!({"content":[{"type":"text","text":" Hi "}]})).unwrap(); + assert_eq!(text, "Hi"); + assert_eq!(blocks.len(), 1); + } + + #[test] + fn the_request_carries_history_web_search_and_the_new_turn_last() { + let history = vec![json!({"role":"user","content":"a"}), json!({"role":"assistant","content":"b"})]; + let user = json!({"role":"user","content":[{"type":"text","text":"c"}]}); + let body = request_body("claude-x", "sys", &history, &user); + assert_eq!(body["model"], "claude-x"); + assert_eq!(body["system"], "sys"); + assert_eq!(body["max_tokens"], MAX_TOKENS); + assert_eq!(body["tools"][0]["name"], "web_search"); + assert_eq!(body["messages"].as_array().unwrap().len(), 3); + assert_eq!(body["messages"][2], user); + } + + #[test] + fn context_is_sent_with_the_first_turn_only() { + let ctx = ChatContext::Window { app_name: "Edge".into(), title: "Docs".into(), url: Some("https://x.dev".into()) }; + let first = user_content(true, Some(&ctx), "q"); + assert_eq!(first.len(), 2); + assert_eq!(first[0]["text"], "Context — App: Edge, Window: Docs, URL: https://x.dev"); + assert_eq!(user_content(false, Some(&ctx), "q"), vec![json!({"type":"text","text":"q"})]); + // A file that cannot be read still names itself. + let ctx = ChatContext::File { name: "gone.pdf".into(), path: "/no/such/gone.pdf".into() }; + assert_eq!(user_content(true, Some(&ctx), "q")[0]["text"], "File: gone.pdf"); + } + + #[test] + fn the_model_list_sits_next_to_the_messages_endpoint() { + let url = Url::parse(DEFAULT_ENDPOINT).unwrap(); + assert_eq!(models_endpoint(&url).as_str(), "https://api.anthropic.com/v1/models?limit=100"); + let url = net::anthropic_endpoint("https://gw.example.com/anthropic").unwrap(); + assert_eq!(models_endpoint(&url).as_str(), "https://gw.example.com/anthropic/v1/models?limit=100"); + let list = json!({"data":[{"id":"claude-opus-5","display_name":"Claude Opus 5"},{"id":"claude-x"},{"nope":1}]}); + assert_eq!( + parse_models(&list), + vec![ + ModelInfo { id: "claude-opus-5".into(), label: "Claude Opus 5".into() }, + ModelInfo { id: "claude-x".into(), label: "claude-x".into() }, + ] + ); + } } diff --git a/windows/src-tauri/src/codex_plan.rs b/windows/src-tauri/src/codex_plan.rs new file mode 100644 index 000000000..d3993aeca --- /dev/null +++ b/windows/src-tauri/src/codex_plan.rs @@ -0,0 +1,219 @@ +// Codex plan usage — the same source as CodexPlanGauge.swift on the Mac. +// +// Coucou asks the Codex CLI itself: `codex app-server` (the JSON-RPC server +// behind Codex's /status) is started, asked `account/rateLimits/read`, and +// stopped. Coucou makes no network call of its own and reads no credential: +// Codex answers with its own sign-in, exactly as /status does, and nothing is +// installed or written. It runs only when the pill shows or is clicked, at most +// once at a time, for 15 s at most, and is skipped while Coucou is paused. + +use std::cmp::Ordering; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{self, AtomicBool}; +use std::sync::mpsc; +use std::time::Duration; + +use serde_json::{json, Value}; + +use crate::platform; + +/// The Mac gives Codex the same 15 s. +const TIMEOUT: Duration = Duration::from_secs(15); +/// One JSON-RPC line we are willing to read; the answer is a few hundred bytes. +const MAX_LINE: usize = 256 * 1024; +/// Everything we are willing to read before the answer shows up. +const MAX_TOTAL: usize = 2 * 1024 * 1024; + +/// What the Mac sends: initialize, initialized, then the one question. +const REQUESTS: &str = concat!( + r#"{"id":1,"method":"initialize","params":{"clientInfo":{"name":"coucou","version":"1"}}}"#, + "\n", + r#"{"method":"initialized"}"#, + "\n", + r#"{"id":2,"method":"account/rateLimits/read"}"#, + "\n", +); + +static BUSY: AtomicBool = AtomicBool::new(false); + +struct NotBusy; +impl Drop for NotBusy { + fn drop(&mut self) { + BUSY.store(false, atomic::Ordering::Release); + } +} + +/// The limits part of Codex's `account/rateLimits/read` answer, or `None` when +/// Codex is not installed, not signed in, slow, already being asked, or Coucou +/// is paused. Blocking: call it off the main thread. +pub fn read() -> Option { + if crate::integrations::PAUSED.load(atomic::Ordering::Relaxed) { + return None; + } + if BUSY.swap(true, atomic::Ordering::AcqRel) { + return None; + } + let _busy = NotBusy; + + let exe = platform::codex_candidates().into_iter().next()?; + let mut cmd = Command::new(&exe); + cmd.arg("app-server").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()); + // An npm install is a `#!/usr/bin/env node` script (or a .cmd calling node): + // its own folder first on PATH, as the Mac does, so it finds its Node. + if let Some(dir) = exe.parent() { + let mut dirs = vec![dir.to_path_buf()]; + if let Some(path) = std::env::var_os("PATH") { + dirs.extend(std::env::split_paths(&path)); + } + if let Ok(joined) = std::env::join_paths(dirs) { + cmd.env("PATH", joined); + } + } + platform::no_console(&mut cmd); + let mut child = cmd.spawn().ok()?; + + let answer = (|| { + let mut stdin = child.stdin.take()?; + let stdout = child.stdout.take()?; + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(first_answer(BufReader::new(stdout))); + }); + stdin.write_all(REQUESTS.as_bytes()).ok()?; + stdin.flush().ok()?; + let answer = rx.recv_timeout(TIMEOUT).ok().flatten(); + // Closing its input is how app-server learns we are done. + drop(stdin); + answer + })(); + // An npm install runs `codex.cmd`: killing cmd.exe alone would leave the + // node app-server under it running. End the whole tree there. + #[cfg(windows)] + { + let mut kill = Command::new("taskkill"); + kill.args(["/T", "/F", "/PID", &child.id().to_string()]); + platform::no_console(&mut kill); + let _ = kill.status(); + } + let _ = child.kill(); + let _ = child.wait(); + answer.map(|result| limits_only(&result)) +} + +/// Reads JSON-RPC lines until the answer to request 2 and returns its `result`. +/// Gives up on a line or a stream that is far too long for what we expect. +pub fn first_answer(reader: impl BufRead) -> Option { + let mut reader = reader; + let mut total = 0usize; + let mut line = Vec::new(); + loop { + line.clear(); + let n = (&mut reader).take(MAX_LINE as u64 + 1).read_until(b'\n', &mut line).ok()?; + if n == 0 { + return None; + } + total += n; + if total > MAX_TOTAL || line.len() > MAX_LINE { + return None; + } + let Ok(message) = serde_json::from_slice::(&line) else { continue }; + if message.get("id").and_then(Value::as_i64) == Some(2) { + return message.get("result").filter(|r| r.is_object()).cloned(); + } + } +} + +/// Only what the card shows goes to the webview: the limits and the free resets. +fn limits_only(result: &Value) -> Value { + json!({ + "rateLimits": result.get("rateLimits").cloned().unwrap_or(Value::Null), + "rateLimitResetCredits": result.get("rateLimitResetCredits").cloned().unwrap_or(Value::Null), + }) +} + +/// Compares version-like names with their numbers as numbers ("v20.1" > "v9.12"), +/// like Foundation's `.numeric` comparison the Mac sorts nvm's folders with. +#[cfg_attr(windows, allow(dead_code))] +pub fn compare_versions(a: &str, b: &str) -> Ordering { + fn parts(s: &str) -> Vec> { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut digits = false; + for c in s.chars() { + if c.is_ascii_digit() != digits && !cur.is_empty() { + out.push(if digits { Ok(cur.parse().unwrap_or(u64::MAX)) } else { Err(cur.clone()) }); + cur.clear(); + } + digits = c.is_ascii_digit(); + cur.push(c); + } + if !cur.is_empty() { + out.push(if digits { Ok(cur.parse().unwrap_or(u64::MAX)) } else { Err(cur) }); + } + out + } + let (pa, pb) = (parts(a), parts(b)); + for (x, y) in pa.iter().zip(pb.iter()) { + let ord = match (x, y) { + (Ok(x), Ok(y)) => x.cmp(y), + (Err(x), Err(y)) => x.cmp(y), + (Ok(_), Err(_)) => Ordering::Less, + (Err(_), Ok(_)) => Ordering::Greater, + }; + if ord != Ordering::Equal { + return ord; + } + } + pa.len().cmp(&pb.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_answer_to_the_question_is_picked_out_of_the_conversation() { + let stream = concat!( + r#"{"id":1,"result":{"userAgent":"codex"}}"#, "\n", + "not json at all\n", + r#"{"method":"account/updated","params":{}}"#, "\n", + r#"{"id":2,"result":{"rateLimits":{"primary":{"usedPercent":12.5,"resetsAt":1900000000}}}}"#, "\n", + r#"{"id":3,"result":{}}"#, "\n", + ); + let result = first_answer(stream.as_bytes()).unwrap(); + assert_eq!(result["rateLimits"]["primary"]["usedPercent"], 12.5); + } + + #[test] + fn an_error_a_closed_stream_or_a_flood_is_no_answer() { + assert!(first_answer(r#"{"id":2,"error":{"message":"not signed in"}}"#.as_bytes()).is_none()); + assert!(first_answer(r#"{"id":1,"result":{}}"#.as_bytes()).is_none()); + assert!(first_answer(&b""[..]).is_none()); + let huge = "x".repeat(MAX_LINE + 10); + assert!(first_answer(format!("{huge}\n{}\n", r#"{"id":2,"result":{}}"#).as_bytes()).is_none()); + // Many short lines add up too. + let chatter = format!("{}\n", "y".repeat(1000)).repeat(MAX_TOTAL / 1000 + 10); + assert!(first_answer(chatter.as_bytes()).is_none()); + } + + #[test] + fn only_the_limits_reach_the_webview() { + let result = json!({ + "rateLimits": { "planType": "plus" }, + "rateLimitResetCredits": { "availableCount": 1 }, + "account": { "email": "me@example.com" } + }); + let kept = limits_only(&result); + assert_eq!(kept["rateLimits"]["planType"], "plus"); + assert_eq!(kept["rateLimitResetCredits"]["availableCount"], 1); + assert!(kept.get("account").is_none()); + } + + #[test] + fn newest_node_first_like_the_mac() { + let mut v = vec!["v9.12.0", "v20.1.0", "v18.20.4", "v20.11.1"]; + v.sort_by(|a, b| compare_versions(b, a)); + assert_eq!(v, ["v20.11.1", "v20.1.0", "v18.20.4", "v9.12.0"]); + } +} diff --git a/windows/src-tauri/src/config_file.rs b/windows/src-tauri/src/config_file.rs new file mode 100644 index 000000000..3579c2d2a --- /dev/null +++ b/windows/src-tauri/src/config_file.rs @@ -0,0 +1,652 @@ +// The one careful way Coucou changes a file that belongs to somebody else: +// Claude Code's settings.json, every other agent's hook config, and the plugin +// files a few agents load. +// +// The rules (CLAUDE.md) are applied here once, for everyone: +// * read strictly — a file we cannot read or parse is an error, never "empty"; +// * the change is computed from the exact bytes the user was shown, identified +// by a fingerprint, and refused if the file moved since; +// * an edit refuses values of an unexpected type instead of replacing them; +// * a dated backup of the current bytes is taken first and must succeed; +// * the new contents are written beside the target and renamed over it, +// keeping its permissions, and through a symlink to the file it points at. +// +// Nothing here decides *what* to change: callers hand in an `Edit` per file. + +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use serde_json::{json, Value}; + +use crate::i18n::{t, tf}; + +/// What an edit wants a file to become. +pub struct Change { + /// The current contents as the diff shows them. + pub before: String, + /// The new contents, or `None` to remove the file. + pub after: Option, +} + +/// Computes the change for one file from its current bytes (`None`: no file). +pub type Edit<'a> = Box) -> Result + 'a>; + +/// One file and what to do with it. +pub struct FileEdit<'a> { + pub path: PathBuf, + pub edit: Edit<'a>, +} + +/// What the user looks at before anything is written. +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Plan { + pub diff: String, + /// Where each existing file will be copied first, one per line; empty when + /// there is nothing to back up. + pub backup: String, + /// The file (or files, one per line) that change. + pub path: String, + /// Identifies the exact bytes the diff was computed from; handed back to + /// `apply` so only what the user actually looked at is ever written. + pub fingerprint: String, +} + +// ── Reading ─────────────────────────────────────────────────────────────────── + +/// The bytes of `path`, `None` when there is no such file. Anything else — a +/// lock, a permission problem, a directory in its place — is an error: not +/// knowing what is in a file is not the same as it being empty. +pub fn read(path: &Path) -> Result>, String> { + match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(tf("Can't read {path}: {error}", &[("path", &path.display().to_string()), ("error", &err.to_string())])), + } +} + +/// A JSON config as an object. A missing, empty or whitespace-only file is an +/// empty object; a UTF-8 BOM (PowerShell 5, Notepad) is stripped. Anything +/// else that is not a JSON object is refused. +pub fn parse_json(bytes: Option<&[u8]>, label: &str) -> Result { + let Some(bytes) = bytes else { return Ok(json!({})) }; + let text = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + if text.iter().all(u8::is_ascii_whitespace) { + return Ok(json!({})); + } + match serde_json::from_slice::(text) { + Ok(v) if v.is_object() => Ok(v), + Ok(_) => Err(tf("{file} isn't a JSON object — Coucou won't touch it.", &[("file", label)])), + Err(err) => Err(tf( + "{file} isn't valid JSON ({error}). Fix or move it, then try again — Coucou won't overwrite it.", + &[("file", label), ("error", &err.to_string())], + )), + } +} + +pub fn pretty(v: &Value) -> String { + serde_json::to_string_pretty(v).unwrap_or_default() +} + +/// An `Edit` for a JSON file: `change` gets the parsed object and returns the +/// new one, or `None` to remove the file. The diff compares both pretty-printed. +pub fn json_edit<'a>( + label: String, + change: impl Fn(&Value) -> Result, String> + 'a, +) -> Edit<'a> { + Box::new(move |bytes| { + let current = parse_json(bytes, &label)?; + let after = change(¤t)?.map(|next| { + let mut text = pretty(&next); + text.push('\n'); + text + }); + // A file that is not there yet shows as all additions. + let before = if bytes.is_some() { pretty(¤t) } else { String::new() }; + Ok(Change { before, after }) + }) +} + +/// An `Edit` for a text file Coucou generates whole (a plugin): `change` gets +/// the current text and returns the new one, or `None` to remove the file. +pub fn text_edit<'a>( + label: String, + change: impl Fn(Option<&str>) -> Result, String> + 'a, +) -> Edit<'a> { + Box::new(move |bytes| { + let current = match bytes { + None => None, + Some(b) => Some( + std::str::from_utf8(b) + .map_err(|_| tf("{file} isn't UTF-8 text — Coucou won't touch it.", &[("file", &label)]))?, + ), + }; + let after = change(current)?; + Ok(Change { before: current.unwrap_or_default().to_string(), after }) + }) +} + +// ── Preview and apply ───────────────────────────────────────────────────────── + +/// Identifies the exact bytes a preview was computed from. FNV-1a is plenty: +/// the question is only "is this still the file I showed the user?". +pub fn fingerprint(bytes: &[u8]) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for b in bytes { + hash ^= *b as u64; + hash = hash.wrapping_mul(0x1000_0000_01b3); + } + format!("{hash:016x}") +} + +/// A missing file and an empty one are told apart: creating a file someone +/// else just created must be refused too. +fn fingerprint_of(bytes: Option<&[u8]>) -> String { + match bytes { + Some(b) => fingerprint(b), + None => "none".into(), + } +} + +/// The diff, backups and fingerprint for a set of edits. Nothing is written. +pub fn preview(edits: &[FileEdit]) -> Result { + let mut diffs = Vec::new(); + let mut backups = Vec::new(); + let mut paths = Vec::new(); + let mut prints = Vec::new(); + for file in edits { + let current = read(&file.path)?; + let change = (file.edit)(current.as_deref())?; + let after = change.after.clone().unwrap_or_default(); + let mut diff = unified_diff(&change.before, after.trim_end_matches('\n')); + match (¤t, &change.after) { + (Some(_), None) => diff = format!("{}\n{diff}", t("The file is removed.")), + (None, None) => diff = t("No change."), + _ => {} + } + if edits.len() > 1 { + diff = format!("── {}\n{diff}", file.path.display()); + } + diffs.push(diff); + if current.is_some() { + backups.push(backup_name(&file.path, &stamp(), 0).display().to_string()); + } + paths.push(file.path.display().to_string()); + prints.push(fingerprint_of(current.as_deref())); + } + Ok(Plan { + diff: diffs.join("\n"), + backup: backups.join("\n"), + path: paths.join("\n"), + fingerprint: prints.join(":"), + }) +} + +/// Applies the edits the user confirmed. Returns the backups taken. +/// +/// Every file is read, checked against `expected` and edited before anything +/// is touched; then every existing file is backed up; only then is anything +/// written. A file that changed since the preview — another tool, another +/// window, the user's own editor — stops everything: the only thing worse than +/// not installing is silently reverting somebody else's edit. +pub fn apply(edits: &[FileEdit], expected: &str) -> Result, String> { + let wanted: Vec<&str> = expected.split(':').collect(); + if wanted.len() != edits.len() { + return Err(t("The preview is out of date. Nothing was written — review the new diff.")); + } + let mut planned = Vec::new(); + for (file, want) in edits.iter().zip(wanted) { + let current = read(&file.path)?; + if fingerprint_of(current.as_deref()) != want { + return Err(tf( + "{path} changed since the preview. Nothing was written — review the new diff.", + &[("path", &file.path.display().to_string())], + )); + } + let change = (file.edit)(current.as_deref())?; + planned.push((file, current, change.after)); + } + + let mut backups = Vec::new(); + for (file, current, _) in &planned { + if let Some(bytes) = current { + let copy = back_up(&file.path, bytes) + .map_err(|e| { + tf( + "Backup of {path} failed, nothing was written: {error}", + &[("path", &file.path.display().to_string()), ("error", &e.to_string())], + ) + })?; + backups.push(copy); + } + } + + for (file, current, after) in planned { + match after { + Some(text) => replace(&file.path, text.as_bytes()) + .map_err(|e| { + tf("Write to {path} failed: {error}", &[("path", &file.path.display().to_string()), ("error", &e.to_string())]) + })?, + None if current.is_some() => std::fs::remove_file(resolve_link(&file.path)) + .map_err(|e| { + tf("Could not remove {path}: {error}", &[("path", &file.path.display().to_string()), ("error", &e.to_string())]) + })?, + None => {} + } + } + Ok(backups) +} + +// ── Writing ─────────────────────────────────────────────────────────────────── + +/// Down to the second: installing then uninstalling in the same minute must not +/// quietly overwrite the first backup. +fn stamp() -> String { + let t = crate::platform::local_time(); + format!( + "{:04}{:02}{:02}-{:02}{:02}{:02}", + t.year, t.month, t.day, t.hour, t.minute, t.second + ) +} + +/// `settings.json.bak-20260102-030405`, then `-1`, `-2`… while that is taken. +fn backup_name(path: &Path, stamp: &str, n: u32) -> PathBuf { + let name = path.file_name().unwrap_or_default().to_string_lossy(); + match n { + 0 => path.with_file_name(format!("{name}.bak-{stamp}")), + n => path.with_file_name(format!("{name}.bak-{stamp}-{n}")), + } +} + +/// Copies `bytes` — the very bytes the change was computed from — beside +/// `path`. A backup that already exists is never written over, and one that +/// could not be written whole is removed rather than left looking faithful. +fn back_up(path: &Path, bytes: &[u8]) -> std::io::Result { + let stamp = stamp(); + for n in 0..100 { + let copy = backup_name(path, &stamp, n); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + match options.open(©) { + Ok(mut file) => { + let written = write_whole(&mut file, bytes).and_then(|()| keep_mode(&file, path)); + drop(file); + if let Err(err) = written { + let _ = std::fs::remove_file(©); + return Err(err); + } + return Ok(copy); + } + Err(err) if err.kind() == ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(std::io::Error::new(ErrorKind::AlreadyExists, "too many backups this second")) +} + +fn write_whole(file: &mut std::fs::File, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write; + file.write_all(bytes)?; + file.sync_all() +} + +/// Gives `file` the permissions of `original` (ours only when there is none). +/// A config can hold API keys: a rewrite or a backup must never widen access. +fn keep_mode(file: &std::fs::File, original: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(original) + .map(|m| m.permissions().mode() & 0o777) + .unwrap_or(0o600); + file.set_permissions(std::fs::Permissions::from_mode(mode))?; + } + #[cfg(not(unix))] + let _ = (file, original); + Ok(()) +} + +/// Writes `bytes` to `temp`, which is about to replace `original`: created +/// readable by us only, then given the original's permissions. +pub fn write_like(temp: &Path, original: &Path, bytes: &[u8]) -> std::io::Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let mut file = options.open(temp)?; + write_whole(&mut file, bytes)?; + keep_mode(&file, original) +} + +/// The file a symlinked config points at (on Windows too), else `path` itself. +fn resolve_link(path: &Path) -> std::path::PathBuf { + let is_link = std::fs::symlink_metadata(path).map(|m| m.file_type().is_symlink()).unwrap_or(false); + if is_link { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) + } else { + path.to_path_buf() + } +} + +/// Replaces `path` with `bytes`: written beside it and renamed over it, so a +/// crash or a full disk leaves the original intact rather than half a file. +fn replace(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + // A dotfiles setup often makes a config a symlink: write to the file it + // points at, so the link survives the rename. + let resolved = resolve_link(path); + let path = resolved.as_path(); + + let name = path.file_name().unwrap_or_default().to_string_lossy(); + let temp = path.with_file_name(format!("{name}.coucou-{}", std::process::id())); + let result = write_like(&temp, path, bytes).and_then(|()| std::fs::rename(&temp, path)); + if result.is_err() { + let _ = std::fs::remove_file(&temp); + } + result +} + +// ── Minimal unified diff (LCS) ──────────────────────────────────────────────── + +/// Config files are short, so a plain O(n·m) LCS is the simplest honest diff. +pub fn unified_diff(before: &str, after: &str) -> String { + let a: Vec<&str> = before.lines().collect(); + let b: Vec<&str> = after.lines().collect(); + let (n, m) = (a.len(), b.len()); + + let mut lcs = vec![vec![0usize; m + 1]; n + 1]; + for i in (0..n).rev() { + for j in (0..m).rev() { + lcs[i][j] = if a[i] == b[j] { + lcs[i + 1][j + 1] + 1 + } else { + lcs[i + 1][j].max(lcs[i][j + 1]) + }; + } + } + + let mut out: Vec = Vec::new(); + let (mut i, mut j) = (0usize, 0usize); + while i < n && j < m { + if a[i] == b[j] { + out.push(format!(" {}", a[i])); + i += 1; + j += 1; + } else if lcs[i + 1][j] >= lcs[i][j + 1] { + out.push(format!("- {}", a[i])); + i += 1; + } else { + out.push(format!("+ {}", b[j])); + j += 1; + } + } + while i < n { + out.push(format!("- {}", a[i])); + i += 1; + } + while j < m { + out.push(format!("+ {}", b[j])); + j += 1; + } + + // Keep three lines of context around each change so the panel stays readable. + let changed: Vec = out + .iter() + .enumerate() + .filter(|(_, l)| l.starts_with('+') || l.starts_with('-')) + .map(|(i, _)| i) + .collect(); + if changed.is_empty() { + return "No change.".into(); + } + let mut keep = vec![false; out.len()]; + for idx in changed { + let lo = idx.saturating_sub(3); + let hi = (idx + 4).min(out.len()); + for k in lo..hi { + keep[k] = true; + } + } + let mut result = String::new(); + let mut gap = false; + for (idx, line) in out.iter().enumerate() { + if keep[idx] { + result.push_str(line); + result.push('\n'); + gap = false; + } else if !gap { + result.push_str(" …\n"); + gap = true; + } + } + result +} + +#[cfg(test)] +pub mod tests { + use super::*; + + /// A fresh directory of our own. + pub fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("coucou-cfg-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn names(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) + .collect(); + names.sort(); + names + } + + /// Adds `"ours": true` and refuses a `hooks` that is not an object. + fn add_ours(path: &Path) -> Vec> { + vec![FileEdit { + path: path.to_path_buf(), + edit: json_edit("test.json".into(), |v| { + if v.get("hooks").is_some_and(|h| !h.is_object()) { + return Err("\"hooks\" has an unexpected type — Coucou has not touched it.".into()); + } + let mut next = v.clone(); + next["ours"] = json!(true); + Ok(Some(next)) + }), + }] + } + + fn remove_file_edit(path: &Path) -> Vec> { + vec![FileEdit { path: path.to_path_buf(), edit: json_edit("test.json".into(), |_| Ok(None)) }] + } + + #[test] + fn unreadable_content_is_an_error_never_an_empty_object() { + for bad in [&b"{ not json"[..], &b"[1,2,3]"[..], &b"\"a string\""[..]] { + assert!(parse_json(Some(bad), "x").is_err()); + } + assert_eq!(parse_json(None, "x").unwrap(), json!({})); + assert_eq!(parse_json(Some(b" \n\t"), "x").unwrap(), json!({})); + let mut bom = vec![0xEF, 0xBB, 0xBF]; + bom.extend_from_slice(br#"{"a":1}"#); + assert_eq!(parse_json(Some(&bom), "x").unwrap()["a"], 1); + } + + #[test] + fn a_fingerprint_notices_any_change_and_tells_missing_from_empty() { + assert_eq!(fingerprint(b"{}"), fingerprint(b"{}")); + assert_ne!(fingerprint(b"{}"), fingerprint(b"{ }")); + assert_ne!(fingerprint_of(None), fingerprint_of(Some(b""))); + } + + #[test] + fn the_backup_holds_the_original_bytes_and_the_rest_is_kept() { + let dir = scratch("backup"); + let path = dir.join("settings.json"); + let mut original = vec![0xEF, 0xBB, 0xBF]; + original.extend_from_slice(br#"{"model":"opus","keep":{"x":[1,2]}}"#); + std::fs::write(&path, &original).unwrap(); + + let plan = preview(&add_ours(&path)).unwrap(); + assert!(plan.diff.contains("\"ours\": true"), "{}", plan.diff); + assert!(plan.backup.contains("settings.json.bak-")); + let backups = apply(&add_ours(&path), &plan.fingerprint).unwrap(); + assert_eq!(backups.len(), 1); + assert_eq!(std::fs::read(&backups[0]).unwrap(), original); + + let after: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(after["model"], "opus"); + assert_eq!(after["keep"]["x"], json!([1, 2])); + assert_eq!(after["ours"], true); + // Nothing but the file and its backup: no temporary left behind. + assert_eq!(names(&dir).len(), 2); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn two_backups_in_the_same_second_never_overwrite_each_other() { + let dir = scratch("twice"); + let path = dir.join("hooks.json"); + std::fs::write(&path, b"{}").unwrap(); + let first = apply(&add_ours(&path), &fingerprint(b"{}")).unwrap(); + let now = std::fs::read(&path).unwrap(); + let second = apply(&add_ours(&path), &fingerprint(&now)).unwrap(); + assert_ne!(first, second); + assert_eq!(std::fs::read(&first[0]).unwrap(), b"{}"); + assert_eq!(std::fs::read(&second[0]).unwrap(), now); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_backup_that_cannot_be_taken_stops_everything() { + let dir = scratch("nobackup"); + // A name the file system accepts, but too long for ".bak-" on top. + let path = dir.join(format!("{}.json", "s".repeat(240))); + std::fs::write(&path, b"{}").unwrap(); + let err = apply(&add_ours(&path), &fingerprint(b"{}")).unwrap_err(); + assert!(err.contains("Backup"), "{err}"); + assert_eq!(std::fs::read(&path).unwrap(), b"{}"); + assert_eq!(names(&dir).len(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_file_that_changed_since_the_preview_is_refused_and_left_alone() { + let dir = scratch("changed"); + let path = dir.join("settings.json"); + std::fs::write(&path, b"{}").unwrap(); + let plan = preview(&add_ours(&path)).unwrap(); + std::fs::write(&path, br#"{"someone":"else"}"#).unwrap(); + let err = apply(&add_ours(&path), &plan.fingerprint).unwrap_err(); + assert!(err.contains("changed since the preview"), "{err}"); + assert_eq!(std::fs::read(&path).unwrap(), br#"{"someone":"else"}"#); + + // A file created since a preview of "no file" is refused too. + let fresh = dir.join("new.json"); + let plan = preview(&add_ours(&fresh)).unwrap(); + std::fs::write(&fresh, b"").unwrap(); + assert!(apply(&add_ours(&fresh), &plan.fingerprint).is_err()); + assert_eq!(std::fs::read(&fresh).unwrap(), b""); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unexpected_types_and_invalid_json_are_refused_before_anything_is_written() { + let dir = scratch("types"); + let path = dir.join("settings.json"); + for bad in [&br#"{"hooks":"a string"}"#[..], &b"{ broken"[..]] { + std::fs::write(&path, bad).unwrap(); + assert!(preview(&add_ours(&path)).is_err()); + assert!(apply(&add_ours(&path), &fingerprint(bad)).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), bad); + } + assert_eq!(names(&dir), ["settings.json"]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_new_file_is_created_with_its_folder_and_nothing_is_backed_up() { + let dir = scratch("create"); + let path = dir.join("deep").join("er").join("hooks.json"); + let plan = preview(&add_ours(&path)).unwrap(); + assert_eq!(plan.backup, ""); + assert!(apply(&add_ours(&path), &plan.fingerprint).unwrap().is_empty()); + let after: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(after, json!({ "ours": true })); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn removing_a_file_backs_it_up_first() { + let dir = scratch("remove"); + let path = dir.join("coucou.json"); + std::fs::write(&path, b"{\"a\":1}").unwrap(); + let plan = preview(&remove_file_edit(&path)).unwrap(); + assert!(plan.diff.starts_with("The file is removed.")); + let backups = apply(&remove_file_edit(&path), &plan.fingerprint).unwrap(); + assert!(!path.exists()); + assert_eq!(std::fs::read(&backups[0]).unwrap(), b"{\"a\":1}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn several_files_are_checked_together() { + let dir = scratch("many"); + let (a, b) = (dir.join("a.json"), dir.join("b.json")); + std::fs::write(&a, b"{}").unwrap(); + let edits = |a: &Path, b: &Path| { + let mut v = add_ours(a); + v.extend(add_ours(b)); + v + }; + let plan = preview(&edits(&a, &b)).unwrap(); + // The second one appears in between: neither is written. + std::fs::write(&b, b"{\"x\":1}").unwrap(); + assert!(apply(&edits(&a, &b), &plan.fingerprint).is_err()); + assert_eq!(std::fs::read(&a).unwrap(), b"{}"); + let plan = preview(&edits(&a, &b)).unwrap(); + apply(&edits(&a, &b), &plan.fingerprint).unwrap(); + for p in [&a, &b] { + let v: Value = serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap(); + assert_eq!(v["ours"], true); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn rewriting_keeps_permissions_and_symlinks() { + use std::os::unix::fs::PermissionsExt; + let dir = scratch("perm"); + let real = dir.join("dotfiles-settings.json"); + let link = dir.join("settings.json"); + std::fs::write(&real, b"{}").unwrap(); + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o640)).unwrap(); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + let backups = apply(&add_ours(&link), &fingerprint(b"{}")).unwrap(); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink()); + let v: Value = serde_json::from_slice(&std::fs::read(&real).unwrap()).unwrap(); + assert_eq!(v["ours"], true); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&real), 0o640); + assert_eq!(mode(&backups[0]), 0o640); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_text_file_that_is_not_utf8_is_refused() { + let edit = text_edit("plugin.js".into(), |_| Ok(Some("x".into()))); + assert!(edit(Some(&[0xff, 0xfe, 0x00])).is_err()); + let change = edit(None).unwrap(); + assert_eq!(change.before, ""); + assert_eq!(change.after.as_deref(), Some("x")); + } +} diff --git a/windows/src-tauri/src/desktop.rs b/windows/src-tauri/src/desktop.rs new file mode 100644 index 000000000..5b7e6509e --- /dev/null +++ b/windows/src-tauri/src/desktop.rs @@ -0,0 +1,1007 @@ +// Mochi on the desktop: his own small window, wherever the user dropped him. +// Window side of DesktopMochi.swift, plus the geometry half of +// DesktopMochiLogic.swift (`logic` below, with its tests). +// +// The island page runs his life cycle (src/island/desktop.ts): fly out, back to +// the island for an alert, home. This file owns the window — where it is, which +// part of it takes the mouse, the flights, the drag, and the saved spot. +// +// How the window is placed depends on the system (platform::DesktopMode): +// * Windows: anywhere, in physical desktop pixels. A cursor poll — running +// only while he is awake on the desktop — makes the window click-through +// except over his body, feeds his eyes, and carries him during a drag. +// * Linux X11: the same coordinates; the input region is his body, and the +// page drives the drag. +// * Linux layer-shell: a layer surface on the island's display, placed with +// margins in logical pixels. +// * GNOME on Wayland: off, nothing is created. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde::Serialize; +use tauri::{AppHandle, Emitter, Manager, PhysicalPosition, State, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + +use crate::island::{self, PollGate}; +use crate::platform::{self, DesktopMode, MouseShape}; +use crate::settings::{self, DesktopSpot}; + +pub const LABEL: &str = "mochi"; + +/// Side of the square window, logical pixels (DesktopMochiLogic.panelSize). +pub const SIZE: f64 = 120.0; +/// Kept between him and the edges of the work area (DesktopMochiLogic.clampMargin). +const MARGIN: f64 = 24.0; +/// To and from the island (DesktopMochi.swift: 0.45 s, ease in-out). +const FLIGHT_MS: u64 = 450; +/// Settling into his spot after a drop (0.25 s, with a little overshoot). +const SNAP_MS: u64 = 250; +const TICK_MS: u64 = 16; + +/// Windows never hides his window: it parks it off-screen. tao shows a hidden +/// window again with SW_SHOW, which may take the keyboard focus from whatever +/// the user is typing in each time he flies out; a window created visible +/// (shown without activation) and only ever moved never is. Same as the +/// island, which is never re-shown either. +const PARK: bool = cfg!(windows); +const PARKED: (f64, f64) = (-32000.0, -32000.0); + +// ── Pure geometry ───────────────────────────────────────────────────────────── + +pub mod logic { + /// A rectangle, y down, in whatever space the caller works in. + #[derive(Clone, Copy, Debug, PartialEq)] + pub struct Rect { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, + } + + impl Rect { + pub fn contains(&self, p: (f64, f64)) -> bool { + p.0 >= self.x && p.0 < self.x + self.w && p.1 >= self.y && p.1 < self.y + self.h + } + + pub fn center(&self) -> (f64, f64) { + (self.x + self.w / 2.0, self.y + self.h / 2.0) + } + } + + /// A connected display: its whole area, the part windows may use (no + /// taskbar), and how many of the space's units make one logical pixel. + #[derive(Clone, Copy, Debug, PartialEq)] + pub struct Display { + pub frame: Rect, + pub work: Rect, + pub scale: f64, + } + + /// Radius of the clickable body, as a fraction of the window side. + pub const BODY_RADIUS_FRACTION: f64 = 0.24; + + /// Island panel, logical pixels: dropping Mochi on it brings him home. + pub const HOME_ZONE_W: f64 = crate::island::PANEL_W; + pub const HOME_ZONE_H: f64 = crate::island::PANEL_H; + + /// Hit test of the round body inside the square window (window-local). + pub fn is_over_body(local: (f64, f64), size: f64) -> bool { + let c = size / 2.0; + let r = size * BODY_RADIUS_FRACTION; + let (dx, dy) = (local.0 - c, local.1 - c); + dx * dx + dy * dy <= r * r + } + + /// Keeps a window of side `size` inside `work`, `margin` from each edge. + pub fn clamp_origin(origin: (f64, f64), size: f64, work: Rect, margin: f64) -> (f64, f64) { + ( + origin.0.max(work.x + margin).min(work.x + work.w - size - margin), + origin.1.max(work.y + margin).min(work.y + work.h - size - margin), + ) + } + + /// The display whose frame holds `p`, or else the one whose centre is closest. + pub fn display_near(p: (f64, f64), displays: &[Display]) -> Option { + displays.iter().copied().find(|d| d.frame.contains(p)).or_else(|| { + displays.iter().copied().min_by(|a, b| { + let da = dist2(p, a.work.center()); + let db = dist2(p, b.work.center()); + da.total_cmp(&db) + }) + }) + } + + fn dist2(a: (f64, f64), b: (f64, f64)) -> f64 { + (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2) + } + + /// Where a window dropped with its top-left at `origin` settles: inside the + /// work area of the display under its centre (or the nearest one). + pub fn settle(origin: (f64, f64), displays: &[Display], size: f64, margin: f64) -> (f64, f64) { + let Some(first) = displays.first() else { return origin }; + let half = size * first.scale / 2.0; + let Some(d) = display_near((origin.0 + half, origin.1 + half), displays) else { + return origin; + }; + clamp_origin(origin, size * d.scale, d.work, margin * d.scale) + } + + /// A spot saved at an earlier launch, checked against the displays + /// connected now: `None` when none of them holds its centre any more (the + /// monitor was unplugged) — he then stays in the island. A spot that is + /// still on a display is pulled back inside its work area. + pub fn restore_spot( + spot: (f64, f64), + displays: &[Display], + size: f64, + margin: f64, + ) -> Option<(f64, f64)> { + displays.iter().find_map(|d| { + let s = size * d.scale; + let center = (spot.0 + s / 2.0, spot.1 + s / 2.0); + d.frame + .contains(center) + .then(|| clamp_origin(spot, s, d.work, margin * d.scale)) + }) + } + + /// First visit: the bottom-right corner of the work area, like the Mac. + pub fn default_spot(d: &Display, size: f64, margin: f64) -> (f64, f64) { + let s = size * d.scale; + let m = margin * d.scale; + (d.work.x + d.work.w - s - m, d.work.y + d.work.h - s - m) + } + + /// The island's panel: centred on the island, from the top of its display. + pub fn home_zone(island_center_x: f64, island_top: f64, scale: f64) -> Rect { + Rect { + x: island_center_x - HOME_ZONE_W * scale / 2.0, + y: island_top, + w: HOME_ZONE_W * scale, + h: HOME_ZONE_H * scale, + } + } + + /// Top-left corner of the window when Mochi sits in the island: centred + /// under the top edge, where the flights start and end. + pub fn island_spot(island_center_x: f64, island_top: f64, size: f64, scale: f64) -> (f64, f64) { + (island_center_x - size * scale / 2.0, island_top) + } + + pub fn ease_in_out(t: f64) -> f64 { + if t < 0.5 { + 4.0 * t * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 + } + } + + /// Ease-out with a small overshoot: the landing squash. + pub fn ease_out_back(t: f64) -> f64 { + let c1 = 1.3; + let c3 = c1 + 1.0; + 1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2) + } + + pub fn lerp(a: (f64, f64), b: (f64, f64), t: f64) -> (f64, f64) { + (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) + } +} + +use logic::{Display, Rect}; + +// ── State ───────────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Source { + /// Dragged out of the island. + Island, + /// Picked up where he sat on the desktop. + Desktop, +} + +#[derive(Clone, Copy)] +struct Carry { + /// Cursor minus the window's top-left at pick-up, physical (Poll mode). + grab: (f64, f64), + from: Source, +} + +#[derive(Default)] +struct Inner { + /// The window is on screen. + shown: bool, + /// Sitting on the desktop, taking pokes (not flying, not carried). + landed: bool, + asleep: bool, + carry: Option, + /// Layer mode: the surface covers the display while it is dragged. + overlay: bool, + /// Top-left corner, in the mode's space. + pos: (f64, f64), + /// Layer mode: logical size of the display he lives on. + display: Option<(f64, f64)>, + /// Poll mode: the click-through state last applied. + ignoring: Option, +} + +pub struct Desktop { + pub mode: DesktopMode, + gate: PollGate, + inner: Mutex, + /// Bumped by every flight: an older one still running stops moving him. + flight: AtomicU64, +} + +impl Desktop { + fn new(mode: DesktopMode) -> Self { + Self { mode, gate: PollGate::new(), inner: Mutex::new(Inner::default()), flight: AtomicU64::new(0) } + } +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct Dropped { + from: Source, + home: bool, +} + +#[derive(Serialize, Clone)] +struct Cursor { + x: f64, + y: f64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DesktopInfo { + /// "poll" | "window" | "layer" | "off". + mode: &'static str, + /// He was on the desktop when the app last quit. + on_desktop: bool, +} + +pub fn window(app: &AppHandle) -> Option { + app.get_webview_window(LABEL) +} + +fn body_disc() -> MouseShape { + MouseShape::Disc { cx: SIZE / 2.0, cy: SIZE / 2.0, r: SIZE * logic::BODY_RADIUS_FRACTION } +} + +// ── Setup ───────────────────────────────────────────────────────────────────── + +/// Same reasoning as the settings window: created hidden at launch, before the +/// island's webview, then only shown and hidden. Nothing is created where the +/// feature is off. +pub fn setup(app: &AppHandle) { + let mut mode = platform::desktop_mode(); + if mode != DesktopMode::Off { + match create_window(app, mode) { + Some(win) if platform::prepare_desktop_window(&win, mode) => {} + Some(win) => { + let _ = win.destroy(); + mode = DesktopMode::Off; + } + None => mode = DesktopMode::Off, + } + } + crate::log::line(format!("desktop Mochi: {}", mode.as_str())); + let desktop = Arc::new(Desktop::new(mode)); + app.manage(desktop.clone()); + // Nothing takes the mouse until he is out. + refresh(app, &desktop); + if mode == DesktopMode::Poll { + spawn_poll(app.clone(), desktop); + } +} + +fn page_url(app: &AppHandle) -> WebviewUrl { + #[cfg(dev)] + if let Some(mut base) = app.config().build.dev_url.clone() { + base.set_path("/mochi.html"); + return WebviewUrl::External(base); + } + let _ = app; + WebviewUrl::App("mochi.html".into()) +} + +fn create_window(app: &AppHandle, _mode: DesktopMode) -> Option { + let mut builder = WebviewWindowBuilder::new(app, LABEL, page_url(app)) + .additional_browser_args(crate::BROWSER_ARGS) + .title("Mochi") + .inner_size(SIZE, SIZE) + // GTK won't size a non-resizable window below its natural size (see + // island::apply_geometry). Windows would grow resize borders instead. + .resizable(cfg!(target_os = "linux")) + .decorations(false) + .transparent(true) + .shadow(false) + .always_on_top(true) + .skip_taskbar(true) + .visible_on_all_workspaces(true) + .focused(false) + .maximizable(false) + .minimizable(false) + .closable(false) + .disable_drag_drop_handler() + .focusable(false) + .visible(PARK); + if PARK { + builder = builder.position(PARKED.0, PARKED.1); + } + let built = builder.build(); + match built { + Ok(win) => Some(win), + Err(err) => { + crate::log::line(format!("desktop Mochi window failed: {err}")); + None + } + } +} + +// ── Coordinates ─────────────────────────────────────────────────────────────── + +/// Connected displays in the mode's space. +fn displays(app: &AppHandle, d: &Desktop) -> Vec { + if d.mode == DesktopMode::Layer { + let (w, h) = d.inner.lock().unwrap().display.unwrap_or((1920.0, 1080.0)); + let frame = Rect { x: 0.0, y: 0.0, w, h }; + return vec![Display { frame, work: frame, scale: 1.0 }]; + } + let Ok(monitors) = app.available_monitors() else { return Vec::new() }; + monitors + .iter() + .map(|m| { + let (p, s, wa) = (m.position(), m.size(), m.work_area()); + Display { + frame: Rect { x: p.x as f64, y: p.y as f64, w: s.width as f64, h: s.height as f64 }, + work: Rect { + x: wa.position.x as f64, + y: wa.position.y as f64, + w: wa.size.width as f64, + h: wa.size.height as f64, + }, + scale: m.scale_factor(), + } + }) + .collect() +} + +/// The island's top-centre point and the scale there, in the mode's space. +fn island_anchor(app: &AppHandle, d: &Desktop) -> Option<(f64, f64, f64)> { + if d.mode == DesktopMode::Layer { + let (w, _) = d.inner.lock().unwrap().display?; + return Some((w / 2.0, 0.0, 1.0)); + } + let win = island::window(app)?; + let pos = win.outer_position().ok()?; + let size = win.outer_size().ok()?; + let scale = win.scale_factor().unwrap_or(1.0); + Some((pos.x as f64 + size.width as f64 / 2.0, pos.y as f64, scale)) +} + +/// The window side in the mode's space. +fn side(app: &AppHandle, d: &Desktop) -> f64 { + if d.mode == DesktopMode::Layer { + return SIZE; + } + SIZE * window(app).and_then(|w| w.scale_factor().ok()).unwrap_or(1.0) +} + +/// Asks the GTK main thread for the island's display (layer mode), and waits. +/// Never called from the main thread itself: every caller is a command that +/// runs on Tauri's async pool. +fn refresh_layer_display(app: &AppHandle, d: &Desktop) { + if d.mode != DesktopMode::Layer { + return; + } + let (Some(island), Some(mochi)) = (island::window(app), window(app)) else { return }; + let (tx, rx) = std::sync::mpsc::channel(); + let _ = app.run_on_main_thread(move || { + let _ = tx.send(platform::layer_display(&island, &mochi)); + }); + if let Ok(Some(size)) = rx.recv_timeout(Duration::from_secs(1)) { + d.inner.lock().unwrap().display = Some(size); + } +} + +/// Island-window client coordinates (logical) → the mode's space. +fn from_island_client(app: &AppHandle, d: &Desktop, x: f64, y: f64) -> Option<(f64, f64)> { + let island = island::window(app)?; + match d.mode { + DesktopMode::Layer => { + // The island is anchored to the top edge and centred. + let (dw, _) = d.inner.lock().unwrap().display?; + let scale = island.scale_factor().unwrap_or(1.0); + let iw = island.inner_size().ok()?.width as f64 / scale; + Some(((dw - iw) / 2.0 + x, y)) + } + DesktopMode::Window => { + let pos = island.outer_position().ok()?; + let scale = island.scale_factor().unwrap_or(1.0); + Some((pos.x as f64 + x * scale, pos.y as f64 + y * scale)) + } + _ => None, + } +} + +// ── Window operations ───────────────────────────────────────────────────────── + +fn place(app: &AppHandle, d: &Desktop, pos: (f64, f64)) { + d.inner.lock().unwrap().pos = pos; + let Some(win) = window(app) else { return }; + match d.mode { + DesktopMode::Poll | DesktopMode::Window => { + let _ = win.set_position(PhysicalPosition::new(pos.0.round() as i32, pos.1.round() as i32)); + } + DesktopMode::Layer => { + let _ = app.run_on_main_thread(move || platform::set_layer_margins(&win, pos.0, pos.1)); + } + DesktopMode::Off => {} + } +} + +fn show(app: &AppHandle, d: &Desktop, at: (f64, f64)) { + place(app, d, at); + d.inner.lock().unwrap().shown = true; + if let Some(win) = window(app) { + if !PARK { + let _ = win.show(); + } + let _ = win.set_always_on_top(true); + } + refresh(app, d); + // The page draws only while he is on screen. + let _ = app.emit_to(LABEL, "desktop-visible", true); +} + +fn hide(app: &AppHandle, d: &Desktop) { + { + let mut i = d.inner.lock().unwrap(); + i.shown = false; + i.landed = false; + i.carry = None; + i.asleep = false; + } + refresh(app, d); + if PARK { + place(app, d, PARKED); + } else if let Some(win) = window(app) { + let _ = win.hide(); + } + let _ = app.emit_to(LABEL, "desktop-visible", false); +} + +/// Re-derives the poll gate and what takes the mouse from the current state. +fn refresh(app: &AppHandle, d: &Desktop) { + let (active, ignore, shape) = { + let mut i = d.inner.lock().unwrap(); + let active = i.shown && (i.carry.is_some() || (i.landed && !i.asleep)); + // Poll mode: Some(flag) is decided here; None leaves it to the poll. + let ignore = if !i.shown || (!i.landed && i.carry.is_none()) { + Some(true) + } else if let Some(c) = i.carry { + Some(c.from == Source::Island) + } else if i.asleep { + // Asleep, nothing polls: the whole little window takes the mouse + // and acts as his own wake area — the first move over it wakes + // him, and the poll gives the rest back right away. + Some(false) + } else { + None + }; + if ignore.is_none() { + i.ignoring = None; + } + let shape = if !i.shown || (!i.landed && i.carry.is_none()) { + MouseShape::Empty + } else if i.overlay { + MouseShape::Whole + } else if matches!(i.carry, Some(Carry { from: Source::Island, .. })) { + MouseShape::Empty + } else { + body_disc() + }; + (active, ignore, shape) + }; + let Some(win) = window(app) else { return }; + match d.mode { + DesktopMode::Poll => { + if let Some(flag) = ignore { + d.inner.lock().unwrap().ignoring = Some(flag); + let _ = win.set_ignore_cursor_events(flag); + } + d.gate.set_active(active); + } + DesktopMode::Window | DesktopMode::Layer => { + let _ = app.run_on_main_thread(move || platform::set_desktop_shape(&win, shape)); + } + DesktopMode::Off => {} + } +} + +/// Moves him from where he is to `to`, then lands him. False when another +/// flight took over before the end. +async fn fly(app: &AppHandle, d: &Desktop, to: (f64, f64), ms: u64, ease: fn(f64) -> f64) -> bool { + let token = d.flight.fetch_add(1, Ordering::SeqCst) + 1; + let from = d.inner.lock().unwrap().pos; + let (tx, rx) = tokio::sync::oneshot::channel(); + let app2 = app.clone(); + std::thread::spawn(move || { + let d = app2.state::>(); + let steps = (ms / TICK_MS).max(1); + for k in 1..=steps { + if d.flight.load(Ordering::SeqCst) != token { + let _ = tx.send(false); + return; + } + let t = k as f64 / steps as f64; + place(&app2, &d, logic::lerp(from, to, ease(t))); + std::thread::sleep(Duration::from_millis(TICK_MS)); + } + let _ = tx.send(d.flight.load(Ordering::SeqCst) == token); + }); + rx.await.unwrap_or(false) +} + +/// Saves what changed about him in the preferences. +fn remember(app: &AppHandle, change: impl FnOnce(&mut settings::DesktopMochiPref)) { + let Some(shared) = app.try_state::() else { return }; + let snapshot = { + let mut s = shared.settings.lock().unwrap(); + change(&mut s.desktop_mochi); + s.clone() + }; + if let Err(err) = settings::save(&snapshot) { + crate::log::line(format!("could not save settings: {err}")); + } +} + +fn remember_spot(app: &AppHandle, d: &Desktop, pos: (f64, f64), on_desktop: bool) { + let space = d.mode.space().to_string(); + remember(app, |p| { + p.on_desktop = on_desktop; + p.spot = Some(DesktopSpot { x: pos.0, y: pos.1, space }); + }); +} + +/// Where he lands when he flies out, or `None` when his spot is on a display +/// that is no longer connected. +fn target_spot(app: &AppHandle, d: &Desktop) -> Option<(f64, f64)> { + let all = displays(app, d); + let saved = app.try_state::().and_then(|s| s.settings.lock().unwrap().desktop_mochi.spot.clone()); + match saved { + Some(spot) if spot.space == d.mode.space() => { + logic::restore_spot((spot.x, spot.y), &all, SIZE, MARGIN) + } + // Never placed, or placed under another kind of session: a fresh start + // in the corner of the island's display. + _ => { + let (cx, top, _) = island_anchor(app, d)?; + let home = logic::display_near((cx, top + 1.0), &all)?; + Some(logic::default_spot(&home, SIZE, MARGIN)) + } + } +} + +/// The drop, wherever it came from: home if it is over the island's panel, +/// otherwise his new spot. +async fn finish_drop(app: &AppHandle, d: &Desktop, pos: (f64, f64), from: Source) { + let s = side(app, d); + let center = (pos.0 + s / 2.0, pos.1 + s / 2.0); + let home = island_anchor(app, d) + .map(|(cx, top, scale)| logic::home_zone(cx, top, scale).contains(center)) + .unwrap_or(false); + { + let mut i = d.inner.lock().unwrap(); + i.carry = None; + i.landed = !home; + } + if home { + if from == Source::Island { + // Back where he came from: he simply reappears in the island. + hide(app, d); + } else { + // The island page flies him home (and forgets the spot). + refresh(app, d); + } + } else { + let spot = logic::settle(pos, &displays(app, d), SIZE, MARGIN); + refresh(app, d); + if spot != pos { + fly(app, d, spot, SNAP_MS, logic::ease_out_back).await; + } + remember_spot(app, d, spot, true); + } + let _ = app.emit_to(island::WINDOW_LABEL, "desktop-mochi-dropped", Dropped { from, home }); +} + +// ── Poll (Windows) ──────────────────────────────────────────────────────────── + +/// Runs only while he is awake on the desktop or being carried: click-through +/// from the body hit test, the cursor for his eyes, and the drag. +fn spawn_poll(app: AppHandle, d: Arc) { + std::thread::spawn(move || loop { + d.gate.wait_until_active(); + let mut last = (f64::MIN, f64::MIN); + while d.gate.is_active() { + std::thread::sleep(Duration::from_millis(TICK_MS)); + let Some(win) = window(&app) else { continue }; + let Some((cx, cy)) = platform::cursor_physical() else { continue }; + let carry = d.inner.lock().unwrap().carry; + + if let Some(c) = carry { + let pos = (cx - c.grab.0, cy - c.grab.1); + if platform::left_button_down() { + if pos != d.inner.lock().unwrap().pos { + place(&app, &d, pos); + } + } else { + // Released: hand the drop to the async pool, where the + // snap animation may wait. + d.inner.lock().unwrap().carry = None; + let app2 = app.clone(); + let d2 = d.clone(); + tauri::async_runtime::spawn(async move { finish_drop(&app2, &d2, pos, c.from).await }); + } + continue; + } + // Between a release and the drop being settled, nothing to decide. + if !d.inner.lock().unwrap().landed { + continue; + } + + let Ok(origin) = win.outer_position() else { continue }; + let scale = win.scale_factor().unwrap_or(1.0); + let local = ((cx - origin.x as f64) / scale, (cy - origin.y as f64) / scale); + let accept = logic::is_over_body(local, SIZE); + { + let mut i = d.inner.lock().unwrap(); + if i.ignoring != Some(!accept) { + i.ignoring = Some(!accept); + let _ = win.set_ignore_cursor_events(!accept); + } + } + if (local.0 - last.0).abs() >= 1.0 || (local.1 - last.1).abs() >= 1.0 { + last = local; + let _ = win.emit("desktop-cursor", Cursor { x: local.0, y: local.1 }); + } + } + }); +} + +// ── Commands ────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub fn desktop_mochi_info(app: AppHandle, desktop: State>) -> DesktopInfo { + let on_desktop = app + .try_state::() + .map(|s| s.settings.lock().unwrap().desktop_mochi.on_desktop) + .unwrap_or(false); + DesktopInfo { mode: desktop.mode.as_str(), on_desktop: on_desktop && desktop.mode != DesktopMode::Off } +} + +/// Dragging Mochi out of the island: the window appears under the pointer +/// (`x`, `y`: island-window client coordinates) and follows it — the cursor +/// poll carries it on Windows, `desktop_mochi_carry` elsewhere. +#[tauri::command] +pub async fn desktop_mochi_pick_up(app: AppHandle, x: f64, y: f64) -> bool { + let d = app.state::>().inner().clone(); + if d.mode == DesktopMode::Off || window(&app).is_none() || d.inner.lock().unwrap().shown { + return false; + } + refresh_layer_display(&app, &d); + let s = side(&app, &d); + let center = match d.mode { + DesktopMode::Poll => platform::cursor_physical(), + _ => from_island_client(&app, &d, x, y), + }; + let Some(center) = center else { return false }; + d.flight.fetch_add(1, Ordering::SeqCst); + d.inner.lock().unwrap().carry = Some(Carry { grab: (s / 2.0, s / 2.0), from: Source::Island }); + show(&app, &d, (center.0 - s / 2.0, center.1 - s / 2.0)); + true +} + +/// Linux: the pointer moved while Mochi is being dragged out of the island. +#[tauri::command] +pub async fn desktop_mochi_carry(app: AppHandle, x: f64, y: f64) { + let d = app.state::>().inner().clone(); + if d.mode == DesktopMode::Poll || d.inner.lock().unwrap().carry.is_none() { + return; + } + let s = side(&app, &d); + if let Some(c) = from_island_client(&app, &d, x, y) { + place(&app, &d, (c.0 - s / 2.0, c.1 - s / 2.0)); + } +} + +/// Linux: the button went up while Mochi was being dragged out of the island. +#[tauri::command] +pub async fn desktop_mochi_carry_end(app: AppHandle, x: f64, y: f64) { + let d = app.state::>().inner().clone(); + if d.mode == DesktopMode::Poll || d.inner.lock().unwrap().carry.is_none() { + return; + } + let s = side(&app, &d); + let pos = from_island_client(&app, &d, x, y) + .map(|c| (c.0 - s / 2.0, c.1 - s / 2.0)) + .unwrap_or_else(|| d.inner.lock().unwrap().pos); + finish_drop(&app, &d, pos, Source::Island).await; +} + +/// The page saw a drag start on Mochi. Returns his top-left corner in the +/// mode's space, which the page needs to draw him in the overlay (layer mode). +#[tauri::command] +pub async fn desktop_mochi_drag_begin(app: AppHandle) -> Option<(f64, f64)> { + let d = app.state::>().inner().clone(); + let pos = { + let i = d.inner.lock().unwrap(); + if !i.landed || i.carry.is_some() { + return None; + } + i.pos + }; + // A drag takes over from a snap still settling him. + d.flight.fetch_add(1, Ordering::SeqCst); + match d.mode { + DesktopMode::Poll => { + let win = window(&app)?; + let (cx, cy) = platform::cursor_physical()?; + let origin = win.outer_position().ok()?; + let grab = (cx - origin.x as f64, cy - origin.y as f64); + d.inner.lock().unwrap().carry = Some(Carry { grab, from: Source::Desktop }); + refresh(&app, &d); + } + DesktopMode::Layer => { + d.inner.lock().unwrap().overlay = true; + let win = window(&app)?; + let _ = app.run_on_main_thread(move || { + platform::set_layer_overlay(&win, true); + platform::set_desktop_shape(&win, MouseShape::Whole); + }); + } + _ => {} + } + Some(pos) +} + +/// X11: the page moves the window during its drag (top-left, physical pixels). +#[tauri::command] +pub async fn desktop_mochi_drag_move(app: AppHandle, x: f64, y: f64) { + let d = app.state::>().inner().clone(); + if d.mode == DesktopMode::Window && d.inner.lock().unwrap().landed { + place(&app, &d, (x, y)); + } +} + +/// Linux: the page's drag ended with the top-left corner at (`x`, `y`), in the +/// mode's space. On Windows the poll sees the release itself. +#[tauri::command] +pub async fn desktop_mochi_drag_end(app: AppHandle, x: f64, y: f64) { + let d = app.state::>().inner().clone(); + if d.mode == DesktopMode::Poll || !d.inner.lock().unwrap().landed { + return; + } + if d.mode == DesktopMode::Layer { + d.inner.lock().unwrap().overlay = false; + if let Some(win) = window(&app) { + let _ = app.run_on_main_thread(move || { + platform::set_layer_overlay(&win, false); + platform::set_layer_margins(&win, x, y); + }); + } + if let Some(win) = window(&app) { + let _ = win.set_size(tauri::LogicalSize::new(SIZE, SIZE)); + } + } + place(&app, &d, (x, y)); + finish_drop(&app, &d, (x, y), Source::Desktop).await; +} + +/// Launch or the end of an alert: from the island to his spot. False when the +/// spot is on a display that is gone — he then stays home, and is forgotten +/// there so the next launch doesn't try again. +#[tauri::command] +pub async fn desktop_mochi_fly_out(app: AppHandle) -> bool { + let d = app.state::>().inner().clone(); + if d.mode == DesktopMode::Off || window(&app).is_none() { + return false; + } + refresh_layer_display(&app, &d); + let Some(target) = target_spot(&app, &d) else { + crate::log::line("desktop Mochi: his spot is on a display that is gone — he stays home"); + remember(&app, |p| p.on_desktop = false); + return false; + }; + let Some((cx, top, scale)) = island_anchor(&app, &d) else { return false }; + let start = logic::island_spot(cx, top, SIZE, scale); + { + let mut i = d.inner.lock().unwrap(); + i.landed = false; + i.carry = None; + i.asleep = false; + } + show(&app, &d, start); + let _ = app.emit_to(LABEL, "desktop-flight", "out"); + if !fly(&app, &d, target, FLIGHT_MS, logic::ease_in_out).await { + return false; + } + d.inner.lock().unwrap().landed = true; + refresh(&app, &d); + remember_spot(&app, &d, target, true); + true +} + +/// To the island, then hidden. `forget` is the user bringing him home (double +/// click, drop on the island); without it he is only away for an alert and +/// flies back out once it is answered. +#[tauri::command] +pub async fn desktop_mochi_fly_home(app: AppHandle, forget: bool) -> bool { + let d = app.state::>().inner().clone(); + if !d.inner.lock().unwrap().shown { + if forget { + remember(&app, |p| p.on_desktop = false); + } + return true; + } + { + let mut i = d.inner.lock().unwrap(); + i.landed = false; + i.carry = None; + i.asleep = false; + } + refresh(&app, &d); + let _ = app.emit_to(LABEL, "desktop-flight", "home"); + let done = match island_anchor(&app, &d) { + Some((cx, top, scale)) => { + let to = logic::island_spot(cx, top, SIZE, scale); + fly(&app, &d, to, FLIGHT_MS, logic::ease_in_out).await + } + None => true, + }; + if done { + hide(&app, &d); + } + if forget { + remember(&app, |p| p.on_desktop = false); + } + done +} + +/// The page dozed off (or woke up). Asleep, nothing polls. +#[tauri::command] +pub fn desktop_mochi_set_asleep(app: AppHandle, desktop: State>, asleep: bool) { + let changed = { + let mut i = desktop.inner.lock().unwrap(); + let changed = i.asleep != asleep; + i.asleep = asleep; + changed + }; + if changed { + refresh(&app, &desktop); + } +} + +#[cfg(test)] +mod tests { + use super::logic::*; + + // Mirrors tests/DesktopMochiTests.swift (isOverBody, clampOrigin), in the + // y-down coordinates every PC uses. + + #[test] + fn is_over_body() { + let s = 120.0; + let r = s * BODY_RADIUS_FRACTION; // 28.8 + assert!(super::logic::is_over_body((60.0, 60.0), s), "center must be inside body"); + assert!(super::logic::is_over_body((60.0 + r - 0.5, 60.0), s), "inside radius must hit"); + assert!(!super::logic::is_over_body((60.0 + r + 0.5, 60.0), s), "outside radius must miss"); + assert!(!super::logic::is_over_body((0.0, 0.0), s), "corner must miss"); + let diag = r / 2f64.sqrt() - 0.5; + assert!(super::logic::is_over_body((60.0 + diag, 60.0 + diag), s), "diagonal inside must hit"); + assert!(super::logic::is_over_body((60.0 - diag, 60.0 + diag), s), "every quadrant"); + } + + #[test] + fn clamp_origin_keeps_him_inside_the_work_area() { + // A 1440×900 display with a 40 px taskbar at the bottom. + let work = Rect { x: 0.0, y: 0.0, w: 1440.0, h: 860.0 }; + let (s, m) = (120.0, 24.0); + assert_eq!(clamp_origin((600.0, 400.0), s, work, m), (600.0, 400.0), "in-bounds origin must be unchanged"); + assert_eq!(clamp_origin((-50.0, 400.0), s, work, m).0, work.x + m, "too-left"); + assert_eq!(clamp_origin((2000.0, 400.0), s, work, m).0, work.x + work.w - s - m, "too-right"); + assert_eq!(clamp_origin((400.0, -50.0), s, work, m).1, work.y + m, "too-high"); + assert_eq!(clamp_origin((400.0, 2000.0), s, work, m).1, work.y + work.h - s - m, "too-low: above the taskbar"); + } + + fn two_displays() -> Vec { + // A 1920×1080 laptop at 100 %, and a 4K monitor at 200 % on its right, + // physical pixels; both with a 48 px taskbar at the bottom. + let a = Rect { x: 0.0, y: 0.0, w: 1920.0, h: 1080.0 }; + let b = Rect { x: 1920.0, y: -200.0, w: 3840.0, h: 2160.0 }; + vec![ + Display { frame: a, work: Rect { h: 1032.0, ..a }, scale: 1.0 }, + Display { frame: b, work: Rect { h: 2064.0, ..b }, scale: 2.0 }, + ] + } + + #[test] + fn a_spot_on_a_connected_display_is_kept() { + let d = two_displays(); + assert_eq!(restore_spot((800.0, 600.0), &d, 120.0, 24.0), Some((800.0, 600.0))); + // On the second display, sized in its own scale. + assert_eq!(restore_spot((3000.0, 1000.0), &d, 120.0, 24.0), Some((3000.0, 1000.0))); + } + + #[test] + fn a_spot_on_a_display_that_is_gone_brings_him_home() { + let d = two_displays(); + // Left of the laptop: a monitor that has been unplugged. + assert_eq!(restore_spot((-1500.0, 300.0), &d, 120.0, 24.0), None); + // Nothing connected at all. + assert_eq!(restore_spot((800.0, 600.0), &[], 120.0, 24.0), None); + // Only the first display left: the spot on the second is gone. + assert_eq!(restore_spot((3000.0, 1000.0), &d[..1], 120.0, 24.0), None); + } + + #[test] + fn a_spot_that_a_taller_taskbar_now_covers_moves_up() { + let d = two_displays(); + let (x, y) = restore_spot((800.0, 1000.0), &d, 120.0, 24.0).unwrap(); + assert_eq!(x, 800.0); + assert_eq!(y, 1032.0 - 120.0 - 24.0); + // The 200 % display clamps with its own scale. + let (_, y) = restore_spot((3000.0, 1700.0), &d, 120.0, 24.0).unwrap(); + assert_eq!(y, -200.0 + 2064.0 - 240.0 - 48.0); + } + + #[test] + fn a_drop_settles_on_the_display_under_him() { + let d = two_displays(); + // Dropped against the right edge of the laptop: stays on the laptop. + assert_eq!(settle((1800.0, 500.0), &d, 120.0, 24.0), (1920.0 - 120.0 - 24.0, 500.0)); + // His centre already over the next display: he moves onto it. + assert_eq!(settle((1870.0, 500.0), &d, 120.0, 24.0), (1920.0 + 48.0, 500.0)); + // Dropped in the gap above the laptop, next to the taller monitor: + // nearest display. + let (x, y) = settle((1000.0, -150.0), &d, 120.0, 24.0); + assert_eq!((x, y), (1000.0, 24.0)); + } + + #[test] + fn first_visit_is_the_bottom_right_corner() { + let d = two_displays(); + assert_eq!(default_spot(&d[0], 120.0, 24.0), (1920.0 - 144.0, 1032.0 - 144.0)); + assert_eq!(default_spot(&d[1], 120.0, 24.0), (1920.0 + 3840.0 - 288.0, -200.0 + 2064.0 - 288.0)); + } + + #[test] + fn the_island_panel_is_home() { + // Island centred on a 1920 px display at 150 %. + let zone = home_zone(960.0, 0.0, 1.5); + assert!(zone.contains((960.0, 10.0)), "on the island"); + assert!(zone.contains((960.0 - 359.0 * 1.5, 300.0 * 1.5)), "inside the panel"); + assert!(!zone.contains((960.0, 330.0 * 1.5)), "below the panel"); + assert!(!zone.contains((100.0, 10.0)), "top-left corner of the screen"); + } + + #[test] + fn he_leaves_and_returns_under_the_top_centre() { + assert_eq!(island_spot(960.0, 0.0, 120.0, 1.0), (900.0, 0.0)); + assert_eq!(island_spot(2880.0, -200.0, 120.0, 2.0), (2760.0, -200.0)); + } + + #[test] + fn flights_start_and_end_where_asked() { + for ease in [ease_in_out as fn(f64) -> f64, ease_out_back] { + assert!(ease(0.0).abs() < 1e-9); + assert!((ease(1.0) - 1.0).abs() < 1e-9); + } + assert!(ease_out_back(0.7) > 1.0, "the landing overshoots a little"); + assert_eq!(lerp((0.0, 10.0), (100.0, 20.0), 0.5), (50.0, 15.0)); + } +} diff --git a/windows/src-tauri/src/files.rs b/windows/src-tauri/src/files.rs index 144907152..629c4aa79 100644 --- a/windows/src-tauri/src/files.rs +++ b/windows/src-tauri/src/files.rs @@ -23,11 +23,54 @@ pub fn inbox_dir() -> PathBuf { settings::local_dir().join("inbox") } +// ── Paths a real drop delivered ───────────────────────────────────────────── +// +// `ingest_file` is callable from the page, so on its own it would copy any file +// the user can read (a script injected into the page could pull in ~/.ssh). +// Only paths the OS just delivered through a real drop are accepted: WebView2's +// drop objects on Windows (webview_drop.rs), Tauri's drag-drop window event +// elsewhere (lib.rs). Each is good once, for a couple of minutes. + +const DROP_VALID_FOR: Duration = Duration::from_secs(120); +const DROP_MAX_PENDING: usize = 64; + +static DROPPED: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Records paths that came from a real drop. +pub fn allow_dropped>(paths: I) { + let mut list = DROPPED.lock().unwrap_or_else(|e| e.into_inner()); + let now = std::time::Instant::now(); + list.retain(|(_, at)| now.duration_since(*at) < DROP_VALID_FOR); + for p in paths { + list.push((p, now)); + } + let excess = list.len().saturating_sub(DROP_MAX_PENDING); + list.drain(..excess); +} + +/// True (once) when `path` was delivered by a drop in the last couple of minutes. +fn take_dropped(path: &str) -> bool { + let mut list = DROPPED.lock().unwrap_or_else(|e| e.into_inner()); + let now = std::time::Instant::now(); + list.retain(|(_, at)| now.duration_since(*at) < DROP_VALID_FOR); + match list.iter().position(|(p, _)| p == path) { + Some(i) => { + list.remove(i); + true + } + None => false, + } +} + pub fn ingest(source: &str) -> Result { + if !take_dropped(source) { + return Err(crate::i18n::t("Only files dropped on the island can be added.")); + } let src = Path::new(source); - let meta = std::fs::metadata(src).map_err(|e| format!("cannot read {source}: {e}"))?; + let meta = std::fs::metadata(src) + .map_err(|e| crate::i18n::tf("Cannot read {path}: {error}", &[("path", source), ("error", &e.to_string())]))?; if meta.is_dir() { - return Err("Folders can't be dropped yet.".into()); + return Err(crate::i18n::t("Folders can't be dropped yet.")); } let dir = inbox_dir(); @@ -52,7 +95,7 @@ pub fn ingest(source: &str) -> Result { } } - std::fs::copy(src, &dest).map_err(|e| format!("cannot copy: {e}"))?; + std::fs::copy(src, &dest).map_err(|e| crate::i18n::tf("Cannot copy: {error}", &[("error", &e.to_string())]))?; // CopyFileEx carries the source's timestamps across, so a file last edited // three years ago would arrive already older than the sweep window and be // deleted on the spot. The inbox ages from when *we* copied it. @@ -94,18 +137,22 @@ mod tests { let source = tmp.join("note.txt"); std::fs::write(&source, b"hello").unwrap(); + let drop = |p: &Path| allow_dropped([p.to_string_lossy().to_string()]); + drop(&source); let first = ingest(source.to_str().unwrap()).unwrap(); assert_eq!(first.name, "note.txt"); assert_eq!(std::fs::read(&first.path).unwrap(), b"hello"); // A second drop of the same name must not clobber the first copy. std::fs::write(&source, b"second").unwrap(); + drop(&source); let second = ingest(source.to_str().unwrap()).unwrap(); assert_ne!(first.path, second.path); assert_eq!(std::fs::read(&first.path).unwrap(), b"hello"); assert_eq!(std::fs::read(&second.path).unwrap(), b"second"); // Folders are refused rather than silently ignored. + drop(&tmp); assert!(ingest(tmp.to_str().unwrap()).is_err()); // An ancient source must not arrive already older than the sweep window. @@ -118,6 +165,7 @@ mod tests { .unwrap() .set_modified(long_ago) .unwrap(); + drop(&old_source); let aged = ingest(old_source.to_str().unwrap()).unwrap(); assert!( Path::new(&aged.path).exists(), @@ -130,3 +178,23 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } } + +#[cfg(test)] +mod drop_tests { + use super::*; + + // One test: the list is process-wide and tests run in parallel. + #[test] + fn only_a_dropped_path_is_ingested_once_and_the_list_stays_bounded() { + let p = "/tmp/coucou-test-not-dropped.txt".to_string(); + assert!(ingest(&p).is_err(), "never dropped"); + allow_dropped([p.clone()]); + assert!(take_dropped(&p)); + assert!(!take_dropped(&p), "good once"); + + allow_dropped((0..200).map(|i| format!("/tmp/bounded-{i}"))); + assert!(DROPPED.lock().unwrap().len() <= DROP_MAX_PENDING); + assert!(take_dropped("/tmp/bounded-199")); + assert!(!take_dropped("/tmp/bounded-0")); + } +} diff --git a/windows/src-tauri/src/github.rs b/windows/src-tauri/src/github.rs new file mode 100644 index 000000000..f1b2907c5 --- /dev/null +++ b/windows/src-tauri/src/github.rs @@ -0,0 +1,799 @@ +// GitHub pulse and contribution calendar — the pure half of GithubPoller.swift, +// GitHubPulse.swift and GitHubActivity.swift. Parsing, alert detection and the +// staleness rule live here, away from the network, so they can be tested. +// +// The pollers that use them are in integrations.rs. + +use serde::Serialize; +use serde_json::Value; + +// ── CI and review states ────────────────────────────────────────────────────── + +#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CiState { + Pending, + Success, + Failure, + Unknown, +} + +impl CiState { + /// GitHub's `StatusState` (statusCheckRollup.state) → our four states. + pub fn from_github(raw: Option<&str>) -> Self { + match raw.map(str::to_ascii_uppercase).as_deref() { + Some("PENDING") | Some("EXPECTED") => CiState::Pending, + Some("SUCCESS") => CiState::Success, + Some("ERROR") | Some("FAILURE") => CiState::Failure, + _ => CiState::Unknown, + } + } +} + +#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ReviewState { + Approved, + ChangesRequested, + Pending, + Unknown, +} + +impl ReviewState { + pub fn from_github(raw: Option<&str>) -> Self { + match raw.map(str::to_ascii_uppercase).as_deref() { + Some("APPROVED") => ReviewState::Approved, + Some("CHANGES_REQUESTED") => ReviewState::ChangesRequested, + Some("REVIEW_REQUIRED") => ReviewState::Pending, + _ => ReviewState::Unknown, + } + } +} + +// ── Pulse ───────────────────────────────────────────────────────────────────── + +#[derive(Serialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GitHubPr { + /// "owner/repo#number" + pub id: String, + pub title: String, + pub url: String, + pub repo: String, + pub number: i64, + pub is_draft: bool, + pub ci: CiState, + pub review: ReviewState, + /// Oid of the PR's last commit, when GitHub returned it. + pub head_sha: Option, +} + +#[derive(Serialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GitHubRepoCi { + pub repo: String, + pub url: String, + pub branch: String, + pub ci: CiState, + /// Oid of the default branch's head commit, when GitHub returned it. + pub head_sha: Option, +} + +#[derive(Serialize, Clone, Debug, PartialEq)] +pub struct GitHubPulse { + pub login: String, + #[serde(rename = "myPRs")] + pub my_prs: Vec, + #[serde(rename = "toReview")] + pub to_review: Vec, + #[serde(rename = "mainCI")] + pub main_ci: Vec, + /// Unix time in milliseconds. + #[serde(rename = "fetchedAt")] + pub fetched_at: u64, +} + +/// What a pulse poll can raise: the island turns these into a badge and a sound. +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(tag = "kind")] +pub enum GitHubEvent { + #[serde(rename = "ciFailed")] + CiFailed { + #[serde(rename = "prId")] + pr_id: String, + }, + #[serde(rename = "ciPassed")] + CiPassed { + #[serde(rename = "prId")] + pr_id: String, + }, + #[serde(rename = "mainFailed")] + MainFailed { repo: String }, + #[serde(rename = "reviewRequested")] + ReviewRequested { + #[serde(rename = "prId")] + pr_id: String, + }, +} + +fn str_at<'a>(v: &'a Value, key: &str) -> Option<&'a str> { + v.get(key).and_then(Value::as_str) +} + +/// Pull request fields shared by "my PRs" and "to review"; None when a required +/// field is missing (that node is skipped, as on macOS). +fn pr_basics(node: &Value) -> Option<(String, String, String, String, i64, bool)> { + let number = node.get("number")?.as_i64()?; + let title = str_at(node, "title")?.to_string(); + let url = str_at(node, "url")?.to_string(); + let repo = node.get("repository").and_then(|r| str_at(r, "nameWithOwner"))?.to_string(); + let is_draft = node.get("isDraft").and_then(Value::as_bool).unwrap_or(false); + Some((format!("{repo}#{number}"), title, url, repo, number, is_draft)) +} + +fn nodes<'a>(parent: Option<&'a Value>) -> &'a [Value] { + parent + .and_then(|p| p.get("nodes")) + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]) +} + +impl GitHubPulse { + /// Parses the pulse GraphQL response. None when there is no `data.viewer`. + pub fn parse(root: &Value, now_ms: u64) -> Option { + let data = root.get("data")?.as_object()?; + let viewer = data.get("viewer")?.as_object()?; + let login = viewer.get("login").and_then(Value::as_str).unwrap_or("").to_string(); + + // My pull requests, first occurrence of an id wins. + let mut my_prs: Vec = Vec::new(); + for node in nodes(viewer.get("pullRequests")) { + let Some((id, title, url, repo, number, is_draft)) = pr_basics(node) else { continue }; + if my_prs.iter().any(|p| p.id == id) { + continue; + } + let review = ReviewState::from_github(str_at(node, "reviewDecision")); + let commit = node + .get("commits") + .and_then(|c| c.get("nodes")) + .and_then(Value::as_array) + .and_then(|a| a.last()) + .and_then(|n| n.get("commit")); + let ci_raw = commit + .and_then(|c| c.get("statusCheckRollup")) + .and_then(|r| str_at(r, "state")); + let head_sha = commit.and_then(|c| str_at(c, "oid")).map(str::to_string); + my_prs.push(GitHubPr { + id, + title, + url, + repo, + number, + is_draft, + ci: CiState::from_github(ci_raw), + review, + head_sha, + }); + } + + // Default-branch CI of the recently pushed repos, archived ones left out. + let mut main_ci: Vec = Vec::new(); + for node in nodes(viewer.get("repositories")) { + if node.get("isArchived").and_then(Value::as_bool).unwrap_or(false) { + continue; + } + let (Some(repo), Some(url)) = (str_at(node, "nameWithOwner"), str_at(node, "url")) else { + continue; + }; + let Some(branch_ref) = node.get("defaultBranchRef").filter(|b| b.is_object()) else { + continue; + }; + let Some(branch) = str_at(branch_ref, "name") else { continue }; + let target = branch_ref.get("target"); + let ci_raw = target + .and_then(|t| t.get("statusCheckRollup")) + .and_then(|r| str_at(r, "state")); + let head_sha = target.and_then(|t| str_at(t, "oid")).map(str::to_string); + main_ci.push(GitHubRepoCi { + repo: repo.to_string(), + url: url.to_string(), + branch: branch.to_string(), + ci: CiState::from_github(ci_raw), + head_sha, + }); + } + + // Pull requests waiting for my review. + let mut to_review: Vec = Vec::new(); + for node in nodes(data.get("reviewRequested")) { + let Some((id, title, url, repo, number, is_draft)) = pr_basics(node) else { continue }; + if to_review.iter().any(|p| p.id == id) { + continue; + } + to_review.push(GitHubPr { + id, + title, + url, + repo, + number, + is_draft, + ci: CiState::Unknown, + review: ReviewState::Pending, + head_sha: None, + }); + } + + Some(GitHubPulse { login, my_prs, to_review, main_ci, fetched_at: now_ms }) + } + + /// True when a PR's CI or a default branch is still running — the next poll + /// then comes after 60 s instead of 5 min. + pub fn has_pending(&self) -> bool { + self.my_prs.iter().any(|p| p.ci == CiState::Pending) + || self.main_ci.iter().any(|r| r.ci == CiState::Pending) + } + + /// Alerts between two polls. The first poll after launch (or after the token + /// changed) has no `old` and stays silent. + /// + /// headSha rule, which catches CI runs faster than the poll interval: + /// - same commit (or both unknown): plain state transitions; + /// - new commit or new PR: alert at once if its CI is already done; if it is + /// still running, the next poll on the same commit will catch the result; + /// - default branches only ever raise "failed", never "passed". + pub fn events(old: Option<&GitHubPulse>, new: &GitHubPulse) -> Vec { + let Some(old) = old else { return Vec::new() }; + let mut out = Vec::new(); + + for pr in &new.my_prs { + // First occurrence wins, should the old list hold a duplicate. + let prev = old.my_prs.iter().find(|p| p.id == pr.id); + match prev { + Some(prev) if prev.head_sha == pr.head_sha => { + if pr.ci == CiState::Failure && prev.ci != CiState::Failure { + out.push(GitHubEvent::CiFailed { pr_id: pr.id.clone() }); + } else if pr.ci == CiState::Success && prev.ci == CiState::Pending { + out.push(GitHubEvent::CiPassed { pr_id: pr.id.clone() }); + } + } + _ => match pr.ci { + CiState::Success => out.push(GitHubEvent::CiPassed { pr_id: pr.id.clone() }), + CiState::Failure => out.push(GitHubEvent::CiFailed { pr_id: pr.id.clone() }), + _ => {} + }, + } + } + + for repo in &new.main_ci { + let prev = old.main_ci.iter().find(|r| r.repo == repo.repo); + let failed = match prev { + Some(prev) if prev.head_sha == repo.head_sha => { + repo.ci == CiState::Failure && prev.ci != CiState::Failure + } + _ => repo.ci == CiState::Failure, + }; + if failed { + out.push(GitHubEvent::MainFailed { repo: repo.repo.clone() }); + } + } + + for pr in &new.to_review { + if !old.to_review.iter().any(|p| p.id == pr.id) { + out.push(GitHubEvent::ReviewRequested { pr_id: pr.id.clone() }); + } + } + + out + } +} + +/// True when nothing was fetched yet or the last fetch is older than `max_age_secs`. +pub fn is_stale(fetched_at_ms: Option, now_ms: u64, max_age_secs: u64) -> bool { + match fetched_at_ms { + None => true, + Some(t) => now_ms.saturating_sub(t) > max_age_secs * 1000, + } +} + +// ── Contribution calendar ───────────────────────────────────────────────────── + +#[derive(Serialize, Clone, Debug, PartialEq)] +pub struct ContributionDay { + /// YYYY-MM-DD + pub date: String, + pub count: i64, + /// 0…4, from NONE … FOURTH_QUARTILE. + pub level: u8, + /// 0 = Sunday … 6 = Saturday. + pub weekday: u8, +} + +#[derive(Serialize, Clone, Debug, PartialEq)] +pub struct GitHubActivity { + pub total: i64, + /// Oldest week first; the last one is usually the current, incomplete week. + pub weeks: Vec>, + #[serde(rename = "fetchedAt")] + pub fetched_at: u64, +} + +fn contribution_level(raw: &str) -> u8 { + match raw { + "FIRST_QUARTILE" => 1, + "SECOND_QUARTILE" => 2, + "THIRD_QUARTILE" => 3, + "FOURTH_QUARTILE" => 4, + _ => 0, + } +} + +impl GitHubActivity { + pub fn parse(root: &Value, now_ms: u64) -> Option { + let calendar = root + .get("data")? + .get("viewer")? + .get("contributionsCollection")? + .get("contributionCalendar")?; + let total = calendar.get("totalContributions")?.as_i64()?; + let weeks_raw = calendar.get("weeks")?.as_array()?; + + let mut weeks = Vec::new(); + for week in weeks_raw { + let Some(days_raw) = week.get("contributionDays").and_then(Value::as_array) else { continue }; + let days: Vec = days_raw + .iter() + .filter_map(|d| { + Some(ContributionDay { + date: str_at(d, "date")?.to_string(), + count: d.get("contributionCount")?.as_i64()?, + level: contribution_level(str_at(d, "contributionLevel")?), + weekday: u8::try_from(d.get("weekday")?.as_i64()?).ok()?, + }) + }) + .collect(); + if !days.is_empty() { + weeks.push(days); + } + } + Some(GitHubActivity { total, weeks, fetched_at: now_ms }) + } +} + +// ── GraphQL ─────────────────────────────────────────────────────────────────── + +/// Same query as GithubPoller.swift. +pub const PULSE_QUERY: &str = r#" +query { + viewer { + login + pullRequests(states: OPEN, first: 20, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + number title url isDraft reviewDecision + repository { nameWithOwner url } + commits(last: 1) { + nodes { commit { oid statusCheckRollup { state } } } + } + } + } + repositories(first: 10, ownerAffiliations: [OWNER], orderBy: {field: PUSHED_AT, direction: DESC}) { + nodes { + nameWithOwner url isArchived + defaultBranchRef { + name + target { ... on Commit { oid statusCheckRollup { state } } } + } + } + } + } + reviewRequested: search(query: "is:pr is:open review-requested:@me archived:false", type: ISSUE, first: 20) { + issueCount + nodes { + ... on PullRequest { + number title url isDraft + author { login } + repository { nameWithOwner url } + } + } + } +} +"#; + +pub const ACTIVITY_QUERY: &str = r#" +query { + viewer { + login + contributionsCollection { + contributionCalendar { + totalContributions + weeks { + contributionDays { + date contributionCount contributionLevel weekday + } + } + } + } + } +} +"#; + +/// Number of GraphQL errors in a response, for the log (never their text: it +/// can quote the query, not secrets, but there is no need for it). +pub fn graphql_error_count(root: &Value) -> usize { + root.get("errors").and_then(Value::as_array).map(Vec::len).unwrap_or(0) +} + +// ── Tests (GitHubPulseTests.swift, GitHubActivityTests.swift) ──────────────── + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn valid_pulse() -> Value { + json!({ + "data": { + "viewer": { + "login": "testuser", + "pullRequests": { "nodes": [ + { + "number": 42, "title": "Add feature", + "url": "https://github.com/testuser/myrepo/pull/42", + "isDraft": false, "reviewDecision": "APPROVED", + "repository": {"nameWithOwner": "testuser/myrepo", "url": "https://github.com/testuser/myrepo"}, + "commits": {"nodes": [{"commit": {"oid": "aaa", "statusCheckRollup": {"state": "SUCCESS"}}}]} + }, + { + "number": 43, "title": "Fix bug", + "url": "https://github.com/testuser/myrepo/pull/43", + "isDraft": true, "reviewDecision": null, + "repository": {"nameWithOwner": "testuser/myrepo", "url": "https://github.com/testuser/myrepo"}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": null}}]} + } + ]}, + "repositories": { "nodes": [ + { + "nameWithOwner": "testuser/myrepo", "url": "https://github.com/testuser/myrepo", + "isArchived": false, + "defaultBranchRef": {"name": "main", "target": {"oid": "bbb", "statusCheckRollup": {"state": "PENDING"}}} + }, + { + "nameWithOwner": "testuser/archived", "url": "https://github.com/testuser/archived", + "isArchived": true, + "defaultBranchRef": {"name": "main", "target": {"statusCheckRollup": {"state": "SUCCESS"}}} + } + ]} + }, + "reviewRequested": { + "issueCount": 1, + "nodes": [{ + "number": 7, "title": "Review this", + "url": "https://github.com/other/repo/pull/7", + "isDraft": false, "author": {"login": "otheruser"}, + "repository": {"nameWithOwner": "other/repo", "url": "https://github.com/other/repo"} + }] + } + } + }) + } + + fn empty_pulse() -> GitHubPulse { + let root = json!({ + "data": { + "viewer": {"login": "testuser", "pullRequests": {"nodes": []}, "repositories": {"nodes": []}}, + "reviewRequested": {"issueCount": 0, "nodes": []} + } + }); + GitHubPulse::parse(&root, 0).unwrap() + } + + fn pr(id: &str, ci: CiState, sha: Option<&str>) -> GitHubPr { + GitHubPr { + id: id.into(), + title: "T".into(), + url: String::new(), + repo: id.split('#').next().unwrap().into(), + number: 1, + is_draft: false, + ci, + review: ReviewState::Unknown, + head_sha: sha.map(str::to_string), + } + } + + fn repo(name: &str, ci: CiState, sha: Option<&str>) -> GitHubRepoCi { + GitHubRepoCi { repo: name.into(), url: String::new(), branch: "main".into(), ci, head_sha: sha.map(str::to_string) } + } + + #[test] + fn parses_a_full_pulse() { + let p = GitHubPulse::parse(&valid_pulse(), 5).unwrap(); + assert_eq!(p.login, "testuser"); + assert_eq!(p.my_prs.len(), 2); + assert_eq!(p.my_prs[0].id, "testuser/myrepo#42"); + assert_eq!(p.my_prs[0].ci, CiState::Success); + assert_eq!(p.my_prs[0].review, ReviewState::Approved); + assert_eq!(p.my_prs[0].head_sha.as_deref(), Some("aaa")); + assert_eq!(p.my_prs[1].ci, CiState::Unknown, "null rollup"); + assert_eq!(p.my_prs[1].review, ReviewState::Unknown, "null review"); + assert!(p.my_prs[1].is_draft); + assert_eq!(p.main_ci.len(), 1, "archived repo filtered out"); + assert_eq!(p.main_ci[0].repo, "testuser/myrepo"); + assert_eq!(p.main_ci[0].ci, CiState::Pending); + assert_eq!(p.main_ci[0].branch, "main"); + assert_eq!(p.main_ci[0].head_sha.as_deref(), Some("bbb")); + assert_eq!(p.to_review.len(), 1); + assert_eq!(p.to_review[0].id, "other/repo#7"); + assert_eq!(p.to_review[0].review, ReviewState::Pending); + assert!(p.has_pending(), "default branch pending"); + assert_eq!(p.fetched_at, 5); + } + + #[test] + fn parses_an_empty_pulse() { + let p = empty_pulse(); + assert!(p.my_prs.is_empty() && p.main_ci.is_empty() && p.to_review.is_empty()); + assert!(!p.has_pending()); + } + + #[test] + fn rejects_bad_pulse_data() { + assert!(GitHubPulse::parse(&json!("garbage"), 0).is_none()); + assert!(GitHubPulse::parse(&json!({}), 0).is_none()); + assert!(GitHubPulse::parse(&json!({"data": null}), 0).is_none()); + } + + #[test] + fn keeps_the_first_of_duplicate_prs() { + let root = json!({ + "data": { + "viewer": { + "login": "testuser", + "pullRequests": {"nodes": [ + {"number": 42, "title": "Add feature", "url": "u", "isDraft": false, "reviewDecision": "APPROVED", + "repository": {"nameWithOwner": "testuser/myrepo"}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]}}, + {"number": 42, "title": "Duplicate entry", "url": "u", "isDraft": true, "reviewDecision": null, + "repository": {"nameWithOwner": "testuser/myrepo"}, + "commits": {"nodes": [{"commit": {"statusCheckRollup": {"state": "FAILURE"}}}]}} + ]}, + "repositories": {"nodes": []} + }, + "reviewRequested": {"issueCount": 0, "nodes": []} + } + }); + let p = GitHubPulse::parse(&root, 0).unwrap(); + assert_eq!(p.my_prs.len(), 1); + assert_eq!(p.my_prs[0].title, "Add feature"); + assert_eq!(p.my_prs[0].ci, CiState::Success); + } + + #[test] + fn maps_ci_states() { + assert_eq!(CiState::from_github(None), CiState::Unknown); + assert_eq!(CiState::from_github(Some("PENDING")), CiState::Pending); + assert_eq!(CiState::from_github(Some("EXPECTED")), CiState::Pending); + assert_eq!(CiState::from_github(Some("SUCCESS")), CiState::Success); + assert_eq!(CiState::from_github(Some("FAILURE")), CiState::Failure); + assert_eq!(CiState::from_github(Some("ERROR")), CiState::Failure); + assert_eq!(CiState::from_github(Some("WAITING")), CiState::Unknown); + } + + #[test] + fn first_poll_is_silent() { + let p = GitHubPulse::parse(&valid_pulse(), 0).unwrap(); + assert!(GitHubPulse::events(None, &p).is_empty()); + } + + #[test] + fn pending_to_success_passes() { + let mut old = empty_pulse(); + old.my_prs = vec![pr("r/p#1", CiState::Pending, None)]; + let mut new = old.clone(); + new.my_prs[0].ci = CiState::Success; + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::CiPassed { pr_id: "r/p#1".into() }]); + } + + #[test] + fn success_to_failure_fails() { + let mut old = empty_pulse(); + old.my_prs = vec![pr("r/p#2", CiState::Success, None)]; + let mut new = old.clone(); + new.my_prs[0].ci = CiState::Failure; + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::CiFailed { pr_id: "r/p#2".into() }]); + } + + #[test] + fn default_branch_breaking_alerts() { + let mut old = empty_pulse(); + old.main_ci = vec![repo("a/b", CiState::Success, None)]; + let mut new = old.clone(); + new.main_ci[0].ci = CiState::Failure; + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::MainFailed { repo: "a/b".into() }]); + } + + #[test] + fn default_branch_turning_green_is_quiet() { + let mut old = empty_pulse(); + old.main_ci = vec![repo("a/b", CiState::Pending, Some("x"))]; + let mut new = old.clone(); + new.main_ci[0].ci = CiState::Success; + assert!(GitHubPulse::events(Some(&old), &new).is_empty()); + new.main_ci[0].head_sha = Some("y".into()); + assert!(GitHubPulse::events(Some(&old), &new).is_empty()); + } + + #[test] + fn new_review_request_alerts_once() { + let mut review = pr("o/r#7", CiState::Unknown, None); + review.review = ReviewState::Pending; + let mut old = empty_pulse(); + let mut new = old.clone(); + new.to_review = vec![review.clone()]; + assert_eq!( + GitHubPulse::events(Some(&old), &new), + vec![GitHubEvent::ReviewRequested { pr_id: "o/r#7".into() }] + ); + old.to_review = vec![review]; + assert!(GitHubPulse::events(Some(&old), &new).is_empty(), "already known"); + } + + #[test] + fn duplicate_ids_in_old_fire_once() { + let mut old = empty_pulse(); + let p = pr("r/p#1", CiState::Pending, None); + old.my_prs = vec![p.clone(), p.clone()]; + let mut new = empty_pulse(); + let mut passed = p; + passed.ci = CiState::Success; + new.my_prs = vec![passed]; + let events = GitHubPulse::events(Some(&old), &new); + assert_eq!(events.iter().filter(|e| matches!(e, GitHubEvent::CiPassed { .. })).count(), 1); + } + + #[test] + fn new_pr_already_green_passes() { + let old = empty_pulse(); + let mut new = old.clone(); + new.my_prs = vec![pr("r/p#10", CiState::Success, Some("abc111"))]; + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::CiPassed { pr_id: "r/p#10".into() }]); + } + + #[test] + fn new_pr_still_pending_is_quiet() { + let old = empty_pulse(); + let mut new = old.clone(); + new.my_prs = vec![pr("r/p#11", CiState::Pending, Some("abc222"))]; + assert!(GitHubPulse::events(Some(&old), &new).is_empty()); + } + + #[test] + fn new_commit_already_red_fails() { + let mut old = empty_pulse(); + old.my_prs = vec![pr("r/p#12", CiState::Success, Some("sha-old"))]; + let mut new = old.clone(); + new.my_prs[0].ci = CiState::Failure; + new.my_prs[0].head_sha = Some("sha-new".into()); + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::CiFailed { pr_id: "r/p#12".into() }]); + } + + #[test] + fn new_commit_already_green_passes_even_from_green() { + // A fast CI ran entirely between two polls: success → (new sha) success. + let mut old = empty_pulse(); + old.my_prs = vec![pr("r/p#14", CiState::Success, Some("one"))]; + let mut new = old.clone(); + new.my_prs[0].head_sha = Some("two".into()); + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::CiPassed { pr_id: "r/p#14".into() }]); + } + + #[test] + fn same_sha_success_stays_quiet() { + let mut old = empty_pulse(); + old.my_prs = vec![pr("r/p#13", CiState::Success, Some("same"))]; + let new = old.clone(); + assert!(GitHubPulse::events(Some(&old), &new).is_empty()); + } + + #[test] + fn new_commit_on_main_red_fails() { + let mut old = empty_pulse(); + old.main_ci = vec![repo("a/b", CiState::Success, Some("sha-old"))]; + let mut new = old.clone(); + new.main_ci[0].ci = CiState::Failure; + new.main_ci[0].head_sha = Some("sha-new".into()); + assert_eq!(GitHubPulse::events(Some(&old), &new), vec![GitHubEvent::MainFailed { repo: "a/b".into() }]); + } + + #[test] + fn staleness() { + let now = 1_000_000; + assert!(is_stale(None, now, 60)); + assert!(!is_stale(Some(now), now, 60)); + assert!(!is_stale(Some(now - 60_000), now, 60)); + assert!(is_stale(Some(now - 61_000), now, 60)); + assert!(!is_stale(Some(now + 5_000), now, 60), "clock skew is not stale"); + } + + #[test] + fn events_serialise_for_the_island() { + let v = serde_json::to_value(GitHubEvent::CiFailed { pr_id: "a/b#1".into() }).unwrap(); + assert_eq!(v, json!({"kind": "ciFailed", "prId": "a/b#1"})); + let v = serde_json::to_value(GitHubEvent::MainFailed { repo: "a/b".into() }).unwrap(); + assert_eq!(v, json!({"kind": "mainFailed", "repo": "a/b"})); + let p = serde_json::to_value(GitHubPulse::parse(&valid_pulse(), 7).unwrap()).unwrap(); + assert!(p.get("myPRs").is_some() && p.get("toReview").is_some() && p.get("mainCI").is_some()); + assert_eq!(p["myPRs"][0]["isDraft"], json!(false)); + assert_eq!(p["myPRs"][0]["headSha"], json!("aaa")); + assert_eq!(p["myPRs"][0]["ci"], json!("success")); + assert_eq!(p["myPRs"][0]["review"], json!("approved")); + assert_eq!(p["fetchedAt"], json!(7)); + } + + // ── Activity ────────────────────────────────────────────────────────────── + + fn day(date: &str, count: i64, level: &str, weekday: i64) -> Value { + json!({"date": date, "contributionCount": count, "contributionLevel": level, "weekday": weekday}) + } + + fn valid_activity() -> Value { + json!({ + "data": { "viewer": { "login": "testuser", "contributionsCollection": { "contributionCalendar": { + "totalContributions": 42, + "weeks": [ + {"contributionDays": [ + day("2026-01-05", 0, "NONE", 0), + day("2026-01-06", 1, "FIRST_QUARTILE", 1), + day("2026-01-07", 4, "SECOND_QUARTILE", 2), + day("2026-01-08", 8, "THIRD_QUARTILE", 3), + day("2026-01-09", 12, "FOURTH_QUARTILE", 4), + day("2026-01-10", 2, "FIRST_QUARTILE", 5), + day("2026-01-11", 0, "NONE", 6) + ]}, + {"contributionDays": [ + day("2026-01-12", 5, "SECOND_QUARTILE", 0), + day("2026-01-13", 10, "THIRD_QUARTILE", 1) + ]} + ] + }}}} + }) + } + + #[test] + fn parses_the_contribution_calendar() { + let a = GitHubActivity::parse(&valid_activity(), 3).unwrap(); + assert_eq!(a.total, 42); + assert_eq!(a.weeks.len(), 2); + assert_eq!(a.weeks[0].len(), 7); + assert_eq!(a.weeks[1].len(), 2, "incomplete current week"); + let levels: Vec = a.weeks[0][..5].iter().map(|d| d.level).collect(); + assert_eq!(levels, vec![0, 1, 2, 3, 4]); + assert_eq!(a.weeks[0][0].date, "2026-01-05"); + assert_eq!(a.weeks[0][0].count, 0); + assert_eq!(a.weeks[0][0].weekday, 0); + assert_eq!(a.weeks[1][1].date, "2026-01-13"); + assert_eq!(a.weeks[1][1].count, 10); + assert_eq!(a.fetched_at, 3); + } + + #[test] + fn unknown_contribution_level_is_zero() { + let root = json!({"data": {"viewer": {"contributionsCollection": {"contributionCalendar": { + "totalContributions": 1, + "weeks": [{"contributionDays": [day("2026-03-01", 1, "EXTRA_SPECIAL", 0)]}] + }}}}}); + assert_eq!(GitHubActivity::parse(&root, 0).unwrap().weeks[0][0].level, 0); + } + + #[test] + fn rejects_bad_activity_data() { + assert!(GitHubActivity::parse(&json!("garbage"), 0).is_none()); + assert!(GitHubActivity::parse(&json!({}), 0).is_none()); + } + + #[test] + fn counts_graphql_errors() { + assert_eq!(graphql_error_count(&json!({"data": {}})), 0); + assert_eq!(graphql_error_count(&json!({"errors": [{}, {}]})), 2); + } +} diff --git a/windows/src-tauri/src/hooks.rs b/windows/src-tauri/src/hooks.rs index 3da1d0e5a..36aa761a0 100644 --- a/windows/src-tauri/src/hooks.rs +++ b/windows/src-tauri/src/hooks.rs @@ -8,12 +8,17 @@ // The command is only the quoted exe path in forward slashes plus the event name: // on Windows Claude Code runs hook commands through Git Bash, and anything with // PowerShell or cmd in it breaks. +// +// Reading, the diff, the backup and the write itself live in config_file.rs, +// shared with every other agent's installer (agents.rs). use std::path::{Path, PathBuf}; use serde::Serialize; use serde_json::{json, Map, Value}; use tauri::{AppHandle, Manager}; +use crate::agents::{self, Shell}; +use crate::config_file::{self, FileEdit}; use crate::{platform, settings}; /// Every event the island reacts to, with the hook timeout written to settings.json. @@ -40,6 +45,8 @@ const MARKER: &str = "coucou-hook"; #[serde(rename_all = "camelCase")] pub struct HookStatus { pub installed: bool, + /// Coucou's status line relay (plan usage) is the one in settings.json. + pub plan_relay_installed: bool, pub settings_path: String, pub hook_path: String, pub hook_ready: bool, @@ -60,68 +67,20 @@ pub fn settings_path() -> PathBuf { platform::home_dir().join(".claude").join("settings.json") } -/// Reads `~/.claude/settings.json`. -/// -/// The only error that means "start from nothing" is the file not being there. -/// Everything else — a lock held by another process, a permission problem, JSON -/// we cannot parse — is reported, because the alternative is treating somebody's -/// unreadable settings as an empty object and then writing that back over them. -fn read_settings() -> Result { - let path = settings_path(); - match std::fs::read(&path) { - Ok(bytes) => parse_settings(&bytes, &path.display().to_string()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), - // A lock, a permission problem, a bad drive: all of them mean we do not - // know what is in there, and not knowing is not the same as empty. - Err(err) => Err(format!("Can't read {}: {err}", path.display())), - } -} - -/// The parsing half of `read_settings`, split out so it can be tested without a -/// home directory. -fn parse_settings(bytes: &[u8], path: &str) -> Result { - // PowerShell writes a UTF-8 BOM with `Set-Content -Encoding utf8`, and - // serde_json refuses it. Stripping it is safe and well defined; guessing at - // anything else is not. - let text = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); - if text.iter().all(u8::is_ascii_whitespace) { - return Ok(json!({})); - } - match serde_json::from_slice::(text) { - Ok(v) if v.is_object() => Ok(v), - Ok(_) => Err(format!("{path} isn't a JSON object — Coucou won't touch it.")), - Err(err) => Err(format!( - "{path} isn't valid JSON ({err}). Fix or move it, then try again — Coucou won't overwrite it." - )), - } -} - /// The settings as they are, or an empty object when we cannot tell. Only for /// read-only paths like `status()`, which must never fail loudly; anything that -/// writes uses `read_settings()` and surfaces the error instead. +/// writes goes through `config_file`, which surfaces the error instead. fn read_settings_lossy() -> Value { - read_settings().unwrap_or_else(|_| json!({})) -} - -#[cfg(windows)] -fn hook_command(event: &str) -> String { - let exe = settings::hook_exe_path().to_string_lossy().replace('\\', "/"); - format!("\"{exe}\" {event}") + config_file::read(&settings_path()) + .ok() + .and_then(|bytes| config_file::parse_json(bytes.as_deref(), "settings.json").ok()) + .unwrap_or_else(|| json!({})) } -/// Claude Code runs the command through `sh`, which still reads `$`, `` ` `` -/// and `\` inside double quotes. Single quotes keep the path a path, whatever -/// the home directory is called. -#[cfg(unix)] +/// On Windows Claude Code runs hook commands through Git Bash; on Linux through +/// `sh`. Either way the relay path is one quoted shell word. fn hook_command(event: &str) -> String { - format!("{} {event}", sh_quote(&settings::hook_exe_path().to_string_lossy())) -} - -/// `s` as one single-quoted shell word: `'` becomes `'\''`, nothing else is -/// special inside single quotes. -#[cfg(unix)] -fn sh_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', r"'\''")) + agents::relay_command(Shell::Sh, event) } fn entry_is_ours(entry: &Value) -> bool { @@ -139,21 +98,29 @@ fn entry_is_ours(entry: &Value) -> bool { .unwrap_or(false) } -/// Settings with Coucou's hooks added; everything else is left untouched. -fn merged(existing: &Value) -> Value { +/// The status line in settings.json is Coucou's relay (old installs wrote +/// `coucou-hook StatusLine`, new ones `coucou-hook --statusline`; both match). +fn status_line_is_ours(v: &Value) -> bool { + v.get("command").and_then(Value::as_str).is_some_and(|c| c.contains(MARKER)) +} + +/// Settings with Coucou's hooks added; everything else is left untouched. A +/// `hooks` (or one of its events) that is not what Claude Code documents is +/// refused rather than replaced. +fn merged(existing: &Value) -> Result { let mut root = existing.as_object().cloned().unwrap_or_default(); - let mut hooks = root - .get("hooks") - .and_then(Value::as_object) - .cloned() - .unwrap_or_else(Map::new); + let mut hooks = match root.get("hooks") { + None => Map::new(), + Some(Value::Object(h)) => h.clone(), + Some(_) => return Err(unexpected("\"hooks\"")), + }; for (event, timeout) in HOOK_EVENTS { - let mut list = hooks - .get(*event) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); + let mut list = match hooks.get(*event) { + None => Vec::new(), + Some(Value::Array(list)) => list.clone(), + Some(_) => return Err(unexpected(&format!("\"hooks\".\"{event}\""))), + }; list.retain(|entry| !entry_is_ours(entry)); list.push(json!({ "hooks": [{ @@ -166,14 +133,20 @@ fn merged(existing: &Value) -> Value { } root.insert("hooks".into(), Value::Object(hooks)); - Value::Object(root) + Ok(Value::Object(root)) +} + +fn unexpected(what: &str) -> String { + crate::i18n::tf("settings.json: {what} has an unexpected type — Coucou has not touched it.", &[("what", what)]) } /// Settings with every Coucou entry removed, and nothing else changed. -fn without_ours(existing: &Value) -> Value { +fn without_ours(existing: &Value) -> Result { let mut root = existing.as_object().cloned().unwrap_or_default(); - let Some(hooks) = root.get("hooks").and_then(Value::as_object).cloned() else { - return Value::Object(root); + let hooks = match root.get("hooks") { + None => return Ok(Value::Object(root)), + Some(Value::Object(h)) => h.clone(), + Some(_) => return Err(unexpected("\"hooks\"")), }; let mut out = Map::new(); for (event, value) in hooks { @@ -195,44 +168,16 @@ fn without_ours(existing: &Value) -> Value { } else { root.insert("hooks".into(), Value::Object(out)); } - Value::Object(root) + Ok(Value::Object(root)) } -fn pretty(v: &Value) -> String { - serde_json::to_string_pretty(v).unwrap_or_default() -} - -/// Down to the second: installing then uninstalling in the same minute must not -/// quietly overwrite the first backup. -fn stamp() -> String { - let t = platform::local_time(); - format!( - "{:04}{:02}{:02}-{:02}{:02}{:02}", - t.year, t.month, t.day, t.hour, t.minute, t.second - ) -} - -fn backup_path() -> PathBuf { - let p = settings_path(); - p.with_file_name(format!("settings.json.bak-{}", stamp())) -} - -/// Identifies the exact bytes a preview was computed from. FNV-1a is plenty: -/// the question is only "is this still the file I showed the user?". -fn fingerprint(bytes: &[u8]) -> String { - let mut hash: u64 = 0xcbf2_9ce4_8422_2325; - for b in bytes { - hash ^= *b as u64; - hash = hash.wrapping_mul(0x1000_0000_01b3); - } - format!("{hash:016x}") -} - -fn current_fingerprint() -> String { - match std::fs::read(settings_path()) { - Ok(bytes) => fingerprint(&bytes), - Err(_) => fingerprint(b""), - } +fn edits(install: bool) -> Vec> { + vec![FileEdit { + path: settings_path(), + edit: config_file::json_edit("settings.json".into(), move |current| { + if install { merged(current).map(Some) } else { without_ours(current).map(Some) } + }), + }] } // ── Public API ──────────────────────────────────────────────────────────────── @@ -253,6 +198,7 @@ pub fn status() -> HookStatus { let hook_path = settings::hook_exe_path(); HookStatus { installed, + plan_relay_installed: plan_relay_installed(¤t), settings_path: settings_path().to_string_lossy().to_string(), hook_ready: hook_path.exists(), hook_path: hook_path.to_string_lossy().to_string(), @@ -260,89 +206,136 @@ pub fn status() -> HookStatus { } pub fn preview(install: bool) -> Result { - let current = read_settings()?; - let next = if install { merged(¤t) } else { without_ours(¤t) }; - Ok(HookPreview { - diff: unified_diff(&pretty(¤t), &pretty(&next)), - backup: backup_path().to_string_lossy().to_string(), - settings_path: settings_path().to_string_lossy().to_string(), - fingerprint: current_fingerprint(), - }) + Ok(plan_to_preview(config_file::preview(&edits(install))?)) } -/// Writes the merged (or cleaned) settings after taking a dated backup. +fn plan_to_preview(plan: config_file::Plan) -> HookPreview { + HookPreview { + diff: plan.diff, + backup: plan.backup, + settings_path: plan.path, + fingerprint: plan.fingerprint, + } +} + +/// Writes the merged (or cleaned) settings after taking a dated backup, and +/// returns where the backup went ("" when there was no file to back up). /// -/// `fingerprint` is the one the preview was computed from. If the file changed -/// in between — another tool, another window, the user's own editor — we stop -/// and make them look at a fresh diff, because the only thing worse than not -/// installing the hooks is silently reverting somebody else's edit. +/// `fingerprint` is the one the preview was computed from: a settings.json that +/// changed in between is refused rather than overwritten (see config_file). pub fn write(install: bool, fingerprint: &str) -> Result { - let path = settings_path(); - let dir = path.parent().unwrap_or(Path::new(".")); - std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; - - // Read before the backup: an unreadable file must abort before we touch - // anything at all. - let current = read_settings()?; - if current_fingerprint() != fingerprint { - return Err(format!( - "{} changed since the preview. Nothing was written — review the new diff.", - path.display() - )); + let backups = config_file::apply(&edits(install), fingerprint)?; + Ok(backups.first().map(|p| p.to_string_lossy().to_string()).unwrap_or_default()) +} + +// ── Status line (plan usage) ────────────────────────────────────────────────── +// +// Claude Code runs one `statusLine` command and hands it the plan limits. Coucou +// puts its relay there; a status line the user already had is kept in +// statusline-previous.json beside the relay, and the relay still runs it, so it +// keeps working. Installing and removing it is separate from the hooks, and +// goes through the same hardened writer (config_file.rs): strict read, diff, +// fingerprint, dated backup, then the write. + +/// Where the user's own status line waits while the relay stands in for it. +pub fn status_line_previous_path() -> PathBuf { + settings::hook_exe_path().with_file_name("statusline-previous.json") +} + +fn read_status_line_previous() -> Option { + let bytes = std::fs::read(status_line_previous_path()).ok()?; + serde_json::from_slice::(&bytes).ok().filter(Value::is_object) +} + +/// True when the `statusLine` in settings.json is Coucou's relay. +pub fn plan_relay_installed(settings: &Value) -> bool { + settings.get("statusLine").is_some_and(status_line_is_ours) +} + +/// `statusLine` as it reads after installing or removing the relay. `None`: the +/// key goes. Installing swaps only `command`, so `padding`, `refreshInterval` +/// and the rest of the user's status line stay as they were. +fn status_line_after(existing: Option<&Value>, install: bool, previous: Option<&Value>) -> Option { + if install { + let mut sl = existing.filter(|v| v.is_object()).cloned().unwrap_or_else(|| json!({})); + let obj = sl.as_object_mut().expect("an object"); + obj.entry("type").or_insert_with(|| json!("command")); + obj.insert("command".into(), json!(hook_command("--statusline"))); + Some(sl) + } else if existing.is_some_and(status_line_is_ours) { + previous.cloned() + } else { + existing.cloned() // not ours any more (the user changed it): leave it alone } +} - let backup = backup_path(); - if path.exists() { - std::fs::copy(&path, &backup).map_err(|e| format!("backup failed: {e}"))?; +fn status_line_settings(current: &Value, install: bool, previous: Option<&Value>) -> Result { + let mut root = current.as_object().cloned().unwrap_or_default(); + // A statusLine that is not an object is something we do not understand: + // refuse rather than replace it. + if install && root.get("statusLine").is_some_and(|v| !v.is_object()) { + return Err(unexpected("\"statusLine\"")); } + match status_line_after(root.get("statusLine"), install, previous) { + Some(sl) => root.insert("statusLine".into(), sl), + None => root.remove("statusLine"), + }; + Ok(Value::Object(root)) +} - let next = if install { merged(¤t) } else { without_ours(¤t) }; - let mut text = pretty(&next); - text.push('\n'); +fn status_line_edits(install: bool, previous: Option) -> Vec> { + vec![FileEdit { + path: settings_path(), + edit: config_file::json_edit("settings.json".into(), move |current| { + Ok(Some(status_line_settings(current, install, previous.as_ref())?)) + }), + }] +} - // A dotfiles setup often makes settings.json a symlink: write to the file it - // points at, so the link survives the rename below. - #[cfg(unix)] - let path = std::fs::canonicalize(&path).unwrap_or(path); +/// The diff the user has to look at before the relay goes in or out. +pub fn status_line_preview(install: bool) -> Result { + Ok(plan_to_preview(config_file::preview(&status_line_edits(install, read_status_line_previous()))?)) +} - // Write beside the target and rename over it: a crash or a full disk leaves - // the original settings.json intact rather than half a file. - let temp = path.with_extension(format!("json.coucou-{}", std::process::id())); - if let Err(err) = write_like(&temp, &path, text.as_bytes()) { - let _ = std::fs::remove_file(&temp); - return Err(format!("write failed: {err}")); - } - if let Err(err) = std::fs::rename(&temp, &path) { - let _ = std::fs::remove_file(&temp); - return Err(format!("write failed: {err}")); +/// Installs or removes the relay, after the same backup and fingerprint checks as +/// the hooks. Installing first saves a status line of the user's own, removing +/// puts it back (or removes the key if there was none). +pub fn status_line_write(install: bool, fingerprint: &str) -> Result { + let before = config_file::read(&settings_path()) + .and_then(|bytes| config_file::parse_json(bytes.as_deref(), "settings.json"))?; + let previous = read_status_line_previous(); + let own = before.get("statusLine").filter(|v| !status_line_is_ours(v)).cloned(); + let saved = match (install, own.as_ref()) { + (true, Some(own)) => { + save_status_line_previous(own).map_err(|_| { + crate::i18n::t("Could not save your current status line next to the relay; nothing was changed.") + })?; + true + } + _ => false, + }; + let result = config_file::apply(&status_line_edits(install, previous), fingerprint); + match &result { + // Back in settings.json: the saved copy has done its job. + Ok(_) if !install => { + let _ = std::fs::remove_file(status_line_previous_path()); + } + // Nothing was written: do not leave a stale copy behind. + Err(_) if saved => { + let _ = std::fs::remove_file(status_line_previous_path()); + } + _ => {} } - Ok(backup.to_string_lossy().to_string()) + let backups = result?; + Ok(backups.first().map(|p| p.to_string_lossy().to_string()).unwrap_or_default()) } -/// Writes `bytes` to `temp`, which is about to replace `original`. -/// -/// On Linux a fresh file would get the umask's 0644, and settings.json can hold -/// API keys in its `env` block: the new file is created readable by us only, -/// then given the original's permissions, so the rename never widens them. -fn write_like(temp: &Path, original: &Path, bytes: &[u8]) -> std::io::Result<()> { - use std::io::Write; - let mut options = std::fs::OpenOptions::new(); - options.write(true).create(true).truncate(true); - #[cfg(unix)] - std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); - let mut file = options.open(temp)?; - file.write_all(bytes)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(original) - .map(|m| m.permissions().mode() & 0o777) - .unwrap_or(0o600); - file.set_permissions(std::fs::Permissions::from_mode(mode))?; +fn save_status_line_previous(status_line: &Value) -> std::io::Result<()> { + let path = status_line_previous_path(); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; } - #[cfg(not(unix))] - let _ = original; - Ok(()) + config_file::write_like(&path, &path, config_file::pretty(status_line).as_bytes()) } /// Copies the relay (coucou-hook.exe / coucou-hook) into the local data dir's @@ -429,115 +422,53 @@ fn install_relay(src: &Path, dest: &Path) { } } -// ── Minimal unified diff (LCS) ──────────────────────────────────────────────── - -/// settings.json is short, so a plain O(n·m) LCS is the simplest honest diff. -fn unified_diff(before: &str, after: &str) -> String { - let a: Vec<&str> = before.lines().collect(); - let b: Vec<&str> = after.lines().collect(); - let (n, m) = (a.len(), b.len()); - - let mut lcs = vec![vec![0usize; m + 1]; n + 1]; - for i in (0..n).rev() { - for j in (0..m).rev() { - lcs[i][j] = if a[i] == b[j] { - lcs[i + 1][j + 1] + 1 - } else { - lcs[i + 1][j].max(lcs[i][j + 1]) - }; - } - } - - let mut out: Vec = Vec::new(); - let (mut i, mut j) = (0usize, 0usize); - while i < n && j < m { - if a[i] == b[j] { - out.push(format!(" {}", a[i])); - i += 1; - j += 1; - } else if lcs[i + 1][j] >= lcs[i][j + 1] { - out.push(format!("- {}", a[i])); - i += 1; - } else { - out.push(format!("+ {}", b[j])); - j += 1; - } - } - while i < n { - out.push(format!("- {}", a[i])); - i += 1; - } - while j < m { - out.push(format!("+ {}", b[j])); - j += 1; - } - - // Keep three lines of context around each change so the panel stays readable. - let changed: Vec = out - .iter() - .enumerate() - .filter(|(_, l)| l.starts_with('+') || l.starts_with('-')) - .map(|(i, _)| i) - .collect(); - if changed.is_empty() { - return "No change.".into(); - } - let mut keep = vec![false; out.len()]; - for idx in changed { - let lo = idx.saturating_sub(3); - let hi = (idx + 4).min(out.len()); - for k in lo..hi { - keep[k] = true; - } - } - let mut result = String::new(); - let mut gap = false; - for (idx, line) in out.iter().enumerate() { - if keep[idx] { - result.push_str(line); - result.push('\n'); - gap = false; - } else if !gap { - result.push_str(" …\n"); - gap = true; - } - } - result -} - #[cfg(test)] mod tests { use super::*; - const WHERE: &str = "settings.json"; - #[test] - fn a_utf8_bom_is_stripped_not_treated_as_corruption() { - // PowerShell 5's `Set-Content -Encoding utf8` produces exactly this. - let mut bytes = vec![0xEF, 0xBB, 0xBF]; - bytes.extend_from_slice(br#"{"model":"opus","hooks":{}}"#); - let parsed = parse_settings(&bytes, WHERE).expect("a BOM must not defeat the parser"); - assert_eq!(parsed["model"], "opus"); + fn the_hooks_leave_the_status_line_alone() { + let theirs = json!({ "statusLine": { "type": "command", "command": "~/bin/my-line" } }); + assert_eq!(merged(&theirs).unwrap()["statusLine"], theirs["statusLine"]); + assert_eq!(without_ours(&theirs).unwrap()["statusLine"], theirs["statusLine"]); + assert!(merged(&json!({})).unwrap().get("statusLine").is_none()); } #[test] - fn unreadable_content_is_an_error_never_an_empty_object() { - // This is the whole bug: returning {} here meant `merged()` produced a - // file containing nothing but Coucou's hooks, and the write replaced - // everything the user had. - for bad in [&b"{ not json"[..], &b"[1,2,3]"[..], &b"\"a string\""[..]] { - assert!( - parse_settings(bad, WHERE).is_err(), - "content we cannot use must refuse, not come back empty" - ); - } + fn the_relay_takes_the_status_line_and_keeps_the_users_other_fields() { + // None yet: ours is added. + let fresh = status_line_settings(&json!({ "model": "opus" }), true, None).unwrap(); + assert!(status_line_is_ours(&fresh["statusLine"])); + assert_eq!(fresh["statusLine"]["type"], "command"); + assert_eq!(fresh["model"], "opus"); + + // Their own: only the command is swapped; padding and refresh stay. + let own = json!({ "statusLine": { "type": "command", "command": "~/bin/my-line", "padding": 2, "refreshInterval": 5 } }); + let taken = status_line_settings(&own, true, None).unwrap(); + assert!(status_line_is_ours(&taken["statusLine"])); + assert_eq!(taken["statusLine"]["padding"], 2); + assert_eq!(taken["statusLine"]["refreshInterval"], 5); + + // Installing again keeps ours and its extra fields. + let again = status_line_settings(&taken, true, None).unwrap(); + assert_eq!(again["statusLine"]["padding"], 2); + assert!(status_line_is_ours(&again["statusLine"])); } #[test] - fn empty_and_whitespace_files_start_from_nothing() { - assert_eq!(parse_settings(b"", WHERE).unwrap(), json!({})); - assert_eq!(parse_settings(b" - ", WHERE).unwrap(), json!({})); + fn removing_the_relay_restores_what_was_there_and_never_touches_anything_else() { + let previous = json!({ "type": "command", "command": "~/bin/my-line", "padding": 2 }); + let ours = status_line_settings(&json!({}), true, None).unwrap(); + + // There was one before: it comes back exactly. + assert_eq!(status_line_settings(&ours, false, Some(&previous)).unwrap()["statusLine"], previous); + // There was none: the key goes. + assert!(status_line_settings(&ours, false, None).unwrap().get("statusLine").is_none()); + // The user changed it since: not ours, so untouched. + let theirs = json!({ "statusLine": { "type": "command", "command": "~/bin/other" } }); + assert_eq!(status_line_settings(&theirs, false, Some(&previous)).unwrap()["statusLine"], theirs["statusLine"]); + // A statusLine we do not understand is refused, never replaced. + assert!(status_line_settings(&json!({ "statusLine": "echo hi" }), true, None).is_err()); } #[test] @@ -556,7 +487,7 @@ mod tests { } }); - let after = merged(&existing); + let after = merged(&existing).unwrap(); assert_eq!(after["model"], "claude-opus-5"); assert_eq!(after["theme"], "dark"); assert_eq!(after["enabledPlugins"], serde_json::json!(["a", "b"])); @@ -569,56 +500,30 @@ mod tests { assert!(pre.iter().any(entry_is_ours), "our own hook was not added"); assert!(after["hooks"]["SomeEventWeDoNotTouch"].is_array()); + // Installing twice still leaves one entry of ours per event. (Compared + // by count: another test points HOME elsewhere meanwhile, which moves + // the relay path.) + let twice = merged(&after).unwrap(); + assert_eq!(twice["hooks"]["PreToolUse"].as_array().unwrap().iter().filter(|e| entry_is_ours(e)).count(), 1); + // And removing ours puts it back exactly as it was. - let cleaned = without_ours(&after); + let cleaned = without_ours(&after).unwrap(); assert_eq!(cleaned, existing); } #[test] - fn a_fingerprint_notices_any_change() { - assert_eq!(fingerprint(b"{}"), fingerprint(b"{}")); - assert_ne!(fingerprint(b"{}"), fingerprint(b"{ }")); - assert_ne!(fingerprint(b""), fingerprint(b"{}")); - } - - #[cfg(unix)] - #[test] - fn the_hook_path_is_one_shell_word_whatever_it_contains() { - assert_eq!(sh_quote("/home/a b/x"), "'/home/a b/x'"); - // $, backticks, backslashes and double quotes stay literal in single quotes. - assert_eq!(sh_quote(r#"/h/$(id)`x`\"y"#), r#"'/h/$(id)`x`\"y'"#); - // A single quote closes, escapes and reopens. - assert_eq!(sh_quote("/h/it's"), r"'/h/it'\''s'"); - } - - /// settings.json can carry API keys in its `env` block: rewriting it must - /// never make it readable by more people than before. - #[cfg(unix)] - #[test] - fn rewriting_settings_never_widens_its_permissions() { - use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!("coucou-perm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("settings.json"); - let temp = dir.join("settings.json.new"); - let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; - - for wanted in [0o600, 0o640, 0o644] { - std::fs::write(&original, b"{}").unwrap(); - std::fs::set_permissions(&original, std::fs::Permissions::from_mode(wanted)).unwrap(); - let _ = std::fs::remove_file(&temp); - write_like(&temp, &original, b"{\"a\":1}").unwrap(); - assert_eq!(mode(&temp), wanted, "the rewrite must keep {wanted:o}"); + fn values_of_an_unexpected_type_are_refused_not_replaced() { + for odd in [ + json!({ "hooks": "a string" }), + json!({ "hooks": [1, 2] }), + json!({ "hooks": { "PreToolUse": { "not": "a list" } } }), + ] { + assert!(merged(&odd).is_err(), "{odd}"); } - - // No original: ours only. - std::fs::remove_file(&original).unwrap(); - let _ = std::fs::remove_file(&temp); - write_like(&temp, &original, b"{}").unwrap(); - assert_eq!(mode(&temp), 0o600); - - let _ = std::fs::remove_dir_all(&dir); + assert!(without_ours(&json!({ "hooks": 3 })).is_err()); + // An event we do not install stays as it is, whatever its shape. + let kept = json!({ "hooks": { "Custom": "anything" } }); + assert_eq!(without_ours(&kept).unwrap(), kept); } /// Everything filesystem-shaped lives in one test on purpose: it points diff --git a/windows/src-tauri/src/i18n.rs b/windows/src-tauri/src/i18n.rs new file mode 100644 index 000000000..bbcb235c0 --- /dev/null +++ b/windows/src-tauri/src/i18n.rs @@ -0,0 +1,290 @@ +// Interface language for what Rust shows: the tray menu, the settings window +// title and the errors the webviews display. Same tables and same lookup as +// src/i18n/i18n.ts: strings keyed by their English text, from the Mac's catalog +// (strings.json, generated by scripts/gen-strings.mjs) and the Windows/Linux +// only ones (extra.json). A string missing in a language shows in English. +// +// The language is Settings.language when it is one of ours; "" follows the +// system: the languages the island's webview reports at launch, else (Linux) +// the LANGUAGE / LC_ALL / LC_MESSAGES / LANG variables, else English. + +use serde_json::{Map, Value}; +use std::collections::HashMap; +use std::sync::{OnceLock, RwLock}; + +pub const LANGUAGES: [&str; 10] = ["en", "zh-Hans", "hi", "es", "ar", "fr", "bn", "pt-BR", "ru", "id"]; + +static MAC: &str = include_str!("../../src/i18n/strings.json"); +static EXTRA: &str = include_str!("../../src/i18n/extra.json"); + +type Table = HashMap>; + +fn table() -> &'static Table { + static TABLE: OnceLock = OnceLock::new(); + TABLE.get_or_init(|| { + let mut out = Table::new(); + // extra.json goes last: it wins where both have a string. + for source in [MAC, EXTRA] { + let Ok(Value::Object(root)) = serde_json::from_str::(source) else { continue }; + let Some(Value::Object(strings)) = root.get("strings") else { continue }; + for (key, entry) in strings { + if let Value::Object(langs) = entry { + out.insert(key.clone(), langs.clone()); + } + } + } + out + }) +} + +struct Current { + picked: String, + system: Vec, + resolved: &'static str, +} + +fn state() -> &'static RwLock { + static STATE: OnceLock> = OnceLock::new(); + STATE.get_or_init(|| { + let system = env_languages(); + let resolved = resolve("", &system); + RwLock::new(Current { picked: String::new(), system, resolved }) + }) +} + +/// LANGUAGE (a list), then LC_ALL, LC_MESSAGES and LANG, as gettext reads them. +fn env_languages() -> Vec { + let mut out = Vec::new(); + if let Ok(list) = std::env::var("LANGUAGE") { + out.extend(list.split(':').filter(|s| !s.is_empty()).map(str::to_string)); + } + for name in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(v) = std::env::var(name) { + if !v.is_empty() && v != "C" && v != "POSIX" { + out.push(v); + } + } + } + out +} + +/// The language to show: `picked` when it is one of ours, else the first of +/// the system's languages we have, else English. Same rules as resolveLanguage +/// in src/i18n/i18n.ts. +pub fn resolve(picked: &str, system: &[String]) -> &'static str { + if let Some(code) = LANGUAGES.iter().find(|c| **c == picked) { + return code; + } + for raw in system { + // "fr_FR.UTF-8" → "fr-fr" + let tag = raw.split('.').next().unwrap_or("").replace('_', "-").to_lowercase(); + let base = tag.split('-').next().unwrap_or(""); + if base.is_empty() { + continue; + } + if base == "zh" { + let traditional = ["zh-hant", "zh-tw", "zh-hk", "zh-mo"].iter().any(|p| tag.starts_with(p)); + if traditional { + continue; + } + return "zh-Hans"; + } + if base == "pt" { + return "pt-BR"; + } + if let Some(code) = LANGUAGES.iter().find(|c| c.to_lowercase() == base) { + return code; + } + } + "en" +} + +/// Records Settings.language. Returns true when the shown language changed. +pub fn set_picked(picked: &str) -> bool { + let mut s = state().write().unwrap_or_else(|e| e.into_inner()); + s.picked = picked.to_string(); + let next = resolve(&s.picked, &s.system); + std::mem::replace(&mut s.resolved, next) != next +} + +/// Records the system's languages as the webview sees them. Returns true when +/// the shown language changed. +pub fn set_system(languages: Vec) -> bool { + let mut s = state().write().unwrap_or_else(|e| e.into_inner()); + if !languages.is_empty() { + s.system = languages; + } + let next = resolve(&s.picked, &s.system); + std::mem::replace(&mut s.resolved, next) != next +} + +/// The language shown now. +pub fn current() -> &'static str { + #[cfg(test)] + return TEST_LANG.with(|c| c.get()); + #[cfg(not(test))] + state().read().unwrap_or_else(|e| e.into_inner()).resolved +} + +// Tests run in parallel and many check English text: each test thread has its +// own language, English unless the test picks another. +#[cfg(test)] +thread_local! { + static TEST_LANG: std::cell::Cell<&'static str> = const { std::cell::Cell::new("en") }; +} + +fn interpolate(text: &str, vars: &[(&str, &str)]) -> String { + let mut out = text.to_string(); + for (name, value) in vars { + out = out.replace(&format!("{{{name}}}"), value); + } + out +} + +fn lookup_in(lang: &str, key: &str) -> Option<&'static Value> { + if lang == "en" { + return None; + } + table().get(key)?.get(lang) +} + +/// CLDR plural category of `n` (whole numbers) in `lang`. +fn plural_category(lang: &str, n: u64) -> &'static str { + match lang { + "zh-Hans" | "id" => "other", + "fr" | "pt-BR" | "hi" | "bn" => { + if n <= 1 { "one" } else { "other" } + } + "ru" => { + let (m10, m100) = (n % 10, n % 100); + if m10 == 1 && m100 != 11 { + "one" + } else if (2..=4).contains(&m10) && !(12..=14).contains(&m100) { + "few" + } else { + "many" + } + } + "ar" => match (n, n % 100) { + (0, _) => "zero", + (1, _) => "one", + (2, _) => "two", + (_, 3..=10) => "few", + (_, 11..=99) => "many", + _ => "other", + }, + _ => { + if n == 1 { "one" } else { "other" } + } + } +} + +fn pick_plural(forms: &Map, category: &str) -> Option { + forms + .get(category) + .or_else(|| forms.get("other")) + .or_else(|| forms.values().next()) + .and_then(Value::as_str) + .map(str::to_string) +} + +/// Marks an English key for a table built before it is shown (the tray's +/// labels): the code translates it with `t` when it shows it. The i18n test +/// (tests/i18n.test.mjs) finds the keys through it. +pub const fn n_(key: &'static str) -> &'static str { + key +} + +/// `key` (English text) in the shown language, else the English text. +pub fn t(key: &str) -> String { + tf(key, &[]) +} + +/// `t` with `{name}` placeholders filled in. +pub fn tf(key: &str, vars: &[(&str, &str)]) -> String { + let lang = current(); + let text = match lookup_in(lang, key) { + Some(Value::String(s)) => s.clone(), + Some(Value::Object(forms)) => pick_plural(forms, "other").unwrap_or_else(|| key.to_string()), + _ => key.to_string(), + }; + interpolate(&text, vars) +} + +/// A count with its words: `tn("{count} file", "{count} files", n, &[])`. +#[allow(dead_code)] +pub fn tn(one: &str, other: &str, count: u64, vars: &[(&str, &str)]) -> String { + let lang = current(); + let n = count.to_string(); + let mut all: Vec<(&str, &str)> = vars.to_vec(); + all.push(("count", &n)); + let text = match lookup_in(lang, other) { + Some(Value::Object(forms)) => pick_plural(forms, plural_category(lang, count)), + Some(Value::String(s)) => Some(s.clone()), + _ => None, + }; + let text = text.unwrap_or_else(|| if count == 1 { one.to_string() } else { other.to_string() }); + interpolate(&text, &all) +} + +#[cfg(test)] +pub(crate) fn set_for_test(lang: &str) { + TEST_LANG.with(|c| c.set(resolve(lang, &[]))); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_like_the_webview() { + let sys = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(resolve("fr", &sys(&["ru"])), "fr"); + assert_eq!(resolve("", &sys(&["fr_FR.UTF-8"])), "fr"); + assert_eq!(resolve("", &sys(&["pt-PT"])), "pt-BR"); + assert_eq!(resolve("", &sys(&["zh-CN"])), "zh-Hans"); + assert_eq!(resolve("", &sys(&["zh-TW", "de", "es-MX"])), "es"); + assert_eq!(resolve("", &sys(&["de-DE"])), "en"); + assert_eq!(resolve("xx", &[]), "en"); + } + + #[test] + fn looks_up_both_tables_and_falls_back_to_english() { + set_for_test("fr"); + // From the Mac's catalog. + assert_eq!(t("Allow"), "Autoriser"); + // From extra.json. + assert_eq!(t("Open Coucou"), "Ouvrir Coucou"); + assert_eq!(t("A string nobody translated"), "A string nobody translated"); + assert_eq!(tf("Open {name}", &[("name", "Vercel")]), "Ouvrir Vercel"); + set_for_test("en"); + assert_eq!(t("Allow"), "Allow"); + assert_eq!(tf("Open {name}", &[("name", "Vercel")]), "Open Vercel"); + } + + #[test] + fn plurals_follow_the_language() { + set_for_test("en"); + assert_eq!(tn("✓ Connected · {count} model", "✓ Connected · {count} models", 1, &[]), "✓ Connected · 1 model"); + assert_eq!(tn("✓ Connected · {count} model", "✓ Connected · {count} models", 3, &[]), "✓ Connected · 3 models"); + set_for_test("es"); + assert_eq!(tn("✓ Connected · {count} model", "✓ Connected · {count} models", 1, &[]), "✓ Conectado · 1 modelo"); + assert_eq!(tn("✓ Connected · {count} model", "✓ Connected · {count} models", 4, &[]), "✓ Conectado · 4 modelos"); + assert_eq!(plural_category("ru", 21), "one"); + assert_eq!(plural_category("ru", 23), "few"); + assert_eq!(plural_category("ru", 25), "many"); + assert_eq!(plural_category("ar", 2), "two"); + set_for_test("en"); + } + + #[test] + fn every_extra_string_has_every_language() { + let Value::Object(root) = serde_json::from_str::(EXTRA).unwrap() else { panic!() }; + let Some(Value::Object(strings)) = root.get("strings") else { panic!("no strings") }; + for (key, entry) in strings { + for lang in LANGUAGES.iter().filter(|l| **l != "en") { + assert!(entry.get(*lang).is_some(), "{key:?} has no {lang}"); + } + } + } +} diff --git a/windows/src-tauri/src/identity.rs b/windows/src-tauri/src/identity.rs new file mode 100644 index 000000000..9fdac3414 --- /dev/null +++ b/windows/src-tauri/src/identity.rs @@ -0,0 +1,67 @@ +// The user's first name, for Mochi's system prompt — the same rules as +// UserIdentity.swift on the Mac. The full name comes from the OS account +// (platform::user_full_name); this file only decides whether it is something +// you would greet someone by. + +use std::sync::OnceLock; + +const MAX_FULL_NAME_CHARS: usize = 32; + +/// Resolved once: the account name cannot change while the app runs. +pub fn first_name() -> Option<&'static str> { + static NAME: OnceLock> = OnceLock::new(); + NAME.get_or_init(|| crate::platform::user_full_name().as_deref().and_then(first_name_from)) + .as_deref() +} + +/// "Théodore Riant" gives "Théodore"; a login handle such as "theodoreriant" +/// or "t.riant2" gives nothing, because "Hey theodoreriant!" reads worse than +/// a greeting with no name at all. +pub fn first_name_from(full_name: &str) -> Option { + let full = full_name.trim(); + if full.is_empty() || full.chars().count() > MAX_FULL_NAME_CHARS || looks_like_login_handle(full) { + return None; + } + let first = full.split(' ').find(|p| !p.is_empty())?; + first.chars().all(is_name_part).then(|| first.to_string()) +} + +/// Where no full name is set the OS hands back the account name, so a single +/// lowercase word, or one carrying digits or separators, is taken as a handle. +fn looks_like_login_handle(name: &str) -> bool { + if name.contains(' ') { + return false; + } + name.to_lowercase() == name || name.chars().any(|c| c.is_numeric() || "._-@".contains(c)) +} + +fn is_name_part(c: char) -> bool { + c.is_alphabetic() || c == '\'' || c == '-' +} + +#[cfg(test)] +mod tests { + use super::first_name_from; + + #[test] + fn a_real_full_name_gives_its_first_word() { + assert_eq!(first_name_from("Théodore Riant").as_deref(), Some("Théodore")); + assert_eq!(first_name_from(" Louis Raille ").as_deref(), Some("Louis")); + assert_eq!(first_name_from("Jean-Luc Picard").as_deref(), Some("Jean-Luc")); + assert_eq!(first_name_from("O'Brien Miles").as_deref(), Some("O'Brien")); + assert_eq!(first_name_from("Louis").as_deref(), Some("Louis")); + assert_eq!(first_name_from("Zoë").as_deref(), Some("Zoë")); + } + + #[test] + fn login_handles_and_odd_names_give_nothing() { + for name in ["", " ", "theodoreriant", "t.riant2", "Louis2", "louis_r", "me@example", "Admin_01"] { + assert_eq!(first_name_from(name), None, "{name}"); + } + // A first word that is not a name. + assert_eq!(first_name_from("R2-D2 Unit"), None); + assert_eq!(first_name_from("Dr. Who"), None); + // Too long to be a person's name. + assert_eq!(first_name_from(&format!("Louis {}", "a".repeat(40))), None); + } +} diff --git a/windows/src-tauri/src/integrations.rs b/windows/src-tauri/src/integrations.rs index d666b9977..14969e48c 100644 --- a/windows/src-tauri/src/integrations.rs +++ b/windows/src-tauri/src/integrations.rs @@ -8,14 +8,16 @@ // Nothing is polled until its key exists in the Credential Manager, and no // request goes anywhere the user has not configured. -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Mutex; use std::time::Duration; use serde::Serialize; use serde_json::{json, Value}; use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::Notify; +use crate::github::{self, GitHubActivity, GitHubPulse}; use crate::island::WINDOW_LABEL; use crate::log; use crate::secrets; @@ -67,6 +69,7 @@ pub fn start(app: AppHandle) { spawn(app.clone(), "integration_stripe", 6, 30, poll_stripe); spawn(app.clone(), "integration_resend", 6, 60, poll_resend); spawn(app.clone(), "integration_github", 7, 300, poll_github); + spawn_github_loops(app.clone()); spawn(app.clone(), "integration_calcom", 8, 300, poll_calcom); spawn(app, "integration_notion", 9, 300, poll_notion); } @@ -107,7 +110,11 @@ where pub async fn poll_once(app: AppHandle, id: &str) { match id { "integration_stripe" => poll_stripe(app).await, - "integration_github" => poll_github(app).await, + "integration_github" => { + wake_github_pulse(); + github_refresh_if_stale("activity"); + poll_github(app).await + } "integration_vercel" => poll_vercel(app).await, "integration_n8n" => poll_n8n(app).await, "integration_resend" => poll_resend(app).await, @@ -133,11 +140,13 @@ fn is_new(key: &'static str, id: &str) -> bool { } } +/// The card's error line, in the interface language (i18n.rs); +/// `unauthorised_hint` comes translated. fn status_error(code: u16, unauthorised_hint: &str) -> String { match code { - 401 => "Invalid API key (401)".into(), - 403 => unauthorised_hint.into(), - _ => format!("API error {code}"), + 401 => crate::i18n::t("Invalid API key (401)"), + 403 => unauthorised_hint.to_string(), + _ => crate::i18n::tf("API error {code}", &[("code", &code.to_string())]), } } @@ -180,7 +189,7 @@ async fn poll_stripe(app: AppHandle) { emit(&app, IntegrationUpdate { id: "integration_stripe", data: json!({}), - error: Some(status_error(code, "Use a secret key (sk_live_… not pk_live_…)")), + error: Some(status_error(code, &crate::i18n::t("Use a secret key (sk_live_… not pk_live_…)"))), event: None, }); return; @@ -189,7 +198,7 @@ async fn poll_stripe(app: AppHandle) { emit(&app, IntegrationUpdate { id: "integration_stripe", data: json!({}), - error: Some(format!("No connection: {e}")), + error: Some(crate::i18n::tf("No connection: {error}", &[("error", &e.to_string())])), event: None, }); return; @@ -280,7 +289,7 @@ async fn poll_github(app: AppHandle) { emit(&app, IntegrationUpdate { id: "integration_github", data: json!({}), - error: Some(status_error(response.status().as_u16(), "Token lacks the needed scope")), + error: Some(status_error(response.status().as_u16(), &crate::i18n::t("Token lacks the needed scope"))), event: None, }); return; @@ -315,14 +324,246 @@ async fn poll_github(app: AppHandle) { _ => 0, }; - emit(&app, IntegrationUpdate { - id: "integration_github", - data: json!({ "totalRepos": public + private, "totalStars": stars }), - error: None, - event: None, + GITHUB.cache.lock().unwrap().stats = Some((public + private, stars)); + emit_github(&app); +} + +// ── GitHub pulse and activity (GithubPoller.swift) ──────────────────────────── +// +// Two more loops next to the stats above, with the Mac cadence: the pulse (my +// pull requests and their CI, reviews waiting for me, default-branch CI) 10 s +// after launch, then every 60 s while some CI is running and every 5 min +// otherwise; the contribution calendar 15 s after launch, then every 30 min. +// Neither touches the network while the pill is off or Coucou is paused, and +// the island wakes them when the card is opened on stale data. +// +// All three results are kept here and always sent together, so one poll never +// wipes what another one reported. + +const GITHUB_ID: &str = "integration_github"; + +#[derive(Default)] +struct GitHubCache { + /// (repositories, stars) + stats: Option<(i64, i64)>, + pulse: Option, + activity: Option, +} + +struct GitHubLoops { + cache: Mutex, + /// Bumped when the token changes: a response for the old token is dropped. + generation: AtomicU64, + pulse_wake: Notify, + activity_wake: Notify, + pulse_busy: AtomicBool, + activity_busy: AtomicBool, +} + +static GITHUB: std::sync::LazyLock = std::sync::LazyLock::new(|| GitHubLoops { + cache: Mutex::new(GitHubCache::default()), + generation: AtomicU64::new(0), + pulse_wake: Notify::new(), + activity_wake: Notify::new(), + pulse_busy: AtomicBool::new(false), + activity_busy: AtomicBool::new(false), +}); + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// What the island's GitHub card receives: whichever of the three is known. +fn github_card_data(cache: &GitHubCache) -> Value { + let mut data = serde_json::Map::new(); + if let Some((repos, stars)) = cache.stats { + data.insert("totalRepos".into(), json!(repos)); + data.insert("totalStars".into(), json!(stars)); + } + if let Some(pulse) = &cache.pulse { + data.insert("pulse".into(), serde_json::to_value(pulse).unwrap_or(Value::Null)); + } + if let Some(activity) = &cache.activity { + data.insert("activity".into(), serde_json::to_value(activity).unwrap_or(Value::Null)); + } + Value::Object(data) +} + +fn emit_github(app: &AppHandle) { + let data = github_card_data(&GITHUB.cache.lock().unwrap()); + emit(app, IntegrationUpdate { id: GITHUB_ID, data, error: None, event: None }); +} + +fn spawn_github_loops(app: AppHandle) { + let pulse_app = app.clone(); + tauri::async_runtime::spawn(async move { + let mut wait = Duration::from_secs(10); + loop { + // Sleeps for `wait`, or less when the island asks for fresh data. + let _ = tokio::time::timeout(wait, GITHUB.pulse_wake.notified()).await; + let pending = run_github_pulse(&pulse_app).await; + wait = Duration::from_secs(if pending { 60 } else { 300 }); + } + }); + tauri::async_runtime::spawn(async move { + let mut wait = Duration::from_secs(15); + loop { + let _ = tokio::time::timeout(wait, GITHUB.activity_wake.notified()).await; + run_github_activity(&app).await; + wait = Duration::from_secs(1800); + } }); } +/// One authenticated GraphQL call. Partial errors are logged (as a count) and the +/// data parsed anyway; only a response without `data` is dropped. +async fn github_graphql(token: &str, query: &str, what: &str) -> Option { + let response = client() + .post("https://api.github.com/graphql") + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .header("User-Agent", "Coucou") + .json(&json!({ "query": query })) + .send() + .await; + let Ok(response) = response else { + log::line(format!("github {what}: no connection")); + return None; + }; + if !response.status().is_success() { + log::line(format!("github {what} HTTP {}", response.status().as_u16())); + return None; + } + let root: Value = response.json().await.ok()?; + let errors = github::graphql_error_count(&root); + if errors > 0 { + log::line(format!("github {what} GraphQL errors: {errors}")); + } + root.get("data").filter(|d| d.is_object())?; + Some(root) +} + +/// Returns whether some CI is still running, which sets the next interval. +async fn run_github_pulse(app: &AppHandle) -> bool { + if PAUSED.load(Ordering::Relaxed) || !enabled(app, GITHUB_ID) { + return false; + } + let Some(token) = secrets::get("github-token") else { return false }; + let generation = GITHUB.generation.load(Ordering::SeqCst); + GITHUB.pulse_busy.store(true, Ordering::SeqCst); + let root = github_graphql(&token, github::PULSE_QUERY, "pulse").await; + GITHUB.pulse_busy.store(false, Ordering::SeqCst); + let Some(pulse) = root.and_then(|r| GitHubPulse::parse(&r, now_ms())) else { return false }; + let pending = pulse.has_pending(); + + let events = { + let mut cache = GITHUB.cache.lock().unwrap(); + // The token changed while this was in flight: it answers for someone else. + if GITHUB.generation.load(Ordering::SeqCst) != generation { + return pending; + } + let events = GitHubPulse::events(cache.pulse.as_ref(), &pulse); + cache.pulse = Some(pulse); + events + }; + emit_github(app); + if !events.is_empty() { + let _ = app.emit_to(WINDOW_LABEL, "github-alerts", &events); + } + pending +} + +async fn run_github_activity(app: &AppHandle) { + if PAUSED.load(Ordering::Relaxed) || !enabled(app, GITHUB_ID) { + return; + } + let Some(token) = secrets::get("github-token") else { return }; + let generation = GITHUB.generation.load(Ordering::SeqCst); + GITHUB.activity_busy.store(true, Ordering::SeqCst); + let root = github_graphql(&token, github::ACTIVITY_QUERY, "activity").await; + GITHUB.activity_busy.store(false, Ordering::SeqCst); + let Some(activity) = root.and_then(|r| GitHubActivity::parse(&r, now_ms())) else { return }; + { + let mut cache = GITHUB.cache.lock().unwrap(); + if GITHUB.generation.load(Ordering::SeqCst) != generation { + return; + } + cache.activity = Some(activity); + } + emit_github(app); +} + +fn wake_github_pulse() { + if !GITHUB.pulse_busy.load(Ordering::SeqCst) { + GITHUB.pulse_wake.notify_one(); + } +} + +/// The card was opened: fetch now if what it shows is older than the Mac's +/// limits (1 min for the pulse, 5 min for the activity grid). No-op while a +/// request is already in flight. +pub fn github_refresh_if_stale(section: &str) { + let now = now_ms(); + let cache = GITHUB.cache.lock().unwrap(); + match section { + "pulse" => { + let fetched = cache.pulse.as_ref().map(|p| p.fetched_at); + if !GITHUB.pulse_busy.load(Ordering::SeqCst) && github::is_stale(fetched, now, 60) { + GITHUB.pulse_wake.notify_one(); + } + } + "activity" => { + let fetched = cache.activity.as_ref().map(|a| a.fetched_at); + if !GITHUB.activity_busy.load(Ordering::SeqCst) && github::is_stale(fetched, now, 300) { + GITHUB.activity_wake.notify_one(); + } + } + _ => {} + } +} + +/// The GitHub token was saved or removed: forget what the old one fetched, drop +/// its requests still in flight, and fetch again right away. +pub fn github_token_changed(app: &AppHandle) { + GITHUB.generation.fetch_add(1, Ordering::SeqCst); + let has_token = secrets::get("github-token").is_some(); + { + let mut cache = GITHUB.cache.lock().unwrap(); + cache.pulse = None; + cache.activity = None; + if !has_token { + cache.stats = None; + } + } + emit_github(app); + GITHUB.pulse_wake.notify_one(); + GITHUB.activity_wake.notify_one(); + if has_token && !PAUSED.load(Ordering::Relaxed) && enabled(app, GITHUB_ID) { + tauri::async_runtime::spawn(poll_github(app.clone())); + } +} + +/// Switching the pill off forgets the pulse, so switching it back on starts +/// silent instead of alerting on everything that changed in between. +pub fn settings_saved(app: &AppHandle, active_integrations: &[String]) { + if active_integrations.iter().any(|id| id == GITHUB_ID) { + return; + } + let had_data = { + let mut cache = GITHUB.cache.lock().unwrap(); + let had = cache.pulse.is_some() || cache.activity.is_some(); + cache.pulse = None; + cache.activity = None; + had + }; + if had_data { + emit_github(app); + } +} + // ── Vercel ──────────────────────────────────────────────────────────────────── async fn poll_vercel(app: AppHandle) { @@ -338,7 +579,7 @@ async fn poll_vercel(app: AppHandle) { emit(&app, IntegrationUpdate { id: "integration_vercel", data: json!({}), - error: Some(status_error(response.status().as_u16(), "Token lacks access")), + error: Some(status_error(response.status().as_u16(), &crate::i18n::t("Token lacks access"))), event: None, }); return; @@ -410,7 +651,7 @@ async fn poll_resend(app: AppHandle) { emit(&app, IntegrationUpdate { id: "integration_resend", data: json!({}), - error: Some(status_error(response.status().as_u16(), "Key lacks access")), + error: Some(status_error(response.status().as_u16(), &crate::i18n::t("Key lacks access"))), event: None, }); return; @@ -472,7 +713,7 @@ async fn poll_notion(app: AppHandle) { emit(&app, IntegrationUpdate { id: "integration_notion", data: json!({}), - error: Some(status_error(response.status().as_u16(), "Integration lacks access")), + error: Some(status_error(response.status().as_u16(), &crate::i18n::t("Integration lacks access"))), event: None, }); return; @@ -559,7 +800,7 @@ async fn poll_calcom(app: AppHandle) { emit(&app, IntegrationUpdate { id: "integration_calcom", data: json!({}), - error: Some(status_error(response.status().as_u16(), "Key lacks access")), + error: Some(status_error(response.status().as_u16(), &crate::i18n::t("Key lacks access"))), event: None, }); return; @@ -664,7 +905,7 @@ async fn poll_n8n(app: AppHandle) { format!("{base}/rest/executions/{id}?includeData=true"), format!("{base}/rest/executions/{id}"), ]; - let mut name = "Workflow".to_string(); + let mut name = crate::i18n::t("Workflow"); let mut detail = None; for url in &detail_urls { let Ok(response) = http.get(url).header("X-N8N-API-KEY", &key).header("Accept", "application/json").send().await @@ -680,8 +921,8 @@ async fn poll_n8n(app: AppHandle) { .and_then(|w| w.get("name")) .and_then(Value::as_str) .or_else(|| json.get("name").and_then(Value::as_str)) - .unwrap_or("Workflow") - .to_string(); + .map(str::to_string) + .unwrap_or_else(|| crate::i18n::t("Workflow")); detail = n8n_detail(&json, success); break; } @@ -763,3 +1004,21 @@ fn fmt_value(v: &Value) -> String { other => other.to_string(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_card_data_sends_only_what_is_known() { + let mut cache = GitHubCache::default(); + assert_eq!(github_card_data(&cache), json!({})); + cache.stats = Some((12, 3400)); + assert_eq!(github_card_data(&cache), json!({ "totalRepos": 12, "totalStars": 3400 })); + cache.activity = Some(GitHubActivity { total: 5, weeks: vec![], fetched_at: 9 }); + let data = github_card_data(&cache); + assert_eq!(data["totalRepos"], json!(12)); + assert_eq!(data["activity"], json!({ "total": 5, "weeks": [], "fetchedAt": 9 })); + assert!(data.get("pulse").is_none()); + } +} diff --git a/windows/src-tauri/src/island.rs b/windows/src-tauri/src/island.rs index 92c9cd4cb..4793c79b8 100644 --- a/windows/src-tauri/src/island.rs +++ b/windows/src-tauri/src/island.rs @@ -89,14 +89,14 @@ impl PollGate { self.cv.notify_all(); } - fn wait_until_active(&self) { + pub(crate) fn wait_until_active(&self) { let mut guard = self.active.lock().unwrap(); while !*guard { guard = self.cv.wait(guard).unwrap(); } } - fn is_active(&self) -> bool { + pub(crate) fn is_active(&self) -> bool { *self.active.lock().unwrap() } } @@ -114,9 +114,117 @@ fn monitor_contains(m: &Monitor, x: f64, y: f64) -> bool { && y < (p.y + s.height as i32) as f64 } -/// The display the island lives on: the primary one, or the one under the cursor. +/// A display's logical origin, the key `at:,` preferences are matched on. +/// Names are no good for that: two monitors of the same model share one. +fn logical_origin(m: &Monitor) -> (i32, i32) { + let scale = m.scale_factor(); + let p = m.position(); + ((p.x as f64 / scale).round() as i32, (p.y as f64 / scale).round() as i32) +} + +/// One entry of the "Island lives on" list in Settings. +#[derive(Serialize, Clone)] +pub struct MonitorChoice { + pub key: String, + pub label: String, +} + +pub fn monitor_choices(app: &AppHandle) -> Vec { + let Ok(monitors) = app.available_monitors() else { return Vec::new() }; + monitors + .iter() + .map(|m| { + let d = describe(m); + MonitorChoice { + key: d.key(), + label: crate::i18n::tf( + "{name} — {width}×{height} at {x},{y}", + &[ + ("name", &d.name), + ("width", &d.w.to_string()), + ("height", &d.h.to_string()), + ("x", &d.x.to_string()), + ("y", &d.y.to_string()), + ], + ), + } + }) + .collect() +} + +/// What a display is remembered by: its logical origin, plus its name and +/// logical size, so it is still found after the layout is rearranged or the +/// resolution changes (the Mac keeps the display's UUID for the same reason; +/// Tauri has no stable ID). +#[derive(Debug, Clone, PartialEq)] +struct DisplayId { + name: String, + x: i32, + y: i32, + w: i32, + h: i32, +} + +impl DisplayId { + /// `at:,` stays first, so a preference saved before still matches. + fn key(&self) -> String { + format!("at:{},{}|{}|{}x{}", self.x, self.y, self.name.replace('|', " "), self.w, self.h) + } +} + +fn describe(m: &Monitor) -> DisplayId { + let (x, y) = logical_origin(m); + let scale = m.scale_factor(); + let s = m.size(); + DisplayId { + name: m.name().cloned().unwrap_or_else(|| "Display".into()), + x, + y, + w: (s.width as f64 / scale).round() as i32, + h: (s.height as f64 / scale).round() as i32, + } +} + +/// Which display a saved `at:` preference points at, best match first: same +/// place and name; the same name and size elsewhere (layout rearranged); the +/// same name alone when unique (resolution changed); the same place. None +/// means unplugged, and the caller falls back to the primary display. +fn pick_display(pref: &str, displays: &[DisplayId]) -> Option { + let rest = pref.strip_prefix("at:")?; + let mut parts = rest.split('|'); + let (x, y) = parts.next()?.split_once(',')?; + let (x, y) = (x.trim().parse::().ok()?, y.trim().parse::().ok()?); + let name = parts.next(); + let size = parts.next().and_then(|s| { + let (w, h) = s.split_once('x')?; + Some((w.parse::().ok()?, h.parse::().ok()?)) + }); + let at = |d: &DisplayId| d.x == x && d.y == y; + if let Some(name) = name { + if let Some(i) = displays.iter().position(|d| at(d) && d.name == name) { + return Some(i); + } + if let Some((w, h)) = size { + if let Some(i) = displays.iter().position(|d| d.name == name && d.w == w && d.h == h) { + return Some(i); + } + } + let mut same_name = displays.iter().enumerate().filter(|(_, d)| d.name == name); + if let (Some((i, _)), None) = (same_name.next(), same_name.next()) { + return Some(i); + } + } + displays.iter().position(at) +} + +/// The display the island lives on: a chosen one, the primary one, or the one +/// under the cursor. fn target_monitor(app: &AppHandle, pref: &str) -> Option { let monitors = app.available_monitors().ok()?; + let ids: Vec = monitors.iter().map(describe).collect(); + if let Some(i) = pick_display(pref, &ids) { + return Some(monitors[i].clone()); + } if pref == "cursor" { if let Some((cx, cy)) = cursor_physical() { if let Some(m) = monitors.iter().find(|m| monitor_contains(m, cx, cy)) { @@ -130,6 +238,48 @@ fn target_monitor(app: &AppHandle, pref: &str) -> Option { .or_else(|| monitors.into_iter().next()) } +#[cfg(test)] +mod display_tests { + use super::*; + + fn d(name: &str, x: i32, y: i32, w: i32, h: i32) -> DisplayId { + DisplayId { name: name.into(), x, y, w, h } + } + + #[test] + fn a_display_is_found_again_after_changes() { + let dell = d("DELL U2720Q", 1920, 0, 2560, 1440); + let lap = d("eDP-1", 0, 0, 1920, 1200); + let key = dell.key(); + assert_eq!(pick_display(&key, &[lap.clone(), dell.clone()]), Some(1)); + // Rearranged: the Dell moved to the left of the laptop. + let moved = [d("eDP-1", 2560, 0, 1920, 1200), d("DELL U2720Q", 0, 0, 2560, 1440)]; + assert_eq!(pick_display(&key, &moved), Some(1)); + // Resolution changed, still the only Dell. + let rescaled = [lap.clone(), d("DELL U2720Q", 1920, 0, 1920, 1080)]; + assert_eq!(pick_display(&key, &rescaled), Some(1)); + // Unplugged: nothing, so the caller falls back to the primary display. + assert_eq!(pick_display(&key, &[lap.clone()]), None); + } + + #[test] + fn two_identical_monitors_are_told_apart_by_place() { + let a = d("LG 27UL500", 0, 0, 1920, 1080); + let b = d("LG 27UL500", 1920, 0, 1920, 1080); + assert_eq!(pick_display(&b.key(), &[a.clone(), b.clone()]), Some(1)); + assert_eq!(pick_display(&a.key(), &[a, b]), Some(0)); + } + + #[test] + fn preferences_saved_before_still_match() { + let lap = d("eDP-1", 0, 0, 1920, 1200); + let ext = d("HDMI-1", 1920, 0, 1920, 1080); + assert_eq!(pick_display("at:1920,0", &[lap.clone(), ext.clone()]), Some(1)); + assert_eq!(pick_display("primary", &[lap, ext]), None); + assert_eq!(pick_display("at:nonsense", &[]), None); + } +} + pub fn screen_info(app: &AppHandle, pref: &str) -> ScreenInfo { match target_monitor(app, pref) { Some(m) => { @@ -172,6 +322,8 @@ pub fn apply_geometry(app: &AppHandle, pref: &str, collapsed: bool) { let _ = win.set_resizable(true); let _ = win.set_size(PhysicalSize::new(pw, ph)); let _ = win.set_position(PhysicalPosition::new(x, y)); + let (lx, ly) = logical_origin(&m); + platform::pin_to_monitor(&win, lx, ly); // Moving across displays can rescale the window: re-assert the physical size. let _ = win.set_size(PhysicalSize::new(pw, ph)); let _ = win.set_always_on_top(true); diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index d9ec3353e..3667b5e88 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -1,16 +1,33 @@ // Coucou for Windows — app wiring and the commands the island calls. +mod agent_hooks; +mod agents; +mod chat; mod claude; +mod codex_plan; +mod config_file; +mod desktop; mod files; +mod github; mod hooks; +mod i18n; +mod identity; mod integrations; mod island; +mod local_chat; mod log; +mod net; +mod openai_compat; mod pipe; mod platform; +mod recap; mod secrets; +mod session_window; mod settings; +mod shortcuts; mod tray; +#[cfg(windows)] +mod webview_drop; use std::process::Command; use std::sync::atomic::Ordering; @@ -20,7 +37,7 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_autostart::{ManagerExt, MacosLauncher}; -use claude::{Chat, ChatContext, ChatReply}; +use chat::{Chat, ChatContext, ChatReply, ModelInfo}; use files::DroppedFile; use hooks::{HookPreview, HookStatus}; use island::{PollGate, ScreenInfo}; @@ -48,7 +65,9 @@ pub struct BootInfo { fn boot(app: AppHandle, shared: State) -> BootInfo { let mut settings = shared.settings.lock().unwrap().clone(); // The real state of ~/.claude/settings.json wins over whatever we stored. - settings.hooks_installed = hooks::status().installed; + let hooks_status = hooks::status(); + settings.hooks_installed = hooks_status.installed; + settings.plan_relay_installed = hooks_status.plan_relay_installed; let screen = island::screen_info(&app, &settings.screen); BootInfo { settings, @@ -61,13 +80,18 @@ fn boot(app: AppHandle, shared: State) -> BootInfo { #[tauri::command] fn save_settings(app: AppHandle, shared: State, settings: Settings) { - let (screen_changed, autostart_changed) = { + let (screen_changed, autostart_changed, shortcuts_changed) = { let mut current = shared.settings.lock().unwrap(); let screen_changed = current.screen != settings.screen; let autostart_changed = current.autostart != settings.autostart; - *current = settings.clone(); - (screen_changed, autostart_changed) + let shortcuts_changed = current.shortcuts != settings.shortcuts; + // Where Mochi sits on the desktop is desktop.rs's to say, not a webview's. + let mut settings = settings.clone(); + settings.desktop_mochi = current.desktop_mochi.clone(); + *current = settings; + (screen_changed, autostart_changed, shortcuts_changed) }; + let settings = shared.settings.lock().unwrap().clone(); if let Err(err) = settings::save(&settings) { log::line(format!("could not save settings: {err}")); } @@ -82,10 +106,35 @@ fn save_settings(app: AppHandle, shared: State, settings: Settings) { let collapsed = shared.gate.collapsed.load(Ordering::Relaxed); island::apply_geometry(&app, &settings.screen, collapsed); } + integrations::settings_saved(&app, &settings.active_integrations); + if shortcuts_changed { + shortcuts::apply(&app, &settings.shortcuts); + } + if i18n::set_picked(&settings.language) { + language_changed(&app); + } // Keep the other window in step (island ⇄ settings window). let _ = app.emit("settings-changed", settings); } +/// The island reports the system's languages at launch, for "System" in +/// Settings → Language (WebView2 and WebKitGTK know them best). +#[tauri::command] +fn set_system_languages(app: AppHandle, languages: Vec) { + if i18n::set_system(languages) { + language_changed(&app); + } +} + +/// What Rust labels itself follows the new language: the tray menu and the +/// settings window's title. The webviews switch on their own. +fn language_changed(app: &AppHandle) { + tray::retitle(app); + if let Some(window) = app.get_webview_window("settings") { + let _ = window.set_title(&i18n::t("Settings — Coucou")); + } +} + /// Hidden island → shrink the window to the invisible wake strip and park the /// cursor poll; anything else → full panel and 60 Hz polling. #[tauri::command] @@ -96,6 +145,7 @@ fn set_collapsed(app: AppHandle, shared: State, collapsed: bool) { // The wake strip must always take the mouse, and a resize invalidates the flag. island::refresh_click_through(&app, &shared.gate); shared.gate.set_active(!collapsed); + platform::set_pointer_watch(!collapsed); } /// The front end pushes the island shape; Rust decides click-through from it. @@ -124,6 +174,12 @@ fn reposition(app: AppHandle, shared: State) { island::apply_geometry(&app, &pref, collapsed); } +/// The displays the island can be pinned to, for Settings. +#[tauri::command] +fn list_monitors(app: AppHandle) -> Vec { + island::monitor_choices(&app) +} + #[tauri::command] fn open_url(url: String) { if !(url.starts_with("http://") || url.starts_with("https://")) { @@ -165,6 +221,54 @@ fn open_in_vscode(path: Option) -> bool { false } +/// "Open terminal": brings forward the terminal or editor window the session +/// runs in, when it was found (Windows, see session_window.rs); otherwise opens +/// the folder in VS Code, as before. +#[tauri::command] +fn open_session(session_id: Option, path: Option) -> bool { + if let Some(owner) = session_id.as_deref().and_then(session_window::lookup) { + let folder = path.as_deref().map(session_window::folder_name).unwrap_or_default(); + if platform::focus_process_window(owner, folder) { + return true; + } + } + open_in_vscode(path) +} + +/// The Claude Desktop pill's target: the Claude app (Windows only — it has no +/// Linux build). +#[tauri::command] +fn open_claude_desktop() -> bool { + platform::open_claude_desktop() +} + +/// The file behind a live diff, if it may be handed to the editor: an existing +/// regular file given by its full path. Anything else — a relative path, a +/// folder, a path `code` could read as an option — goes no further. +fn diff_file(path: &str) -> Option<&std::path::Path> { + let p = std::path::Path::new(path); + (p.is_absolute() && p.is_file()).then_some(p) +} + +/// The diff card's ↗: opens the edited file in VS Code when `code` is on PATH, +/// otherwise shows its folder. The file itself is never opened by its type — +/// xdg-open or Explorer would run a script that Claude just wrote. +#[tauri::command] +fn open_file_in_vscode(path: String) -> bool { + let Some(file) = diff_file(&path) else { return false }; + if let Some(code) = platform::find_on_path("code") { + let mut cmd = Command::new(code); + cmd.arg(file); + if platform::no_console(&mut cmd).spawn().is_ok() { + return true; + } + } + if let Some(folder) = file.parent().filter(|d| d.is_dir()) { + platform::reveal_folder(&folder.to_string_lossy()); + } + false +} + #[tauri::command] fn quit_app(app: AppHandle) { app.exit(0); @@ -184,6 +288,12 @@ fn hooks_status() -> HookStatus { hooks::status() } +/// Pill ID → whether that agent's hooks reach Coucou. Read-only. +#[tauri::command] +fn agent_hooks_status() -> std::collections::HashMap { + agent_hooks::status() +} + /// Returns the diff the user has to look at before anything is written. #[tauri::command] fn hooks_preview(install: bool) -> Result { @@ -213,11 +323,88 @@ fn hooks_apply( Ok(backup) } +// ── Other agents' hooks and plugins ────────────────────────────────────────── + +#[tauri::command] +fn agent_hooks_list() -> Vec { + agents::list() +} + +/// The diff the user has to look at before anything is written. +#[tauri::command] +fn agent_hooks_preview(agent: String, install: bool) -> Result { + agents::preview(&agent, install) +} + +/// Only ever called from an explicit click in the settings window, with the +/// fingerprint of the preview the user looked at. +#[tauri::command] +fn agent_hooks_apply(agent: String, install: bool, fingerprint: String) -> Result { + let backups = agents::apply(&agent, install, &fingerprint)?; + let done = if install { "installed" } else { "removed" }; + log::line(format!("agent hooks {done} for {agent}")); + Ok(backups) +} + +// ── Plan usage ──────────────────────────────────────────────────────────────── + +/// The diff of putting the plan usage relay into (or taking it out of) the +/// status line, before anything is written. +#[tauri::command] +fn status_line_preview(install: bool) -> Result { + hooks::status_line_preview(install) +} + +/// Only ever called from an explicit click in the settings window. +#[tauri::command] +fn status_line_apply( + app: AppHandle, + shared: State, + install: bool, + fingerprint: String, +) -> Result { + let backup = hooks::status_line_write(install, &fingerprint)?; + let updated = { + let mut current = shared.settings.lock().unwrap(); + current.plan_relay_installed = install; + // As on the Mac: taking the relay out turns the pill off with it. + if !install { + current.show_plan_in_notch = false; + } + if let Err(err) = settings::save(¤t) { + log::line(format!("could not save settings: {err}")); + } + current.clone() + }; + let _ = app.emit("settings-changed", updated); + Ok(backup) +} + +/// Codex plan usage, asked of the Codex CLI (`codex app-server`) when its pill +/// shows. Off the main thread: it can take a few seconds. +#[tauri::command] +async fn codex_plan_usage() -> Option { + tauri::async_runtime::spawn_blocking(codex_plan::read).await.ok().flatten() +} + #[tauri::command] fn approval_decision(app: AppHandle, request_id: String, decision: String) { + recap::record_decision(&app, &request_id, &decision); pipe::answer(&app, &request_id, &decision); } +/// An option picked on the island for a question Claude Code asked. +#[tauri::command] +fn approval_answer( + app: AppHandle, + request_id: String, + answers: std::collections::HashMap, +) { + // An answered question is not an Allow / Deny: nothing for the recap. + recap::forget_request(&app, &request_id); + pipe::answer_question(&app, &request_id, &answers); +} + /// The island has the card on screen, so the long wait for a human may begin. /// Until this arrives the relay only waits a few hundred milliseconds, which is /// what stops a paused or unresponsive island from freezing Claude Code. @@ -230,21 +417,45 @@ fn approval_ack(app: AppHandle, request_id: String) { /// already up. Claude Code falls back to asking in the terminal immediately. #[tauri::command] fn approval_decline(app: AppHandle, request_id: String) { + recap::forget_request(&app, &request_id); pipe::decline(&app, &request_id); } // ── Chat, files and secrets ─────────────────────────────────────────────────── -/// One chat turn. The API key and any file bytes stay on the Rust side. +/// One chat turn with the provider picked in the chat view. API keys and any +/// file bytes stay on the Rust side. #[tauri::command] async fn chat_send( + app: AppHandle, shared: State<'_, Shared>, chat: State<'_, Chat>, query: String, context: Option, ) -> Result { - let model = shared.settings.lock().unwrap().model.clone(); - claude::send(&chat, &model, query, context).await + let settings = shared.settings.lock().unwrap().clone(); + chat::send(&app, &chat, &settings, query, context).await +} + +/// The models a provider offers, for the picker in the chat view. Only asked +/// once the user picked that provider, and only with its key or address. +#[tauri::command] +async fn chat_models(shared: State<'_, Shared>, provider: String) -> Result, String> { + let settings = shared.settings.lock().unwrap().clone(); + chat::models(&settings, &provider).await +} + +/// Settings → Local models → Connect: does the server answer, and with which models? +#[tauri::command] +async fn local_connect(provider: String, url: String) -> Result { + local_chat::connect(&provider, &url).await +} + +/// Stores the custom server's key for the address typed next to it; it is only +/// ever sent to that address. +#[tauri::command] +fn local_set_key(url: String, key: String) -> Result<(), String> { + local_chat::set_custom_key(&url, &key) } #[tauri::command] @@ -265,13 +476,29 @@ fn secret_present(key: String) -> bool { } #[tauri::command] -fn secret_set(key: String, value: String) -> Result<(), String> { - secrets::set(&key, &value) +fn secret_set(app: AppHandle, key: String, value: String) -> Result<(), String> { + // Bound to its server's address: only local_set_key may store it. + if key == local_chat::CUSTOM_KEY { + return Err("use local_set_key".into()); + } + let before = (key == "github-token").then(|| secrets::get(&key)); + secrets::set(&key, &value)?; + if let Some(before) = before { + if secrets::get(&key) != before { + integrations::github_token_changed(&app); + } + } + Ok(()) } #[tauri::command] -fn secret_clear(key: String) -> Result<(), String> { - secrets::clear(&key) +fn secret_clear(app: AppHandle, key: String) -> Result<(), String> { + let had = key == "github-token" && secrets::present(&key); + secrets::clear(&key)?; + if had { + integrations::github_token_changed(&app); + } + Ok(()) } /// Opens the configured n8n instance — the URL lives in the Credential Manager. @@ -288,12 +515,39 @@ async fn refresh_integration(app: AppHandle, id: String) { integrations::poll_once(app, &id).await; } +/// The GitHub card was opened: refetch its `section` ("pulse" or "activity") +/// if it is stale. The pollers still decline while the pill is off or paused. +#[tauri::command] +fn github_refresh(section: String) { + integrations::github_refresh_if_stale(§ion); +} + /// Lets the island write to the same log as the Rust side. #[tauri::command] fn log_line(message: String) { log::line(format!("ui {message}")); } +// ── Global shortcuts ────────────────────────────────────────────────────────── + +/// How each global shortcut went: registered, taken by another app, and so on. +#[tauri::command] +fn shortcuts_status(app: AppHandle) -> shortcuts::Report { + shortcuts::status(&app) +} + +/// Settings is recording a new combination: let go of ours meanwhile, so the +/// keys reach the recorder instead of running an action. `false` takes them back. +#[tauri::command] +fn shortcuts_suspend(app: AppHandle, shared: State, suspended: bool) { + if suspended { + shortcuts::suspend(&app); + } else { + let stored = shared.settings.lock().unwrap().shortcuts.clone(); + shortcuts::apply(&app, &stored); + } +} + // ── Settings window ─────────────────────────────────────────────────────────── /// WebView2 allows exactly one browser environment per app, and its options are @@ -301,7 +555,7 @@ fn log_line(message: String) { /// for the *same* arguments as the island (see `additionalBrowserArgs` in /// tauri.conf.json) — a mismatch makes the second window come up blank, with no /// error anywhere. -const BROWSER_ARGS: &str = "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required"; +pub(crate) const BROWSER_ARGS: &str = "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required"; /// In a dev build the pages are served by Vite, so the second window needs the /// absolute dev URL; a bundled build resolves it inside the app bundle. @@ -323,7 +577,7 @@ fn create_settings_window(app: &AppHandle) { let url = settings_page_url(app); match WebviewWindowBuilder::new(app, "settings", url) .additional_browser_args(BROWSER_ARGS) - .title("Settings — Coucou") + .title(i18n::t("Settings — Coucou")) .inner_size(560.0, 680.0) .min_inner_size(460.0, 480.0) .resizable(true) @@ -363,55 +617,119 @@ fn open_settings_window(app: AppHandle) { pub fn run() { platform::prepare_environment(); let loaded = settings::load(); + i18n::set_picked(&loaded.language); let gate = Arc::new(PollGate::new()); - tauri::Builder::default() - .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { - let _ = app.emit_to(island::WINDOW_LABEL, "tray", "open".to_string()); + let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + // `coucou --shortcut `: what a desktop's own keyboard + // settings run where we can't listen for keys ourselves (Wayland). + match shortcuts::from_args(&argv) { + Some(action) => shortcuts::dispatch(app, action), + None => { + let _ = app.emit_to(island::WINDOW_LABEL, "tray", "open".to_string()); + } + } })) - .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)) + .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)); + // Where no global shortcut can work, the plugin isn't even started. + if platform::global_shortcuts_blocked().is_none() { + builder = builder.plugin(shortcuts::plugin()); + } + + builder .manage(Shared { settings: Mutex::new(loaded.clone()), gate: gate.clone(), }) .manage(Pending::default()) .manage(Chat::default()) + .manage(shortcuts::Registry::default()) + .manage(recap::load()) .invoke_handler(tauri::generate_handler![ boot, save_settings, + set_system_languages, set_collapsed, set_island_rect, focus_window, reposition, + list_monitors, open_url, open_in_vscode, + open_session, + open_claude_desktop, + open_file_in_vscode, quit_app, hooks_status, + agent_hooks_status, hooks_preview, hooks_apply, + agent_hooks_list, + agent_hooks_preview, + agent_hooks_apply, + status_line_preview, + status_line_apply, + codex_plan_usage, approval_decision, + approval_answer, approval_ack, approval_decline, log_line, chat_send, + chat_models, + local_connect, + local_set_key, chat_reset, ingest_file, secret_present, secret_set, secret_clear, refresh_integration, + github_refresh, open_n8n, open_settings_window, set_paused, + shortcuts_status, + shortcuts_suspend, + recap::recap_history, + recap::recap_prefs, + recap::recap_set_enabled, + recap::recap_set_hide_projects, + recap::recap_mark_shown, + recap::recap_clear, + recap::recap_save_png, + recap::recap_reveal_saved, + desktop::desktop_mochi_info, + desktop::desktop_mochi_pick_up, + desktop::desktop_mochi_carry, + desktop::desktop_mochi_carry_end, + desktop::desktop_mochi_drag_begin, + desktop::desktop_mochi_drag_move, + desktop::desktop_mochi_drag_end, + desktop::desktop_mochi_fly_out, + desktop::desktop_mochi_fly_home, + desktop::desktop_mochi_set_asleep, ]) .setup(move |app| { let handle = app.handle().clone(); tray::build(&handle)?; // Before the island: see create_settings_window. create_settings_window(&handle); + // Same rule for Mochi's desktop window. + desktop::setup(&handle); if let Some(win) = island::window(&handle) { + // Where Tauri takes the drop itself (Linux), its paths are the + // ones ingest_file may copy (files.rs). + win.on_window_event(|event| { + if let tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event { + files::allow_dropped(paths.iter().map(|p| p.to_string_lossy().to_string())); + } + }); platform::make_non_activating(&win); + #[cfg(windows)] + webview_drop::install(&handle); island::apply_geometry(&handle, &loaded.screen, false); let _ = win.show(); } @@ -428,8 +746,32 @@ pub fn run() { hooks::ensure_hook_exe(&handle); pipe::start(handle.clone()); integrations::start(handle.clone()); + shortcuts::apply(&handle, &loaded.shortcuts); Ok(()) }) .run(tauri::generate_context!()) .expect("error while running Coucou"); } + +#[cfg(test)] +mod tests { + use super::diff_file; + + #[test] + fn only_an_existing_file_by_its_full_path_reaches_the_editor() { + let dir = std::env::temp_dir().join(format!("coucou-diff-file-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("edited.ts"); + std::fs::write(&file, "x").unwrap(); + + assert!(diff_file(&file.to_string_lossy()).is_some()); + // A folder, a missing file, a relative path or an option never pass. + assert!(diff_file(&dir.to_string_lossy()).is_none()); + assert!(diff_file(&dir.join("missing.ts").to_string_lossy()).is_none()); + assert!(diff_file("edited.ts").is_none()); + assert!(diff_file("--help").is_none()); + assert!(diff_file("").is_none()); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/windows/src-tauri/src/local_chat.rs b/windows/src-tauri/src/local_chat.rs new file mode 100644 index 000000000..95d624c72 --- /dev/null +++ b/windows/src-tauri/src/local_chat.rs @@ -0,0 +1,560 @@ +// Chat with a model server the user runs: Ollama, LM Studio, or any other +// server that speaks the OpenAI API (vLLM, llama.cpp, Unsloth…) — the same +// integration as LocalChat.swift, plus that last one. +// +// The answer is streamed token by token. Each step goes to the island as a +// `chat-delta` event carrying the text visible so far, and the island shows it +// growing; the reply of the command is the finished text. `` blocks of +// reasoning models stay hidden while open and are dropped from the answer. +// +// Nothing leaves the machine unless the user pointed the server address +// somewhere else: that address is the only place this module talks to. + +use std::time::{Duration, Instant}; + +use reqwest::Url; +use serde::Serialize; +use serde_json::{json, Value}; +use tauri::{AppHandle, Emitter}; + +use crate::chat::{self, Chat, ChatContext, ChatReply, ModelInfo}; +use crate::island::WINDOW_LABEL; +use crate::settings::Settings; +use crate::i18n::{t, tf}; +use crate::{net, secrets}; + +/// Credential store entry of the key of the user's own OpenAI-compatible server. +pub const CUSTOM_KEY: &str = "openai-compatible-key"; + +const MAX_TOKENS: u32 = 4096; +/// A text file is sent inline up to this many characters; the rest is cut. +const MAX_INLINE_CHARS: usize = 24_000; +/// The island is told about new text at most this often. +const DELTA_INTERVAL: Duration = Duration::from_millis(1000 / 15); +/// Ceilings for a streamed answer: one event line, and the whole answer. +const MAX_LINE: usize = 1024 * 1024; +const MAX_ANSWER: usize = 4 * 1024 * 1024; +/// Ollama and LM Studio ignore the key, but some clients insist on sending one. +const NO_KEY: &str = "ollama"; +/// Models that embed or rank rather than chat are left out of the list. +const NOT_CHAT: &[&str] = &["embed", "bge-", "all-minilm", "clip", "rerank"]; + +// ── The custom server's key ─────────────────────────────────────────────────── +// +// The key is stored together with the one address it was entered for, and is +// only ever sent there. The address in the settings, or the one given to +// "Connect", can be changed from the page; the key cannot follow it. And a key +// never goes over plain http to another machine. + +#[derive(Serialize, serde::Deserialize)] +struct BoundKey { + url: String, + key: String, +} + +fn may_carry_key(url: &Url) -> bool { + url.scheme() == "https" || net::is_loopback_url(url) +} + +/// Settings → Local models: stores the key for the address typed next to it. +pub fn set_custom_key(typed_url: &str, key: &str) -> Result<(), String> { + let url = net::normalise_server_url(typed_url)?; + if !may_carry_key(&url) { + return Err(t("A key is only sent over https, or to a server on this computer.")); + } + let bound = BoundKey { url: url.as_str().trim_end_matches('/').to_string(), key: key.trim().to_string() }; + let value = serde_json::to_string(&bound).map_err(|e| e.to_string())?; + secrets::set(CUSTOM_KEY, &value) +} + +/// The key, if it was entered for exactly this address. +fn custom_key_for(url: &Url) -> Option { + let stored = secrets::get(CUSTOM_KEY)?; + key_for(&stored, url) +} + +fn key_for(stored: &str, url: &Url) -> Option { + let bound: BoundKey = serde_json::from_str(stored).ok()?; + (may_carry_key(url) && bound.url == url.as_str().trim_end_matches('/') && !bound.key.is_empty()) + .then_some(bound.key) +} + +/// A model server as the settings describe it. +pub struct Server { + pub id: &'static str, + pub name: &'static str, + /// As stored: empty until the user connects it. + pub url: String, + pub key: Option, +} + +/// Ollama, LM Studio or "custom", from the settings; None for any other id. +pub fn server(settings: &Settings, id: &str) -> Option { + let (id, name, url, key) = match id { + "ollama" => ("ollama", "Ollama", &settings.ollama_url, None), + "lmstudio" => ("lmstudio", "LM Studio", &settings.lmstudio_url, None), + "custom" => { + let key = net::normalise_server_url(&settings.custom_url).ok().and_then(|u| custom_key_for(&u)); + ("custom", crate::i18n::n_("OpenAI-compatible server"), &settings.custom_url, key) + } + _ => return None, + }; + Some(Server { id, name, url: url.clone(), key }) +} + +/// The usual address of a server on this machine; none for "custom". Ollama's +/// own OLLAMA_HOST wins when it is set, as the Ollama CLI does. +fn usual_address(id: &str) -> Option { + match id { + "ollama" => Some( + std::env::var("OLLAMA_HOST") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| "http://127.0.0.1:11434".into()), + ), + "lmstudio" => Some("http://127.0.0.1:1234".into()), + _ => None, + } +} + +fn bearer(key: Option<&str>) -> String { + format!("Bearer {}", key.filter(|k| !k.is_empty()).unwrap_or(NO_KEY)) +} + +fn unreachable(url: &Url) -> String { + tf("Cannot reach {url}. Is the server running?", &[("url", url.as_str().trim_end_matches('/'))]) +} + +fn base_url(server: &Server) -> Result { + if server.url.trim().is_empty() { + return Err(tf("Connect {name} in Settings → Local models first.", &[("name", &t(server.name))])); + } + net::normalise_server_url(&server.url) +} + +/// What the "Connect" button reports: the address as it will be stored, the +/// chat models the server has, and whether the address is this machine. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Connected { + pub url: String, + pub models: Vec, + pub loopback: bool, +} + +/// Settings → Local models → Connect: does the server answer, and which models +/// does it have? An empty address means the usual one on this machine. +pub async fn connect(id: &str, typed: &str) -> Result { + let raw = if typed.trim().is_empty() { usual_address(id).unwrap_or_default() } else { typed.to_string() }; + let url = net::normalise_server_url(&raw)?; + let key = if id == "custom" { custom_key_for(&url) } else { None }; + let models = list(&url, key.as_deref()).await?; + Ok(Connected { + url: url.as_str().trim_end_matches('/').to_string(), + models, + loopback: net::is_loopback_url(&url), + }) +} + +/// The models of a connected server, for the picker in the chat view. +pub async fn models(server: &Server) -> Result, String> { + let url = base_url(server)?; + let models = list(&url, server.key.as_deref()).await?; + if models.is_empty() { + return Err(tf("No models yet. Download one in {name} first.", &[("name", &t(server.name))])); + } + Ok(models.into_iter().map(|id| ModelInfo { label: id.clone(), id }).collect()) +} + +/// `GET /v1/models`, chat models only, or why the server can't be reached. +async fn list(base: &Url, key: Option<&str>) -> Result, String> { + let response = net::client(base, Duration::from_secs(5))? + .get(net::join(base, "v1/models")) + .header("Authorization", bearer(key)) + .send() + .await + .map_err(|_| unreachable(base))?; + if matches!(response.status().as_u16(), 401 | 403) { + return Err(t("The server refused the key. Check it, then connect again.")); + } + if !response.status().is_success() { + return Err(unreachable(base)); + } + let bytes = net::read_capped(response, net::MAX_BODY).await?; + let body: Value = serde_json::from_slice(&bytes).map_err(|_| unreachable(base))?; + parse_models(&body).ok_or_else(|| unreachable(base)) +} + +fn parse_models(body: &Value) -> Option> { + let items = body.get("data")?.as_array()?; + Some( + items + .iter() + .filter_map(|m| m.get("id").and_then(Value::as_str)) + .filter(|id| !NOT_CHAT.iter().any(|n| id.to_lowercase().contains(n))) + .map(str::to_string) + .collect(), + ) +} + +// ── Messages ────────────────────────────────────────────────────────────────── + +/// What a dropped file adds to the first message: text inline (cut), anything +/// else by name only. +fn file_note(name: &str, path: &str) -> String { + let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + let binary = matches!(ext.as_str(), "pdf" | "jpg" | "jpeg" | "png" | "gif" | "webp"); + // Read no more than the inline limit can use (4 bytes per character at most). + let text = if binary { None } else { read_prefix(path, MAX_INLINE_CHARS * 4) }; + match text { + Some(text) if !text.is_empty() => { + let mut body: String = text.chars().take(MAX_INLINE_CHARS).collect(); + if body.len() < text.len() { + body.push_str("\n[truncated]"); + } + format!("File: {name}\nFile contents:\n{body}") + } + _ => format!("File: {name}"), + } +} + +/// The first `limit` bytes of a file as UTF-8 text (a character cut in half at +/// the end is dropped), or None for a file that is not text. +fn read_prefix(path: &str, limit: usize) -> Option { + use std::io::Read; + let mut bytes = Vec::new(); + std::fs::File::open(path).ok()?.take(limit as u64).read_to_end(&mut bytes).ok()?; + match String::from_utf8(bytes) { + Ok(text) => Some(text), + Err(e) if e.utf8_error().error_len().is_none() => { + let valid = e.utf8_error().valid_up_to(); + let mut bytes = e.into_bytes(); + bytes.truncate(valid); + String::from_utf8(bytes).ok() + } + Err(_) => None, + } +} + +fn user_text(first: bool, context: Option<&ChatContext>, query: &str) -> String { + match context.filter(|_| first) { + Some(ChatContext::File { name, path }) => format!("{}\n\n{query}", file_note(name, path)), + Some(ChatContext::Window { app_name, title, url }) => { + format!("{}\n\n{query}", chat::window_line(app_name, title, url.as_deref())) + } + None => query.to_string(), + } +} + +fn request_body(model: &str, system: &str, history: &[Value], user: &Value) -> Value { + let mut messages = vec![json!({ "role": "system", "content": system })]; + messages.extend(history.iter().cloned()); + messages.push(user.clone()); + json!({ "model": model, "messages": messages, "stream": true, "max_tokens": MAX_TOKENS }) +} + +/// One chat turn with a model server. +pub async fn send( + app: &AppHandle, + chat: &Chat, + server: &Server, + model: &str, + query: String, + context: Option, +) -> Result { + let base = base_url(server)?; + if model.is_empty() { + return Err(t("Pick a model above the chat box first.")); + } + let turn = chat.begin(server.id); + let user = json!({ "role": "user", "content": user_text(turn.first, context.as_ref(), &query) }); + let body = request_body(model, &chat::system_prompt(false), &turn.history, &user); + + let answer = stream(&base, server.key.as_deref(), model, &body, |visible| { + let _ = app.emit_to(WINDOW_LABEL, "chat-delta", visible); + }) + .await?; + if answer.is_empty() { + return Err(t("No response text.")); + } + let plain = chat::plain_question(turn.first, context.as_ref(), &query); + chat.commit(&turn, user, json!({ "role": "assistant", "content": answer }), &plain, &answer); + Ok(ChatReply { text: answer }) +} + +// ── Streaming ───────────────────────────────────────────────────────────────── + +/// One server-sent event line: `Some(Ok(text))` for a content delta, +/// `Some(Err(message))` for an error event, None for anything else (other +/// lines, `[DONE]`, a delta without content, such as reasoning-only ones). +fn parse_sse_line(line: &str) -> Option> { + let payload = line.strip_prefix("data:")?.trim_start(); + if payload == "[DONE]" { + return None; + } + let json: Value = serde_json::from_str(payload).ok()?; + if let Some(msg) = json.pointer("/error/message").and_then(Value::as_str) { + return Some(Err(msg.to_string())); + } + json.pointer("/choices/0/delta/content")?.as_str().map(|s| Ok(s.to_string())) +} + +/// Removes finished `…` blocks (reasoning models such as DeepSeek-R1). +fn filter_thinking_blocks(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find("") { + let Some(len) = rest[start..].find("") else { break }; + out.push_str(&rest[..start]); + rest = &rest[start + len + "".len()..]; + } + out.push_str(rest); + out.trim().to_string() +} + +/// For the text shown while streaming: finished blocks go, and so does +/// everything after a block that has not been closed yet. +fn progressive_filter(text: &str) -> String { + let cleaned = filter_thinking_blocks(text); + match cleaned.find("") { + Some(at) => cleaned[..at].trim().to_string(), + None => cleaned, + } +} + +/// Posts the request and reads the event stream. `on_delta` gets the visible +/// text at most 15 times a second, and once more at the end; the result is the +/// whole answer without its thinking. +async fn stream( + base: &Url, + key: Option<&str>, + model: &str, + body: &Value, + mut on_delta: impl FnMut(String), +) -> Result { + let mut reply = net::client(base, Duration::from_secs(300))? + .post(net::join(base, "v1/chat/completions")) + .header("Authorization", bearer(key)) + .json(body) + .send() + .await + .map_err(|_| unreachable(base))?; + + let status = reply.status(); + if status.as_u16() == 404 { + return Err(tf("{model} isn't installed. Pick another model above the chat box.", &[("model", model)])); + } + if !status.is_success() { + let body = net::read_capped(reply, net::MAX_ERROR_BODY).await.unwrap_or_default(); + let detail = net::error_detail(&body); + return Err(if detail.is_empty() { format!("HTTP {}", status.as_u16()) } else { detail }); + } + + let mut accumulated = String::new(); + let mut pending = Vec::::new(); + let mut last = Instant::now().checked_sub(DELTA_INTERVAL).unwrap_or_else(Instant::now); + while let Some(chunk) = reply.chunk().await.map_err(|_| unreachable(base))? { + pending.extend_from_slice(&chunk); + // Whole lines only: a chunk may end in the middle of one, or of a character. + while let Some(end) = pending.iter().position(|b| *b == b'\n') { + let line: Vec = pending.drain(..=end).collect(); + let line = String::from_utf8_lossy(&line); + match parse_sse_line(line.trim_end()) { + Some(Ok(delta)) => accumulated.push_str(&delta), + Some(Err(message)) => return Err(message), + None => {} + } + } + if pending.len() > MAX_LINE || accumulated.len() > MAX_ANSWER { + return Err(t("The server's answer is too large.")); + } + if last.elapsed() >= DELTA_INTERVAL { + last = Instant::now(); + on_delta(progressive_filter(&accumulated)); + } + } + // A last line without its newline. + if let Some(Ok(delta)) = parse_sse_line(String::from_utf8_lossy(&pending).trim_end()) { + accumulated.push_str(&delta); + } + // The last state always goes out, whatever the throttle skipped. + on_delta(progressive_filter(&accumulated)); + Ok(filter_thinking_blocks(&accumulated)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::net::tests::serve_once; + + fn block_on(f: impl std::future::Future) -> T { + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(f) + } + + fn url(s: &str) -> Url { + net::normalise_server_url(s).unwrap() + } + + #[test] + fn only_content_deltas_are_taken_from_the_event_stream() { + let line = r#"data: {"choices":[{"delta":{"content":"Hel"}}]}"#; + assert_eq!(parse_sse_line(line), Some(Ok("Hel".into()))); + let line = r#"data:{"choices":[{"delta":{"content":"lo"}}]}"#; + assert_eq!(parse_sse_line(line), Some(Ok("lo".into()))); + assert_eq!(parse_sse_line("data: [DONE]"), None); + assert_eq!(parse_sse_line(""), None); + assert_eq!(parse_sse_line(": keep-alive"), None); + assert_eq!(parse_sse_line(r#"data: {"choices":[{"delta":{"role":"assistant"}}]}"#), None); + assert_eq!(parse_sse_line(r#"data: {"choices":[{"delta":{"content":null,"reasoning_content":"x"}}]}"#), None); + assert_eq!(parse_sse_line("data: not json"), None); + assert_eq!(parse_sse_line(r#"data: {"error":{"message":"out of memory"}}"#), Some(Err("out of memory".into()))); + } + + #[test] + fn finished_thinking_blocks_go_and_an_open_one_hides_what_follows() { + assert_eq!(filter_thinking_blocks("hmm\n\nThe answer."), "The answer."); + assert_eq!(filter_thinking_blocks("axbyc"), "abc"); + assert_eq!(filter_thinking_blocks("plain"), "plain"); + // Streaming: nothing of an unfinished block shows. + assert_eq!(progressive_filter("Sure. let me think"), "Sure."); + assert_eq!(progressive_filter("still thinking"), ""); + assert_eq!(progressive_filter("doneVisible again"), "Visible"); + // A tag that is only half written is not a block yet. + assert_eq!(progressive_filter("text \nstep "}}]}"#, + r#"{"choices":[{"delta":{"content":"one\n"}}]}"#, + r#"{"choices":[{"delta":{"content":null,"reasoning_content":"internal"}}]}"#, + r###"{"choices":[{"delta":{"content":"## Answer\n\n"}}]}"###, + r#"{"choices":[{"delta":{"content":"- **item 1**\n"}}]}"#, + r#"{"choices":[{"delta":{"content":"```python\nprint('hello')\n```"}}]}"#, + ]; + let mut body = String::new(); + for e in events { + body.push_str(&format!("data: {e}\n\n")); + } + body.push_str("data: [DONE]\n\n"); + let u = serve_once("200 OK", "Content-Type: text/event-stream\r\n", body.into_bytes()); + let mut seen = Vec::new(); + let answer = block_on(stream(&url(&u), None, "m", &json!({}), |v| seen.push(v))).unwrap(); + assert_eq!(answer, "## Answer\n\n- **item 1**\n```python\nprint('hello')\n```"); + assert_eq!(seen.last().unwrap(), &answer); + assert!(seen.iter().all(|v| !v.contains("step") && !v.contains("internal"))); + } + + #[test] + fn stream_errors_say_what_happened() { + let u = serve_once("404 Not Found", "Content-Length: 2\r\n", b"{}".to_vec()); + let err = block_on(stream(&url(&u), None, "unknown-model", &json!({}), |_| {})).unwrap_err(); + assert_eq!(err, "unknown-model isn't installed. Pick another model above the chat box."); + let body = br#"{"error":{"message":"context too long"}}"#; + let u = serve_once("400 Bad Request", &format!("Content-Length: {}\r\n", body.len()), body.to_vec()); + assert_eq!(block_on(stream(&url(&u), None, "m", &json!({}), |_| {})).unwrap_err(), "context too long"); + let u = serve_once("200 OK", "", b"data: {\"error\":{\"message\":\"crashed\"}}\n".to_vec()); + assert_eq!(block_on(stream(&url(&u), None, "m", &json!({}), |_| {})).unwrap_err(), "crashed"); + } + + #[test] + fn an_endless_line_is_cut_off() { + let u = serve_once("200 OK", "", vec![b'x'; MAX_LINE + 10]); + let err = block_on(stream(&url(&u), None, "m", &json!({}), |_| {})).unwrap_err(); + assert_eq!(err, "The server's answer is too large."); + } + + #[test] + fn text_files_go_inline_cut_and_everything_else_by_name() { + let dir = std::env::temp_dir().join(format!("coucou-local-chat-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let txt = dir.join("notes.txt"); + std::fs::write(&txt, "é".repeat(MAX_INLINE_CHARS + 500)).unwrap(); + let note = file_note("notes.txt", txt.to_str().unwrap()); + let body = note.strip_prefix("File: notes.txt\nFile contents:\n").expect("inline text"); + let body = body.strip_suffix("\n[truncated]").expect("marked as cut"); + assert_eq!(body.chars().count(), MAX_INLINE_CHARS); + + let small = dir.join("small.md"); + std::fs::write(&small, "# hi").unwrap(); + assert_eq!(file_note("small.md", small.to_str().unwrap()), "File: small.md\nFile contents:\n# hi"); + + let png = dir.join("pic.png"); + std::fs::write(&png, "not really a picture").unwrap(); + assert_eq!(file_note("pic.png", png.to_str().unwrap()), "File: pic.png"); + assert_eq!(file_note("gone.txt", "/no/such/file"), "File: gone.txt"); + let bin = dir.join("blob.bin"); + std::fs::write(&bin, [0xff, 0xfe, 0x00, 0x80]).unwrap(); + assert_eq!(file_note("blob.bin", bin.to_str().unwrap()), "File: blob.bin"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn the_custom_key_only_goes_to_the_address_it_was_entered_for() { + let url = |u: &str| net::normalise_server_url(u).unwrap(); + let stored = serde_json::to_string(&BoundKey { url: "https://llm.example.com".into(), key: "sk-1".into() }).unwrap(); + assert_eq!(key_for(&stored, &url("https://llm.example.com")).as_deref(), Some("sk-1")); + assert_eq!(key_for(&stored, &url("https://attacker.example")), None); + assert_eq!(key_for(&stored, &url("http://llm.example.com")), None); + // A plain string (the page can write one through secret_set) binds nothing. + assert_eq!(key_for("sk-raw", &url("https://llm.example.com")), None); + let local = serde_json::to_string(&BoundKey { url: "http://127.0.0.1:8080".into(), key: "k".into() }).unwrap(); + assert_eq!(key_for(&local, &url("http://127.0.0.1:8080")).as_deref(), Some("k")); + assert!(!may_carry_key(&url("http://192.168.1.20:8080"))); + } +} diff --git a/windows/src-tauri/src/net.rs b/windows/src-tauri/src/net.rs new file mode 100644 index 000000000..3d3992fad --- /dev/null +++ b/windows/src-tauri/src/net.rs @@ -0,0 +1,285 @@ +// Network helpers shared by the chat providers: address checks, bounded reads +// and HTTP clients. +// +// Every answer a server sends is read with a ceiling, so a misbehaving or +// hostile server (a local model address can point anywhere) cannot make the +// app hold an unbounded amount of memory. + +use std::net::{IpAddr, Ipv4Addr}; +use std::time::Duration; + +use reqwest::Url; + +use crate::i18n::{t, tf}; + +/// Ceiling for a JSON answer: a chat reply or a model list. +pub const MAX_BODY: usize = 8 * 1024 * 1024; +/// Ceiling for an error body: only its message is shown. +pub const MAX_ERROR_BODY: usize = 64 * 1024; + +/// True for an address that is this machine: `localhost`, 127.0.0.0/8, ::1, +/// and the unspecified addresses (0.0.0.0, ::), which connect to this machine. +pub fn is_loopback_host(host: &str) -> bool { + let host = host.trim_start_matches('[').trim_end_matches(']').to_ascii_lowercase(); + if host == "localhost" || host.ends_with(".localhost") { + return true; + } + match host.parse::() { + Ok(IpAddr::V4(ip)) => ip.is_loopback() || ip.is_unspecified(), + Ok(IpAddr::V6(ip)) => { + ip.is_loopback() + || ip.is_unspecified() + || ip.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback()) + } + Err(_) => false, + } +} + +pub fn is_loopback_url(url: &Url) -> bool { + url.host_str().is_some_and(is_loopback_host) +} + +/// The address of a model server as the user typed or pasted it, cleaned up: +/// a missing scheme becomes `http://`, trailing slashes and the documentation +/// sub-paths people paste (`/api`, `/v1`) go, and the loopback names become +/// 127.0.0.1 — on Windows `localhost` may resolve to ::1 first while Ollama +/// only listens on IPv4. Only http and https, and no `user:password@`. +pub fn normalise_server_url(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Err(t("Enter the server address first.")); + } + let with_scheme = if raw.contains("://") { raw.to_string() } else { format!("http://{raw}") }; + let mut url = Url::parse(&with_scheme).map_err(|_| tf("Not a valid address: {address}", &[("address", raw)]))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(t("The address must start with http:// or https://.")); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(t("Leave the user name and password out of the address.")); + } + if url.host_str().is_none_or(str::is_empty) { + return Err(tf("Not a valid address: {address}", &[("address", raw)])); + } + if is_loopback_url(&url) { + let _ = url.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST)); + } + let mut path = url.path().trim_end_matches('/').to_string(); + for suffix in ["/api", "/v1"] { + if let Some(rest) = path.strip_suffix(suffix) { + path = rest.trim_end_matches('/').to_string(); + } + } + url.set_path(&path); + url.set_query(None); + url.set_fragment(None); + Ok(url) +} + +/// `base` + `tail`, with exactly one slash between them. +pub fn join(base: &Url, tail: &str) -> String { + format!("{}/{}", base.as_str().trim_end_matches('/'), tail.trim_start_matches('/')) +} + +/// The Messages endpoint for an Anthropic-compatible gateway given in +/// COUCOU_ANTHROPIC_BASE_URL. A base URL (`https://gw.example.com`, +/// `…/v1`) gets `/v1/messages` added; a full endpoint is kept. The key goes +/// wherever this points, so it must be https — plain http only to this machine. +pub fn anthropic_endpoint(raw: &str) -> Result { + const VAR: &str = "COUCOU_ANTHROPIC_BASE_URL"; + let mut url = Url::parse(raw.trim()).map_err(|_| format!("{VAR} is not a valid URL."))?; + match url.scheme() { + "https" => {} + "http" if is_loopback_url(&url) => {} + "http" => return Err(format!("{VAR} must use https:// (plain http only to this computer).")), + _ => return Err(format!("{VAR} must start with https://.")), + } + if !url.username().is_empty() || url.password().is_some() || url.host_str().is_none() { + return Err(format!("{VAR} must not carry a user name or password.")); + } + let path = url.path().trim_end_matches('/').to_string(); + let path = if path.ends_with("/v1/messages") { + path + } else if path.ends_with("/v1") { + format!("{path}/messages") + } else { + format!("{path}/v1/messages") + }; + url.set_path(&path); + url.set_fragment(None); + Ok(url) +} + +/// A client for `url`: requests to this machine skip any system proxy, which +/// would otherwise see (and usually fail) a loopback address. +pub fn client(url: &Url, timeout: Duration) -> Result { + let mut builder = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10).min(timeout)) + .timeout(timeout); + if is_loopback_url(url) { + builder = builder.no_proxy(); + } + builder.build().map_err(|e| e.to_string()) +} + +/// The body of `response`, refused past `limit` bytes. +pub async fn read_capped(mut response: reqwest::Response, limit: usize) -> Result, String> { + if response.content_length().is_some_and(|n| n > limit as u64) { + return Err(t("The server's answer is too large.")); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|e| tf("Network error: {error}", &[("error", &e.to_string())]))? { + if body.len() + chunk.len() > limit { + return Err(t("The server's answer is too large.")); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +/// The text of an error body, cut down for a message: `error.message` +/// (OpenAI, Anthropic), a bare `error` string (Ollama), or the first characters. +pub fn error_detail(body: &[u8]) -> String { + let text = String::from_utf8_lossy(body); + if let Ok(v) = serde_json::from_str::(&text) { + if let Some(m) = v.pointer("/error/message").and_then(|m| m.as_str()) { + return m.to_string(); + } + if let Some(m) = v.get("error").and_then(|m| m.as_str()) { + return m.to_string(); + } + if let Some(m) = v.get("message").and_then(|m| m.as_str()) { + return m.to_string(); + } + // Gemini answers some errors as a one-element array. + if let Some(m) = v.pointer("/0/error/message").and_then(|m| m.as_str()) { + return m.to_string(); + } + } + text.trim().chars().take(200).collect() +} + +/// Only the host of an address, for the log: never a path, a query or a key. +pub fn host_for_log(url: &Url) -> String { + match (url.host_str(), url.port()) { + (Some(h), Some(p)) => format!("{h}:{p}"), + (Some(h), None) => h.to_string(), + _ => "?".into(), + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + #[test] + fn loopback_hosts_are_recognised_and_others_are_not() { + for host in ["localhost", "LOCALHOST", "app.localhost", "127.0.0.1", "127.8.9.1", "::1", "[::1]", "0.0.0.0", "[::]", "::ffff:127.0.0.1"] { + assert!(is_loopback_host(host), "{host}"); + } + for host in ["example.com", "192.168.1.10", "10.0.0.2", "localhost.example.com", "[2001:db8::1]", "128.0.0.1", ""] { + assert!(!is_loopback_host(host), "{host}"); + } + } + + #[test] + fn a_pasted_server_address_is_cleaned_up() { + let n = |s: &str| normalise_server_url(s).map(|u| u.to_string()); + assert_eq!(n(" http://localhost:11434/ ").unwrap(), "http://127.0.0.1:11434/"); + assert_eq!(n("http://localhost:11434/v1").unwrap(), "http://127.0.0.1:11434/"); + assert_eq!(n("http://127.0.0.1:1234/v1/").unwrap(), "http://127.0.0.1:1234/"); + assert_eq!(n("localhost:11434/api").unwrap(), "http://127.0.0.1:11434/"); + assert_eq!(n("0.0.0.0:11434").unwrap(), "http://127.0.0.1:11434/"); + assert_eq!(n("http://[::1]:8000").unwrap(), "http://127.0.0.1:8000/"); + assert_eq!(n("https://llm.example.com/proxy/v1?x=1#y").unwrap(), "https://llm.example.com/proxy"); + assert_eq!(n("gpu-box.lan:8000").unwrap(), "http://gpu-box.lan:8000/"); + } + + #[test] + fn a_server_address_that_is_not_http_is_refused() { + assert!(normalise_server_url("").is_err()); + assert!(normalise_server_url(" ").is_err()); + assert!(normalise_server_url("file:///etc/passwd").is_err()); + assert!(normalise_server_url("javascript://alert(1)").is_err()); + assert!(normalise_server_url("http://user:pw@example.com").is_err()); + assert!(normalise_server_url("http://").is_err()); + } + + #[test] + fn join_puts_one_slash_between_base_and_path() { + let base = normalise_server_url("http://127.0.0.1:11434").unwrap(); + assert_eq!(join(&base, "/v1/models"), "http://127.0.0.1:11434/v1/models"); + let base = Url::parse("https://api.openai.com/v1").unwrap(); + assert_eq!(join(&base, "chat/completions"), "https://api.openai.com/v1/chat/completions"); + } + + #[test] + fn the_anthropic_gateway_is_taken_as_a_base_url_and_must_be_https() { + let e = |s: &str| anthropic_endpoint(s).map(|u| u.to_string()); + assert_eq!(e("https://gw.example.com").unwrap(), "https://gw.example.com/v1/messages"); + assert_eq!(e("https://gw.example.com/").unwrap(), "https://gw.example.com/v1/messages"); + assert_eq!(e("https://gw.example.com/anthropic").unwrap(), "https://gw.example.com/anthropic/v1/messages"); + assert_eq!(e("https://gw.example.com/v1").unwrap(), "https://gw.example.com/v1/messages"); + assert_eq!(e("https://gw.example.com/v1/messages/").unwrap(), "https://gw.example.com/v1/messages"); + assert_eq!(e(" http://localhost:4000 ").unwrap(), "http://localhost:4000/v1/messages"); + assert_eq!(e("http://127.0.0.1:4000/v1").unwrap(), "http://127.0.0.1:4000/v1/messages"); + assert!(e("http://gw.example.com").is_err()); + assert!(e("ftp://gw.example.com").is_err()); + assert!(e("https://me:secret@gw.example.com").is_err()); + assert!(e("not a url").is_err()); + } + + #[test] + fn error_details_come_from_the_usual_places() { + assert_eq!(error_detail(br#"{"error":{"message":"bad key"}}"#), "bad key"); + assert_eq!(error_detail(br#"{"error":"model 'x' not found"}"#), "model 'x' not found"); + assert_eq!(error_detail(br#"[{"error":{"message":"quota"}}]"#), "quota"); + assert_eq!(error_detail(b" plain text "), "plain text"); + assert_eq!(error_detail("x".repeat(500).as_bytes()).len(), 200); + } + + #[test] + fn only_the_host_goes_to_the_log() { + let url = Url::parse("https://gw.example.com:8443/secret/path?key=abc").unwrap(); + assert_eq!(host_for_log(&url), "gw.example.com:8443"); + let url = Url::parse("https://gw.example.com/v1/messages").unwrap(); + assert_eq!(host_for_log(&url), "gw.example.com"); + } + + /// One-shot HTTP server on a free port answering the next request with `body`. + pub(crate) fn serve_once(status: &str, headers: &str, body: Vec) -> String { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let head = format!("HTTP/1.1 {status}\r\n{headers}Connection: close\r\n\r\n"); + std::thread::spawn(move || { + let (mut conn, _) = listener.accept().unwrap(); + let mut buf = [0u8; 8192]; + let _ = conn.read(&mut buf); + let _ = conn.write_all(head.as_bytes()); + let _ = conn.write_all(&body); + }); + url + } + + fn block_on(f: impl std::future::Future) -> T { + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(f) + } + + #[test] + fn a_body_past_the_ceiling_is_refused_with_or_without_a_length() { + let get = |url: String| async move { + let url = Url::parse(&url).unwrap(); + let response = client(&url, Duration::from_secs(5)).unwrap().get(url).send().await.unwrap(); + read_capped(response, 1000).await + }; + // Declared length too large. + let url = serve_once("200 OK", "Content-Length: 5000\r\n", vec![b'x'; 5000]); + assert!(block_on(get(url)).is_err()); + // No length (read to the end of the connection): stopped while reading. + let url = serve_once("200 OK", "", vec![b'x'; 5000]); + assert!(block_on(get(url)).is_err()); + // Under the ceiling: read whole. + let url = serve_once("200 OK", "Content-Length: 3\r\n", b"abc".to_vec()); + assert_eq!(block_on(get(url)).unwrap(), b"abc"); + } +} diff --git a/windows/src-tauri/src/openai_compat.rs b/windows/src-tauri/src/openai_compat.rs new file mode 100644 index 000000000..06546733f --- /dev/null +++ b/windows/src-tauri/src/openai_compat.rs @@ -0,0 +1,420 @@ +// Cloud providers that speak the OpenAI chat completions API: OpenAI, Google AI +// (Gemini's OpenAI-compatible endpoint, as ClaudeService.swift uses it) and +// OpenRouter. One table describes them; one client talks to all three. +// +// Same contract as claude.rs: the key never leaves the credential store and +// file bytes never cross the IPC boundary. Chat only — no tools are sent, so +// the model can answer but never act on the machine. + +use std::time::Duration; + +use reqwest::Url; +use serde_json::{json, Value}; + +use crate::chat::{self, Chat, ChatContext, ChatReply, ModelInfo}; +use crate::i18n::{t, tf}; +use crate::{net, secrets}; + +pub struct Provider { + pub id: &'static str, + pub name: &'static str, + pub base_url: &'static str, + /// Credential store entry of its API key. + pub key: &'static str, + /// Relative to `base_url`. + pub models_path: &'static str, + pub default_model: &'static str, + /// Model ids containing any of these are not chat models. + pub not_chat: &'static [&'static str], + /// OpenAI's reasoning models refuse `max_tokens` and want + /// `max_completion_tokens`; the compatible APIs take the older name. + pub max_tokens_field: &'static str, +} + +pub const PROVIDERS: &[Provider] = &[ + Provider { + id: "openai", + name: "OpenAI", + base_url: "https://api.openai.com/v1", + key: "openai-api-key", + models_path: "models", + default_model: "gpt-4o", + not_chat: &[ + "embed", "tts", "whisper", "dall-e", "audio", "realtime", "moderat", "codex", + "computer-use", "transcribe", "image", "sora", "babbage", "davinci", "instruct", + ], + max_tokens_field: "max_completion_tokens", + }, + Provider { + id: "google", + name: "Google AI", + base_url: "https://generativelanguage.googleapis.com/v1beta/openai", + key: "google-api-key", + models_path: "models", + default_model: "gemini-2.0-flash", + not_chat: &["embed", "imagen", "veo", "aqa", "tts", "audio", "live"], + max_tokens_field: "max_tokens", + }, + Provider { + id: "openrouter", + name: "OpenRouter", + base_url: "https://openrouter.ai/api/v1", + key: "openrouter-api-key", + models_path: "models", + default_model: "openrouter/auto", + not_chat: &[], + max_tokens_field: "max_tokens", + }, +]; + +pub fn provider(id: &str) -> Option<&'static Provider> { + PROVIDERS.iter().find(|p| p.id == id) +} + +const MAX_TOKENS: u32 = 4096; +/// Text and code files are inlined; anything larger is skipped. +const MAX_INLINE_TEXT: u64 = 200_000; +/// Images are sent inline as data URLs; past this size they are skipped. +const MAX_IMAGE: u64 = 5_000_000; + +fn url(p: &Provider, tail: &str) -> Result { + let base = Url::parse(p.base_url).map_err(|e| e.to_string())?; + Url::parse(&net::join(&base, tail)).map_err(|e| e.to_string()) +} + +/// The user's message for one turn: plain text, or text and an image part +/// when the first turn carries a dropped picture. +fn user_message(first: bool, context: Option<&ChatContext>, query: &str) -> Value { + let mut prefix = String::new(); + let mut image = None; + match context.filter(|_| first) { + Some(ChatContext::File { name, path }) => match file_part(path) { + Some(FilePart::Image(part)) => { + image = Some(part); + prefix = format!("File: {name}\n\n"); + } + Some(FilePart::Text(body)) => prefix = format!("File: {name}\nFile contents:\n{body}\n\n"), + None => prefix = format!("File: {name}\n\n"), + }, + Some(ChatContext::Window { app_name, title, url }) => { + prefix = format!("{}\n\n", chat::window_line(app_name, title, url.as_deref())); + } + None => {} + } + let text = format!("{prefix}{query}"); + match image { + None => json!({ "role": "user", "content": text }), + Some(part) => json!({ "role": "user", "content": [part, { "type": "text", "text": text }] }), + } +} + +fn request_body(p: &Provider, model: &str, system: &str, history: &[Value], user: &Value) -> Value { + let mut messages = vec![json!({ "role": "system", "content": system })]; + messages.extend(history.iter().cloned()); + messages.push(user.clone()); + let mut body = json!({ "model": model, "messages": messages }); + body[p.max_tokens_field] = json!(MAX_TOKENS); + body +} + +/// The answer's text, or the error the provider put in a 200 reply +/// (OpenRouter does when the upstream model fails). +fn reply_text(p: &Provider, response: &Value) -> Result { + let text = response + .pointer("/choices/0/message/content") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + if !text.is_empty() { + return Ok(text); + } + if let Some(msg) = response.pointer("/error/message").and_then(Value::as_str) { + return Err(format!("{}: {msg}", p.name)); + } + Err(t("No response text.")) +} + +fn status_error(p: &Provider, status: u16, detail: &str) -> String { + match status { + 401 | 403 => tf( + "{name} rejected the API key ({status}). Check it in Settings.", + &[("name", p.name), ("status", &status.to_string())], + ), + 402 => tf("{name}: not enough credits (402). {detail}", &[("name", p.name), ("detail", detail)]), + 404 => tf( + "{name}: model not found (404). Pick another one above the chat box. {detail}", + &[("name", p.name), ("detail", detail)], + ), + 429 => tf("{name} rate limit reached (429): {detail}", &[("name", p.name), ("detail", detail)]), + _ => format!("{} {status}: {detail}", p.name), + } +} + +/// One chat turn with a cloud provider. +pub async fn send( + chat: &Chat, + p: &'static Provider, + model: &str, + query: String, + context: Option, +) -> Result { + let key = secrets::get(p.key).ok_or_else(|| tf("{name} API key missing. Add it in Settings.", &[("name", p.name)]))?; + if model.is_empty() { + return Err(tf("Pick a {name} model above the chat box.", &[("name", p.name)])); + } + let turn = chat.begin(p.id); + let user = user_message(turn.first, context.as_ref(), &query); + let body = request_body(p, model, &chat::system_prompt(false), &turn.history, &user); + + let endpoint = url(p, "chat/completions")?; + let response = net::client(&endpoint, Duration::from_secs(90))? + .post(endpoint) + .bearer_auth(&key) + .json(&body) + .send() + .await + .map_err(|e| tf("Network error: {error}", &[("error", &e.to_string())]))?; + let status = response.status(); + if !status.is_success() { + let body = net::read_capped(response, net::MAX_ERROR_BODY).await.unwrap_or_default(); + return Err(status_error(p, status.as_u16(), &net::error_detail(&body))); + } + let bytes = net::read_capped(response, net::MAX_BODY).await?; + let json: Value = serde_json::from_slice(&bytes).map_err(|e| tf("Bad API response: {error}", &[("error", &e.to_string())]))?; + let text = reply_text(p, &json)?; + + let plain = chat::plain_question(turn.first, context.as_ref(), &query); + chat.commit(&turn, user, json!({ "role": "assistant", "content": text }), &plain, &text); + Ok(ChatReply { text }) +} + +/// The provider's chat models. Only ever asked with the user's key, once they +/// picked this provider in the chat. +pub async fn models(p: &Provider, key: &str) -> Result, String> { + let endpoint = url(p, p.models_path)?; + let response = net::client(&endpoint, Duration::from_secs(15))? + .get(endpoint) + .bearer_auth(key) + .send() + .await + .map_err(|e| tf("Network error: {error}", &[("error", &e.to_string())]))?; + let status = response.status(); + if !status.is_success() { + let body = net::read_capped(response, net::MAX_ERROR_BODY).await.unwrap_or_default(); + return Err(status_error(p, status.as_u16(), &net::error_detail(&body))); + } + let bytes = net::read_capped(response, net::MAX_BODY).await?; + let json: Value = serde_json::from_slice(&bytes).map_err(|_| t("Unexpected API response."))?; + Ok(parse_models(p, &json)) +} + +fn parse_models(p: &Provider, json: &Value) -> Vec { + let items = json.get("data").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut models: Vec<(ModelInfo, i64, bool)> = items + .iter() + .filter(|m| { + // OpenRouter lists image and audio generators too. + m.pointer("/architecture/output_modalities") + .and_then(Value::as_array) + .is_none_or(|mods| mods.iter().any(|x| x.as_str() == Some("text"))) + }) + .filter_map(|m| { + let raw = m.get("id")?.as_str()?; + // Gemini sometimes answers "models/gemini-…". + let id = raw.strip_prefix("models/").unwrap_or(raw).to_string(); + let lower = id.to_lowercase(); + if id.is_empty() || p.not_chat.iter().any(|x| lower.contains(x)) { + return None; + } + let created = m.get("created").and_then(Value::as_i64).unwrap_or(0); + let free = p.id == "openrouter" && is_free(m, &id); + let name = m.get("name").and_then(Value::as_str).unwrap_or(&id); + let label = if free && !name.to_lowercase().contains("(free)") { format!("{name} (free)") } else { name.to_string() }; + Some((ModelInfo { id, label }, created, free)) + }) + .collect(); + match p.id { + // Newest first, as on the Mac. + "openai" => models.sort_by_key(|m| std::cmp::Reverse(m.1)), + // Free models first, then by name. + "openrouter" => models.sort_by(|a, b| { + b.2.cmp(&a.2).then_with(|| a.0.label.to_lowercase().cmp(&b.0.label.to_lowercase())) + }), + _ => {} + } + models.into_iter().map(|(m, _, _)| m).collect() +} + +fn is_free(m: &Value, id: &str) -> bool { + let zero = |ptr: &str| { + m.pointer(ptr) + .and_then(Value::as_str) + .is_some_and(|v| v.parse::().ok() == Some(0.0)) + }; + id.ends_with(":free") || (zero("/pricing/prompt") && zero("/pricing/completion")) +} + +enum FilePart { + Image(Value), + Text(String), +} + +/// Images → image_url data URL, text/code → inline text. PDFs and other binary +/// files go by name only (there is no portable document part). +fn file_part(path: &str) -> Option { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + let len = std::fs::metadata(path).ok()?.len(); + let image = match ext.as_str() { + "jpg" | "jpeg" => Some("image/jpeg"), + "png" => Some("image/png"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + _ => None, + }; + if let Some(media) = image { + if len > MAX_IMAGE { + return None; + } + let bytes = std::fs::read(path).ok()?; + let url = format!("data:{media};base64,{}", crate::claude::base64_for(&bytes)); + return Some(FilePart::Image(json!({ "type": "image_url", "image_url": { "url": url } }))); + } + if ext == "pdf" || len > MAX_INLINE_TEXT { + return None; + } + std::fs::read_to_string(path).ok().map(FilePart::Text) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(id: &str) -> &'static Provider { + provider(id).unwrap() + } + + #[test] + fn the_table_points_at_each_providers_own_api_and_key() { + assert_eq!(url(p("openai"), "chat/completions").unwrap().as_str(), "https://api.openai.com/v1/chat/completions"); + assert_eq!( + url(p("google"), "chat/completions").unwrap().as_str(), + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" + ); + assert_eq!(url(p("openrouter"), "models").unwrap().as_str(), "https://openrouter.ai/api/v1/models"); + for prov in PROVIDERS { + assert!(crate::secrets::KNOWN_KEYS.contains(&prov.key), "{}", prov.key); + assert!(prov.base_url.starts_with("https://")); + } + assert!(provider("anthropic").is_none()); + assert!(provider("ollama").is_none()); + } + + #[test] + fn the_request_is_system_then_history_then_the_new_turn() { + let history = vec![json!({"role":"user","content":"a"}), json!({"role":"assistant","content":"b"})]; + let user = user_message(false, None, "c"); + assert_eq!(user, json!({"role":"user","content":"c"})); + let body = request_body(p("google"), "gemini-x", "sys", &history, &user); + assert_eq!(body["model"], "gemini-x"); + assert_eq!(body["max_tokens"], MAX_TOKENS); + let msgs = body["messages"].as_array().unwrap(); + assert_eq!(msgs.len(), 4); + assert_eq!(msgs[0], json!({"role":"system","content":"sys"})); + assert_eq!(msgs[3], user); + assert!(body.get("tools").is_none(), "chat only: no tools"); + // OpenAI's reasoning models refuse the old field name. + let body = request_body(p("openai"), "gpt-5", "sys", &[], &user); + assert_eq!(body["max_completion_tokens"], MAX_TOKENS); + assert!(body.get("max_tokens").is_none()); + } + + #[test] + fn context_rides_with_the_first_turn_as_text_or_an_image() { + let dir = std::env::temp_dir().join(format!("coucou-oai-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let txt = dir.join("notes.txt"); + std::fs::write(&txt, "hello").unwrap(); + let ctx = ChatContext::File { name: "notes.txt".into(), path: txt.to_string_lossy().into() }; + assert_eq!( + user_message(true, Some(&ctx), "sum up")["content"], + "File: notes.txt\nFile contents:\nhello\n\nsum up" + ); + assert_eq!(user_message(false, Some(&ctx), "more")["content"], "more"); + + let png = dir.join("pic.png"); + std::fs::write(&png, [0x89, b'P', b'N', b'G']).unwrap(); + let ctx = ChatContext::File { name: "pic.png".into(), path: png.to_string_lossy().into() }; + let msg = user_message(true, Some(&ctx), "what is it?"); + let parts = msg["content"].as_array().unwrap(); + assert_eq!(parts[0]["type"], "image_url"); + assert!(parts[0]["image_url"]["url"].as_str().unwrap().starts_with("data:image/png;base64,")); + assert_eq!(parts[1], json!({"type":"text","text":"File: pic.png\n\nwhat is it?"})); + + let pdf = dir.join("doc.pdf"); + std::fs::write(&pdf, "%PDF").unwrap(); + let ctx = ChatContext::File { name: "doc.pdf".into(), path: pdf.to_string_lossy().into() }; + assert_eq!(user_message(true, Some(&ctx), "q")["content"], "File: doc.pdf\n\nq"); + let _ = std::fs::remove_dir_all(dir); + + let ctx = ChatContext::Window { app_name: "Code".into(), title: "x".into(), url: None }; + assert_eq!(user_message(true, Some(&ctx), "q")["content"], "Context — App: Code, Window: x\n\nq"); + } + + #[test] + fn the_reply_text_or_the_error_inside_a_200_is_read() { + let ok = json!({"choices":[{"message":{"role":"assistant","content":" Hi! "}}]}); + assert_eq!(reply_text(p("openai"), &ok).unwrap(), "Hi!"); + let failed = json!({"error":{"message":"upstream down"}}); + assert_eq!(reply_text(p("openrouter"), &failed).unwrap_err(), "OpenRouter: upstream down"); + assert_eq!(reply_text(p("google"), &json!({"choices":[]})).unwrap_err(), "No response text."); + } + + #[test] + fn status_errors_say_what_to_do() { + assert!(status_error(p("openai"), 401, "x").contains("rejected the API key")); + assert!(status_error(p("openrouter"), 402, "x").contains("credits")); + assert_eq!(status_error(p("google"), 500, "boom"), "Google AI 500: boom"); + } + + #[test] + fn model_lists_keep_chat_models_only() { + let openai = json!({"data":[ + {"id":"gpt-4o","created":100}, + {"id":"text-embedding-3-small","created":300}, + {"id":"gpt-5-mini","created":200}, + {"id":"whisper-1","created":400}, + {"id":"dall-e-3","created":500} + ]}); + let ids: Vec<_> = parse_models(p("openai"), &openai).into_iter().map(|m| m.id).collect(); + assert_eq!(ids, vec!["gpt-5-mini", "gpt-4o"]); + + let google = json!({"data":[ + {"id":"models/gemini-2.0-flash"}, + {"id":"models/text-embedding-004"}, + {"id":"gemini-2.5-pro"}, + {"id":"models/imagen-3.0"} + ]}); + let ids: Vec<_> = parse_models(p("google"), &google).into_iter().map(|m| m.id).collect(); + assert_eq!(ids, vec!["gemini-2.0-flash", "gemini-2.5-pro"]); + + let openrouter = json!({"data":[ + {"id":"b/paid","name":"B Paid","pricing":{"prompt":"0.000001","completion":"0.000002"}}, + {"id":"a/free:free","name":"A (free)"}, + {"id":"c/zero","name":"C Zero","pricing":{"prompt":"0","completion":"0"}}, + {"id":"d/image","name":"D Image","architecture":{"output_modalities":["image"]}} + ]}); + let models = parse_models(p("openrouter"), &openrouter); + let labels: Vec<_> = models.iter().map(|m| m.label.as_str()).collect(); + assert_eq!(labels, vec!["A (free)", "C Zero (free)", "B Paid"]); + } + + #[test] + fn an_unknown_answer_gives_an_empty_list() { + assert!(parse_models(p("openai"), &json!({"nope":true})).is_empty()); + } +} diff --git a/windows/src-tauri/src/pipe.rs b/windows/src-tauri/src/pipe.rs index 36b6316c2..e668728de 100644 --- a/windows/src-tauri/src/pipe.rs +++ b/windows/src-tauri/src/pipe.rs @@ -14,9 +14,11 @@ // * whatever happens we drop the connection after the decision timeout, and // the terminal takes over. // -// What we write back is the bare word `allow` or `deny`. Turning that into the -// documented hookSpecificOutput JSON is coucou-hook's job, so the wire format -// Claude Code expects lives in exactly one place. +// What we write back is the bare word `allow` or `deny` — or, when the request +// was Claude Code asking a question, `{"answers":{…}}` with what was picked on +// the island. Turning either into the documented hookSpecificOutput JSON is +// coucou-hook's job, so the wire format Claude Code expects lives in exactly +// one place. use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; @@ -32,6 +34,7 @@ use tokio::sync::mpsc; use crate::island::WINDOW_LABEL; use crate::log; +use crate::session_window; /// Slightly under coucou-hook's own 110 s wait, so we always answer first. const DECISION_TIMEOUT: Duration = Duration::from_secs(108); @@ -152,6 +155,10 @@ pub fn start(app: AppHandle) { trait Relay: AsyncRead + AsyncWrite + Unpin { /// Ends the conversation once everything has been written. fn finish(&mut self) {} + /// The relay process on the other end, where the OS says. + fn client_pid(&self) -> Option { + None + } } #[cfg(windows)] @@ -159,6 +166,10 @@ impl Relay for NamedPipeServer { fn finish(&mut self) { let _ = self.disconnect(); } + fn client_pid(&self) -> Option { + use std::os::windows::io::AsRawHandle; + crate::platform::pipe_client_pid(self.as_raw_handle()) + } } /// Dropping the stream closes it; the relay reads up to our newline first. @@ -195,8 +206,15 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) { .unwrap_or_default() .to_string(); + note_session_window(&pipe, &payload, &event); + // Counts for the weekly recap — never the command, path or prompt itself. + crate::recap::observe(&app, &payload); + if event != "PermissionRequest" { - log::line(format!("hook {event}")); + // The status line relay calls in with every Claude Code update: not log-worthy. + if event != "StatusLine" { + log::line(format!("hook {event}")); + } let _ = app.emit_to(WINDOW_LABEL, "hook", payload); pipe.finish(); return; @@ -208,6 +226,7 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) { let pending = app.state::(); pending.0.lock().unwrap().insert(id.clone(), tx); } + crate::recap::note_request(&app, &id, &payload); payload["request_id"] = json!(id); log::line(format!("hook PermissionRequest id={id}")); let _ = app.emit_to(WINDOW_LABEL, "hook", payload); @@ -224,13 +243,38 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) { pipe.finish(); } +/// Finds, once per session, the window it runs in — see session_window.rs. +/// +/// Only while the session is unknown, so the process snapshot is not taken on +/// every event. The relay must still be running for its parents to be found: +/// a permission request always is (it waits for us), a quick event may already +/// have exited, and then a later event of the session tries again. +fn note_session_window(pipe: &impl Relay, payload: &Value, event: &str) { + let Some(session) = payload.get("session_id").and_then(Value::as_str) else { return }; + if event == "SessionEnd" { + session_window::forget(session); + return; + } + if session_window::known(session) { + return; + } + let Some(relay) = pipe.client_pid() else { return }; + let ancestors = crate::platform::process_ancestors(relay); + // No ancestors: the relay was already gone, so try again next time. Some, + // but none with a window (a classic console): settled, VS Code it is. + if !ancestors.is_empty() { + let owner = crate::platform::first_with_window(&ancestors); + session_window::remember(session, owner.unwrap_or(session_window::NO_WINDOW)); + } +} + /// Two waits: a short one for "the card is up", then the long one for a human. async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option { match tokio::time::timeout(ACK_TIMEOUT, rx.recv()).await { Ok(Some(Reply::Ack)) => {} // A click that beats the ack is still a click. Ok(Some(Reply::Decision(d))) => { - log::line(format!("hook id={id} answered {d}")); + log::line(format!("hook id={id} answered {}", loggable(&d))); return Some(d); } Ok(Some(Reply::Decline)) => { @@ -246,7 +290,7 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option { - log::line(format!("hook id={id} answered {d}")); + log::line(format!("hook id={id} answered {}", loggable(&d))); Some(d) } Ok(Some(Reply::Decline)) => { @@ -260,6 +304,11 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option &str { + if decision.starts_with('{') { "a question" } else { decision } +} + fn send(app: &AppHandle, request_id: &str, reply: Reply, keep: bool) { let sender = { let pending = app.state::(); @@ -295,3 +344,13 @@ pub fn answer(app: &AppHandle, request_id: &str, decision: &str) { log::line(format!("decision id={request_id} {word}")); send(app, request_id, Reply::Decision(word.to_string()), false); } + +/// Called when an option is picked for a question Claude Code asked. `answers` +/// maps each question's text to the chosen label, which is the shape +/// AskUserQuestion takes them in. +pub fn answer_question(app: &AppHandle, request_id: &str, answers: &HashMap) { + log::line(format!("decision id={request_id} answered a question")); + // One line: the relay reads up to the first newline. + let line = json!({ "answers": answers }).to_string(); + send(app, request_id, Reply::Decision(line), false); +} diff --git a/windows/src-tauri/src/platform/linux.rs b/windows/src-tauri/src/platform/linux.rs index e748a336a..38dff741d 100644 --- a/windows/src-tauri/src/platform/linux.rs +++ b/windows/src-tauri/src/platform/linux.rs @@ -51,6 +51,38 @@ pub fn local_dir() -> PathBuf { xdg("XDG_DATA_HOME", ".local/share").join("coucou") } +/// Where a saved image goes, best first: the XDG pictures folder named in +/// ~/.config/user-dirs.dirs, then ~/Pictures, ~/Downloads and the home folder. +/// The caller takes the first one that exists. +pub fn picture_dirs() -> Vec { + let home = home_dir(); + let mut dirs = Vec::new(); + let user_dirs = xdg("XDG_CONFIG_HOME", ".config").join("user-dirs.dirs"); + if let Ok(text) = std::fs::read_to_string(user_dirs) { + if let Some(dir) = xdg_user_dir(&text, "XDG_PICTURES_DIR", &home) { + dirs.push(dir); + } + } + dirs.push(home.join("Pictures")); + dirs.push(home.join("Downloads")); + dirs.push(home); + dirs +} + +/// `XDG_PICTURES_DIR="$HOME/Pictures"` → /home/me/Pictures. Only the two forms +/// xdg-user-dirs writes are understood: "$HOME/…" and an absolute path. +fn xdg_user_dir(text: &str, key: &str, home: &Path) -> Option { + let line = text.lines().map(str::trim).find(|l| l.starts_with(key))?; + let value = line.strip_prefix(key)?.trim_start().strip_prefix('=')?.trim(); + let value = value.strip_prefix('"')?.strip_suffix('"')?; + let path = match value.strip_prefix("$HOME") { + Some(rest) => home.join(rest.trim_start_matches('/')), + None => PathBuf::from(value), + }; + // "$HOME/" alone means "no pictures folder", by the spec. + (path.is_absolute() && path != home).then_some(path) +} + /// Environment the webview must inherit, set before any thread or process /// starts. /// @@ -60,6 +92,8 @@ pub fn local_dir() -> PathBuf { /// every launch, so each launch would rewrite the system's registry with /// plugin paths that vanish once Coucou quits. Give ours its own file. pub fn prepare_environment() { + prefer_x11_on_gnome(); + follow_gnome_text_scaling(); if std::env::var_os("APPIMAGE").is_none() || std::env::var_os("GST_REGISTRY").is_some() { return; } @@ -69,6 +103,66 @@ pub fn prepare_environment() { } } +/// GNOME on Wayland has no layer-shell and ignores where a regular window asks +/// to go, so the island lands in the middle of the screen. Through XWayland it +/// can be placed, and a Dock window survives "show desktop" (Super+D). +/// An inherited GDK_BACKEND=wayland is overridden too: editors and terminals +/// pass theirs down to every child. COUCOU_X11=0 keeps the Wayland window. +fn prefer_x11_on_gnome() { + let env = |k: &str| std::env::var(k).unwrap_or_default(); + if should_prefer_x11( + &env("XDG_SESSION_TYPE"), + &env("XDG_CURRENT_DESKTOP"), + &env("COUCOU_X11"), + &env("DISPLAY"), + ) { + std::env::set_var("GDK_BACKEND", "x11"); + } +} + +/// Only with XWayland actually there (`DISPLAY` set): forcing x11 without it +/// would leave GTK with no display at all. +fn should_prefer_x11(session: &str, desktop: &str, opt: &str, display: &str) -> bool { + session.eq_ignore_ascii_case("wayland") + && desktop.split(':').any(|d| d.eq_ignore_ascii_case("gnome")) + && opt != "0" + && !display.trim().is_empty() +} + +fn is_gnome(desktop: &str) -> bool { + desktop.split(':').any(|d| d.eq_ignore_ascii_case("gnome")) +} + +/// GNOME's "Large text" (text-scaling-factor > 1) makes WebKitGTK draw every +/// font larger inside a window sized in fixed pixels, so the island's content +/// was cut off (#122). The island's layout is its own, so it asks GDK to undo +/// that factor for this process only. An explicit GDK_DPI_SCALE always wins. +fn follow_gnome_text_scaling() { + if std::env::var_os("GDK_DPI_SCALE").is_some() + || !is_gnome(&std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default()) + { + return; + } + let Ok(out) = Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "text-scaling-factor"]) + .output() + else { + return; + }; + if let Some(scale) = dpi_scale_for(&String::from_utf8_lossy(&out.stdout)) { + std::env::set_var("GDK_DPI_SCALE", scale); + } +} + +/// `"1.25\n"` → `Some("0.8")`; `None` for 1.0 or anything unreadable. +fn dpi_scale_for(gsettings_output: &str) -> Option { + let factor: f64 = gsettings_output.trim().parse().ok()?; + if !(0.5..=3.0).contains(&factor) || (factor - 1.0).abs() < 0.01 { + return None; + } + Some(format!("{:.4}", 1.0 / factor).trim_end_matches('0').trim_end_matches('.').to_string()) +} + pub fn local_time() -> LocalTime { let mut tm: libc::tm = unsafe { std::mem::zeroed() }; unsafe { @@ -119,6 +213,25 @@ pub fn relay_socket_path() -> Option { is_private_dir(&dir).then(|| dir.join("coucou.sock")) } +// ── Who we are ──────────────────────────────────────────────────────────────── + +/// The account's full name: the first field of the passwd GECOS entry +/// ("Louis Raille,,,"), or nothing when it is unset. +pub fn user_full_name() -> Option { + unsafe { + let mut pwd: libc::passwd = std::mem::zeroed(); + let mut result: *mut libc::passwd = std::ptr::null_mut(); + let mut buf = vec![0 as libc::c_char; 16 * 1024]; + let rc = libc::getpwuid_r(libc::getuid(), &mut pwd, buf.as_mut_ptr(), buf.len(), &mut result); + if rc != 0 || result.is_null() || pwd.pw_gecos.is_null() { + return None; + } + let gecos = std::ffi::CStr::from_ptr(pwd.pw_gecos).to_string_lossy().into_owned(); + let name = gecos.split(',').next().unwrap_or("").trim().to_string(); + (!name.is_empty()).then_some(name) + } +} + // ── Processes ───────────────────────────────────────────────────────────────── /// Nothing to hide: a spawned process only gets a terminal if it asks for one. @@ -146,6 +259,55 @@ pub fn find_on_path(stem: &str) -> Option { }) } +// ── Session windows ("Open terminal") ───────────────────────────────────────── +// +// Wayland lets no app raise another app's window, and X11 would need a window +// manager protocol client this build does not carry, so "Open terminal" opens +// the folder in VS Code here, as before. There is no Claude desktop app for +// Linux either. + +pub fn process_ancestors(_pid: u32) -> Vec { + Vec::new() +} + +pub fn first_with_window(_pids: &[u32]) -> Option { + None +} + +pub fn focus_process_window(_pid: u32, _folder: &str) -> bool { + false +} + +pub fn open_claude_desktop() -> bool { + false +} + +/// Where the Codex CLI may be, best first: $PATH, then the usual per-user +/// install folders, which a desktop launch often leaves out of $PATH (npm's +/// global prefix, Volta, Bun, pnpm, and nvm with its newest Node first). +pub fn codex_candidates() -> Vec { + let home = home_dir(); + let mut out: Vec = find_on_path("codex").into_iter().collect(); + for dir in [".local/bin", ".npm-global/bin", ".volta/bin", ".bun/bin", ".local/share/pnpm"] { + out.push(home.join(dir).join("codex")); + } + out.push(PathBuf::from("/usr/local/bin/codex")); + out.push(PathBuf::from("/usr/bin/codex")); + let nvm = home.join(".nvm/versions/node"); + if let Ok(entries) = std::fs::read_dir(&nvm) { + let mut versions: Vec = + entries.filter_map(|e| e.ok()?.file_name().into_string().ok()).collect(); + versions.sort_by(|a, b| crate::codex_plan::compare_versions(b, a)); + out.extend(versions.iter().map(|v| nvm.join(v).join("bin/codex"))); + } + out.retain(|p| { + std::fs::metadata(p) + .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) + }); + out +} + // ── Cursor ──────────────────────────────────────────────────────────────────── /// Nothing polls the cursor here: the page reports it over the island, and the @@ -167,8 +329,12 @@ mod layer { use gtk::ffi::GtkWindow; use std::os::raw::{c_char, c_int}; + pub const LAYER_TOP: c_int = 2; pub const LAYER_OVERLAY: c_int = 3; + pub const EDGE_LEFT: c_int = 0; + pub const EDGE_RIGHT: c_int = 1; pub const EDGE_TOP: c_int = 2; + pub const EDGE_BOTTOM: c_int = 3; pub const KEYBOARD_NONE: c_int = 0; pub const KEYBOARD_ON_DEMAND: c_int = 2; @@ -179,11 +345,18 @@ mod layer { pub fn gtk_layer_set_namespace(window: *mut GtkWindow, name_space: *const c_char); pub fn gtk_layer_set_layer(window: *mut GtkWindow, layer: c_int); pub fn gtk_layer_set_anchor(window: *mut GtkWindow, edge: c_int, anchor: c_int); + pub fn gtk_layer_set_margin(window: *mut GtkWindow, edge: c_int, margin: c_int); + pub fn gtk_layer_set_monitor(window: *mut GtkWindow, monitor: *mut gtk::gdk::ffi::GdkMonitor); pub fn gtk_layer_set_exclusive_zone(window: *mut GtkWindow, zone: c_int); pub fn gtk_layer_set_keyboard_mode(window: *mut GtkWindow, mode: c_int); } } +/// COUCOU_LAYER_SHELL=0 is the way out on a compositor where it misbehaves. +fn layer_shell_wanted() -> bool { + std::env::var("COUCOU_LAYER_SHELL").map(|v| v != "0").unwrap_or(true) +} + /// True once the island window is a layer-shell surface. static LAYER_SURFACE: AtomicBool = AtomicBool::new(false); @@ -210,8 +383,11 @@ pub fn unblock_webview_drops(_app: &AppHandle) {} /// up to the window manager. pub fn make_non_activating(win: &WebviewWindow) { let Ok(gw) = win.gtk_window() else { return }; - // COUCOU_LAYER_SHELL=0 is the way out on a compositor where it misbehaves. - let wanted = std::env::var("COUCOU_LAYER_SHELL").map(|v| v != "0").unwrap_or(true); + // The pointer watch reports the pointer leaving on every kind of window: + // a regular one (GNOME, X11) misses `mouseout` just as a layer surface does. + ISLAND_GTK.with(|cell| *cell.borrow_mut() = Some(gw.clone())); + watch_pointer_leave(win); + let wanted = layer_shell_wanted(); let supported = unsafe { layer::gtk_layer_is_supported() } != 0; if !wanted || !supported || gw.is_realized() { let why = if !wanted { @@ -223,6 +399,18 @@ pub fn make_non_activating(win: &WebviewWindow) { }; crate::log::line(format!("island is a regular window ({why})")); gw.set_accept_focus(false); + if !gw.is_realized() { + // On X11 a Dock is kept above everything and is the only kind of + // window, besides the desktop, that "show desktop" leaves alone. + // COUCOU_DOCK=0 falls back to a utility window. + let dock = std::env::var("COUCOU_DOCK").map(|v| v != "0").unwrap_or(true); + gw.set_type_hint(if dock { + gtk::gdk::WindowTypeHint::Dock + } else { + gtk::gdk::WindowTypeHint::Utility + }); + } + gw.set_keep_above(true); return; } // tao gives undecorated Wayland windows an empty titlebar to force @@ -263,6 +451,102 @@ pub fn make_non_activating(win: &WebviewWindow) { crate::log::line("island is a layer-shell overlay"); } +thread_local! { + /// The island's GTK window, for the pointer watch (GTK main thread only). + static ISLAND_GTK: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +/// Reports the pointer leaving the island, which the page cannot see by itself. +/// +/// On a layer surface WebKitGTK does not send `mouseout` when the pointer leaves +/// for good, and GTK's leave-notify fires spuriously while the pointer is still +/// over the island (each followed by a re-enter), so neither tells "really +/// gone". GDK's pointer focus does: it follows the compositor's wl_pointer +/// enter/leave. It is read every 100 ms while the island is shown; both edges go +/// to the page as `pointer-inside`, and leaving also emits the far-away cursor +/// the page uses. While the island is hidden the timer is removed altogether +/// (set_pointer_watch), so a hidden island costs nothing. +static POINTER_WIN: std::sync::OnceLock = std::sync::OnceLock::new(); +static WATCH_WANTED: AtomicBool = AtomicBool::new(true); +static WATCH_RUNNING: AtomicBool = AtomicBool::new(false); + +fn watch_pointer_leave(win: &WebviewWindow) { + let _ = POINTER_WIN.set(win.clone()); + start_pointer_watch(); +} + +/// Starts or parks the pointer watch. Called with the island's collapsed state; +/// safe from any thread (the timer itself lives on the GTK main loop). +pub fn set_pointer_watch(active: bool) { + WATCH_WANTED.store(active, Ordering::Relaxed); + if active { + gtk::glib::MainContext::default().invoke(start_pointer_watch); + } +} + +/// Main thread only. +fn start_pointer_watch() { + if !WATCH_WANTED.load(Ordering::Relaxed) || WATCH_RUNNING.swap(true, Ordering::Relaxed) { + return; + } + use tauri::Emitter; + let Some(win) = POINTER_WIN.get().cloned() else { + WATCH_RUNNING.store(false, Ordering::Relaxed); + return; + }; + let inside = std::cell::Cell::new(false); + gtk::glib::timeout_add_local(std::time::Duration::from_millis(100), move || { + if !WATCH_WANTED.load(Ordering::Relaxed) { + WATCH_RUNNING.store(false, Ordering::Relaxed); + return gtk::glib::ControlFlow::Break; + } + let now_inside = ISLAND_GTK + .with(|cell| { + let gw = cell.borrow().clone()?; + let ours = gw.window()?; + let pointer = gtk::gdk::Display::default()?.default_seat()?.pointer()?; + let (under, _, _) = pointer.window_at_position(); + Some(under?.toplevel() == ours.toplevel()) + }) + .unwrap_or(false); + if inside.replace(now_inside) != now_inside { + // The page gates its own (late, sometimes stale) mouse events on + // this state, so a mousemove queued before the pointer left cannot + // pull the island back to "inside". + let _ = win.emit("pointer-inside", now_inside); + if !now_inside { + let _ = win.emit("cursor", crate::island::CursorPayload { x: -10_000.0, y: -10_000.0 }); + } + } + gtk::glib::ControlFlow::Continue + }); +} + +/// Puts the layer surface on the display whose logical origin is (`x`, `y`). +/// +/// A layer surface ignores `set_position`: without an explicit output the +/// compositor maps it on whichever display has focus at that moment, so on a +/// multi-monitor Hyprland or Sway desktop the island wandered from one screen +/// to the other every time it was remapped. A no-op for a regular window, which +/// `set_position` already places. +pub fn pin_to_monitor(win: &WebviewWindow, x: i32, y: i32) { + if !LAYER_SURFACE.load(Ordering::Relaxed) { + return; + } + let Ok(gw) = win.gtk_window() else { return }; + let display = gw.display(); + let monitor = (0..display.n_monitors()) + .filter_map(|i| display.monitor(i)) + .find(|m| { + let g = m.geometry(); + g.x() == x && g.y() == y + }); + let Some(monitor) = monitor else { return }; + let mon_ptr: *mut gtk::gdk::ffi::GdkMonitor = monitor.to_glib_none().0; + unsafe { layer::gtk_layer_set_monitor(gtk_window_ptr(&gw), mon_ptr) }; +} + /// Temporarily allow keyboard focus so a text field inside the island can be /// typed in. pub fn set_activating(win: &WebviewWindow, activating: bool) { @@ -300,6 +584,201 @@ fn apply_input_region(gw: &impl IsA, rect: Region) { } } +// ── Global shortcuts ────────────────────────────────────────────────────────── + +/// Global shortcuts are X11 key grabs. A Wayland session has no such thing: a +/// grab made through XWayland only sees keys typed into other X11 windows, so +/// it would look registered and never fire. The XDG GlobalShortcuts portal is +/// the Wayland way and isn't wired up yet, so on Wayland nothing is registered +/// and Settings explains how to bind `coucou --shortcut ` in the desktop's +/// own keyboard settings instead. +pub fn global_shortcuts_blocked() -> Option<&'static str> { + let set = |var: &str| std::env::var_os(var).is_some_and(|v| !v.is_empty()); + let wayland = set("WAYLAND_DISPLAY") + || std::env::var("XDG_SESSION_TYPE").is_ok_and(|v| v.eq_ignore_ascii_case("wayland")); + if wayland { + Some("wayland") + } else if !set("DISPLAY") { + Some("no-display") + } else { + None + } +} + +/// On X11, AltGr is a modifier of its own (ISO_Level3_Shift): Ctrl+Alt+key +/// never stands for it, so no combination takes a character away. +pub fn ctrl_alt_types(_vk: u16, _shift: bool) -> Option { + None +} + +// ── Desktop Mochi window ────────────────────────────────────────────────────── +// +// Where the window may go decides everything here: +// * X11 places any window where it is asked: an ordinary always-on-top +// window, moved with set_position; +// * a layer-shell compositor won't move a toplevel, but anchors a layer +// surface wherever its margins say, on the island's display; +// * GNOME on Wayland has neither, so the feature is off there. +// In every case the input region is Mochi's body, so clicks anywhere else go +// to the desktop underneath without any cursor polling. + +/// Which of the three applies. Must run on the GTK main thread. +pub fn desktop_mode() -> super::DesktopMode { + use gtk::glib::prelude::ObjectExt; + let wayland = gtk::gdk::Display::default() + .map(|d| d.type_().name().contains("Wayland")) + .unwrap_or(false); + let layer = layer_shell_wanted() && unsafe { layer::gtk_layer_is_supported() } != 0; + if layer { + super::DesktopMode::Layer + } else if !wayland { + super::DesktopMode::Window + } else { + super::DesktopMode::Off + } +} + +/// The input shape last asked for the desktop Mochi, re-applied on every map. +static MOCHI_SHAPE: Mutex = Mutex::new(super::MouseShape::Empty); + +/// Sets the window up for `mode` before it is ever shown. False means it can't +/// be used (a layer surface can't be made from a window already shown). +pub fn prepare_desktop_window(win: &WebviewWindow, mode: super::DesktopMode) -> bool { + let Ok(gw) = win.gtk_window() else { return false }; + gw.set_accept_focus(false); + match mode { + super::DesktopMode::Layer => { + if gw.is_realized() { + crate::log::line("desktop Mochi: window already shown, no layer surface"); + return false; + } + gw.set_titlebar(None::<>k::Widget>); + let ptr = gtk_window_ptr(&gw); + unsafe { + layer::gtk_layer_init_for_window(ptr); + layer::gtk_layer_set_namespace(ptr, c"coucou-mochi".as_ptr()); + // Above windows, below fullscreen video — like a floating panel. + layer::gtk_layer_set_layer(ptr, layer::LAYER_TOP); + layer::gtk_layer_set_anchor(ptr, layer::EDGE_TOP, 1); + layer::gtk_layer_set_anchor(ptr, layer::EDGE_LEFT, 1); + // -1: margins count from the display's corner, panels or not, + // the same origin the island uses. + layer::gtk_layer_set_exclusive_zone(ptr, -1); + layer::gtk_layer_set_keyboard_mode(ptr, layer::KEYBOARD_NONE); + } + // Same first-frame problem as the island (see make_non_activating). + let remapped = std::cell::Cell::new(false); + gw.connect_map_event(move |w, _| { + apply_mouse_shape(w, *MOCHI_SHAPE.lock().unwrap()); + if !remapped.replace(true) { + let w = w.clone(); + gtk::glib::idle_add_local_once(move || { + w.hide(); + w.show_all(); + apply_mouse_shape(&w, *MOCHI_SHAPE.lock().unwrap()); + }); + } + gtk::glib::Propagation::Proceed + }); + true + } + super::DesktopMode::Window => { + gw.connect_map_event(|w, _| { + apply_mouse_shape(w, *MOCHI_SHAPE.lock().unwrap()); + gtk::glib::Propagation::Proceed + }); + true + } + _ => false, + } +} + +/// Which part of the desktop Mochi takes the mouse. Main thread. +pub fn set_desktop_shape(win: &WebviewWindow, shape: super::MouseShape) { + *MOCHI_SHAPE.lock().unwrap() = shape; + let Ok(gw) = win.gtk_window() else { return }; + apply_mouse_shape(&gw, shape); +} + +fn apply_mouse_shape(gw: &impl IsA, shape: super::MouseShape) { + use gtk::cairo::{RectangleInt, Region}; + if shape == super::MouseShape::Whole { + gw.input_shape_combine_region(None); + return; + } + let Some(gdk_window) = gw.window() else { return }; + match shape { + super::MouseShape::Whole => {} + super::MouseShape::Empty => { + let region = Region::create_rectangle(&RectangleInt::new(0, 0, 0, 0)); + gdk_window.input_shape_combine_region(®ion, 0, 0); + } + super::MouseShape::Disc { cx, cy, r } => { + // A region is made of rectangles: stack 2 px rows into a disc. + let region = Region::create_rectangle(&RectangleInt::new(0, 0, 0, 0)); + let mut y = (cy - r).floor(); + while y < cy + r { + let mid = y + 1.0 - cy; + let half = (r * r - mid * mid).max(0.0).sqrt(); + let row = RectangleInt::new( + (cx - half).floor() as i32, + y as i32, + (2.0 * half).ceil() as i32, + 2, + ); + let _ = region.union_rectangle(&row); + y += 2.0; + } + gdk_window.input_shape_combine_region(®ion, 0, 0); + } + } +} + +/// Layer surface only: puts the top-left corner at (x, y), logical pixels from +/// the display's corner. Main thread. +pub fn set_layer_margins(win: &WebviewWindow, x: f64, y: f64) { + let Ok(gw) = win.gtk_window() else { return }; + let ptr = gtk_window_ptr(&gw); + unsafe { + layer::gtk_layer_set_margin(ptr, layer::EDGE_LEFT, x.round() as i32); + layer::gtk_layer_set_margin(ptr, layer::EDGE_TOP, y.round() as i32); + } +} + +/// Layer surface only: `on` stretches it over the whole display — how a drag +/// gets exact pointer positions on Wayland, where a surface that moves under +/// the pointer can't tell where it is — and `off` folds it back to Mochi's +/// size in the corner. Main thread. +pub fn set_layer_overlay(win: &WebviewWindow, on: bool) { + let Ok(gw) = win.gtk_window() else { return }; + let ptr = gtk_window_ptr(&gw); + unsafe { + layer::gtk_layer_set_anchor(ptr, layer::EDGE_RIGHT, on as i32); + layer::gtk_layer_set_anchor(ptr, layer::EDGE_BOTTOM, on as i32); + if on { + layer::gtk_layer_set_margin(ptr, layer::EDGE_LEFT, 0); + layer::gtk_layer_set_margin(ptr, layer::EDGE_TOP, 0); + } + } +} + +/// Layer surface only: puts the desktop Mochi on the island's display and +/// returns that display's logical size. Main thread. +pub fn layer_display(island: &WebviewWindow, mochi: &WebviewWindow) -> Option<(f64, f64)> { + let island_gw = island.gtk_window().ok()?; + let display = gtk::gdk::Display::default()?; + let monitor = island_gw + .window() + .and_then(|w| display.monitor_at_window(&w)) + .or_else(|| display.primary_monitor()) + .or_else(|| display.monitor(0))?; + if let Ok(gw) = mochi.gtk_window() { + unsafe { layer::gtk_layer_set_monitor(gtk_window_ptr(&gw), monitor.to_glib_none().0) }; + } + let g = monitor.geometry(); + Some((g.width() as f64, g.height() as f64)) +} + #[cfg(test)] mod tests { use super::*; @@ -333,6 +812,26 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[test] + fn x11_is_only_preferred_on_gnome_wayland_unless_opted_out() { + assert!(should_prefer_x11("wayland", "ubuntu:GNOME", "", ":0")); + assert!(!should_prefer_x11("x11", "ubuntu:GNOME", "", ":0")); + assert!(!should_prefer_x11("wayland", "KDE", "", ":0")); + assert!(!should_prefer_x11("wayland", "GNOME", "0", ":0")); + // No XWayland: keep the Wayland window rather than no display at all. + assert!(!should_prefer_x11("wayland", "GNOME", "", "")); + } + + #[test] + fn text_scaling_is_undone_for_the_island() { + assert_eq!(dpi_scale_for("1.25\n").as_deref(), Some("0.8")); + assert_eq!(dpi_scale_for("1.5").as_deref(), Some("0.6667")); + assert_eq!(dpi_scale_for("1.0").as_deref(), None); + assert_eq!(dpi_scale_for("").as_deref(), None); + assert_eq!(dpi_scale_for("nonsense").as_deref(), None); + assert_eq!(dpi_scale_for("40").as_deref(), None); + } + #[test] fn private_dirs_are_closed_to_everyone_else() { use std::os::unix::fs::MetadataExt; @@ -344,4 +843,17 @@ mod tests { assert_eq!(std::fs::metadata(&dir).unwrap().mode() & 0o777, 0o700); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn the_pictures_folder_comes_from_user_dirs() { + let home = Path::new("/home/me"); + let text = "# written by xdg-user-dirs-update\nXDG_DESKTOP_DIR=\"$HOME/Desktop\"\nXDG_PICTURES_DIR=\"$HOME/Images\"\n"; + assert_eq!(xdg_user_dir(text, "XDG_PICTURES_DIR", home), Some(PathBuf::from("/home/me/Images"))); + let absolute = "XDG_PICTURES_DIR=\"/data/pics\""; + assert_eq!(xdg_user_dir(absolute, "XDG_PICTURES_DIR", home), Some(PathBuf::from("/data/pics"))); + // "$HOME/" means the folder is disabled; relative paths are not paths. + assert_eq!(xdg_user_dir("XDG_PICTURES_DIR=\"$HOME/\"", "XDG_PICTURES_DIR", home), None); + assert_eq!(xdg_user_dir("XDG_PICTURES_DIR=\"pics\"", "XDG_PICTURES_DIR", home), None); + assert_eq!(xdg_user_dir("", "XDG_PICTURES_DIR", home), None); + } } diff --git a/windows/src-tauri/src/platform/mod.rs b/windows/src-tauri/src/platform/mod.rs index 5eb032dc7..73ce61f9f 100644 --- a/windows/src-tauri/src/platform/mod.rs +++ b/windows/src-tauri/src/platform/mod.rs @@ -31,3 +31,55 @@ pub fn home_dir() -> PathBuf { .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) } + +/// How the desktop Mochi's window can be placed and clicked on this system. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum DesktopMode { + /// Windows: placed anywhere, and a cursor poll drives click-through, the + /// eyes and the drag, exactly like the island. + Poll, + /// Linux on X11: an ordinary window the window manager lets us place; the + /// input region is the click-through and the page drives the drag. + Window, + /// Linux on a layer-shell compositor (KDE, COSMIC, wlroots): a layer + /// surface anchored to the top-left corner of the island's display, placed + /// with margins. + Layer, + /// Wayland without layer-shell (GNOME): no way to put a window where the + /// user dropped it, so Mochi stays in the island. + Off, +} + +impl DesktopMode { + pub fn as_str(self) -> &'static str { + match self { + DesktopMode::Poll => "poll", + DesktopMode::Window => "window", + DesktopMode::Layer => "layer", + DesktopMode::Off => "off", + } + } + + /// The space a saved spot is measured in. A spot from another space (the + /// same machine once on X11, once on Wayland) is not trusted. + pub fn space(self) -> &'static str { + match self { + // Physical pixels of the whole desktop, as the OS reports monitors. + DesktopMode::Poll | DesktopMode::Window => "screen", + // Logical pixels from the top-left corner of the island's display. + DesktopMode::Layer | DesktopMode::Off => "layer", + } + } +} + +/// Which part of the desktop Mochi's window takes the mouse. +#[derive(Clone, Copy, PartialEq, Debug)] +#[cfg_attr(windows, allow(dead_code))] +pub enum MouseShape { + /// Nothing: every click goes to what is underneath (during a flight). + Empty, + /// The whole window (while he is being dragged across the screen). + Whole, + /// A disc, in window-logical pixels: Mochi's body. + Disc { cx: f64, cy: f64, r: f64 }, +} diff --git a/windows/src-tauri/src/platform/windows.rs b/windows/src-tauri/src/platform/windows.rs index 6e1200a8a..8e7efc07c 100644 --- a/windows/src-tauri/src/platform/windows.rs +++ b/windows/src-tauri/src/platform/windows.rs @@ -1,5 +1,7 @@ // Windows: Win32 for the island window and the cursor, %APPDATA% for files. +use std::collections::HashMap; +use std::os::windows::io::RawHandle; use std::os::windows::process::CommandExt; use std::path::PathBuf; use std::process::Command; @@ -10,17 +12,26 @@ use ::windows::core::{BOOL, PWSTR}; use ::windows::Win32::Foundation::{CloseHandle, HANDLE, HLOCAL, HWND, LPARAM, LocalFree, POINT}; use ::windows::Win32::Security::Authorization::ConvertSidToStringSidW; use ::windows::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER}; +use ::windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, +}; use ::windows::Win32::System::Ole::RevokeDragDrop; +use ::windows::Win32::System::Pipes::GetNamedPipeClientProcessId; use ::windows::Win32::System::SystemInformation::GetLocalTime; use ::windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; -use ::windows::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_LBUTTON}; +use ::windows::Win32::UI::Input::KeyboardAndMouse::{ + GetAsyncKeyState, GetKeyboardLayoutList, MapVirtualKeyExW, ToUnicodeEx, HKL, MAPVK_VK_TO_VSC, + VK_CONTROL, VK_LBUTTON, VK_MENU, VK_SHIFT, +}; use ::windows::Win32::UI::WindowsAndMessaging::{ - EnumChildWindows, GetClassNameW, GetCursorPos, GetWindowLongPtrW, SetWindowLongPtrW, - GWL_EXSTYLE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, + EnumChildWindows, EnumWindows, GetClassNameW, GetCursorPos, GetWindow, GetWindowLongPtrW, + GetWindowTextW, GetWindowThreadProcessId, IsIconic, IsWindowVisible, SetForegroundWindow, + SetWindowLongPtrW, ShowWindow, GWL_EXSTYLE, GW_OWNER, SW_RESTORE, WS_EX_NOACTIVATE, + WS_EX_TOOLWINDOW, }; use super::LocalTime; -use crate::island::WINDOW_LABEL; +use crate::session_window::{self, Proc}; /// File name of the Claude Code relay. pub const HOOK_EXE: &str = "coucou-hook.exe"; @@ -49,6 +60,26 @@ pub fn local_dir() -> PathBuf { base.join("Coucou") } +/// Where a saved image goes, best first: Pictures (also where OneDrive moves +/// it), then Downloads and the profile folder. The caller takes the first one +/// that exists. Read from the environment rather than the Known Folders API: a +/// relocated Pictures folder falls back to Downloads, which is fine for a PNG. +pub fn picture_dirs() -> Vec { + let mut dirs = Vec::new(); + let home = std::env::var_os("USERPROFILE").map(PathBuf::from); + if let Some(home) = &home { + dirs.push(home.join("Pictures")); + } + if let Some(onedrive) = std::env::var_os("OneDrive").map(PathBuf::from) { + dirs.push(onedrive.join("Pictures")); + } + if let Some(home) = home { + dirs.push(home.join("Downloads")); + dirs.push(home); + } + dirs +} + /// %APPDATA% and %LOCALAPPDATA% are already private to the user. pub fn ensure_private_dir(dir: &std::path::Path) -> std::io::Result<()> { std::fs::create_dir_all(dir) @@ -102,6 +133,27 @@ pub fn find_on_path(stem: &str) -> Option { None } +/// Where the Codex CLI may be, best first: %PATH% (codex.exe or npm's +/// codex.cmd), then npm's global folder and the Volta / Bun / pnpm ones. +/// Rust quotes the one fixed argument safely for a `.cmd` (see find_on_path). +pub fn codex_candidates() -> Vec { + let mut out: Vec = find_on_path("codex").into_iter().collect(); + let var = |k: &str| std::env::var_os(k).map(PathBuf::from).filter(|p| p.is_absolute()); + if let Some(appdata) = var("APPDATA") { + out.push(appdata.join("npm").join("codex.cmd")); + } + if let Some(local) = var("LOCALAPPDATA") { + out.push(local.join("Volta").join("bin").join("codex.exe")); + out.push(local.join("pnpm").join("codex.cmd")); + } + if let Some(home) = var("USERPROFILE") { + out.push(home.join(".bun").join("bin").join("codex.exe")); + out.push(home.join(".local").join("bin").join("codex.exe")); + } + out.retain(|p| p.is_file()); + out +} + // ── Who we are ──────────────────────────────────────────────────────────────── // // Named pipes share one machine-wide namespace, so the SID in the name is what @@ -145,6 +197,66 @@ pub fn current_user_sid() -> Option { } } +// The display name only feeds Mochi's greeting (identity.rs). The calls are +// declared here by hand, with their documented C signatures, so they need no +// extra `windows` crate features. +#[link(name = "secur32")] +extern "system" { + fn GetUserNameExW(name_format: i32, name_buffer: *mut u16, size: *mut u32) -> u8; +} + +#[link(name = "netapi32")] +extern "system" { + fn NetUserGetInfo(server: *const u16, user: *const u16, level: u32, buffer: *mut *mut u8) -> u32; + fn NetApiBufferFree(buffer: *mut core::ffi::c_void) -> u32; +} + +/// EXTENDED_NAME_FORMAT::NameDisplay. +const NAME_DISPLAY: i32 = 3; + +/// The account's display name ("Louis Raille"): the directory's for a domain +/// or Entra account, else the local account's "Full name", else nothing. +pub fn user_full_name() -> Option { + directory_display_name().or_else(local_full_name).filter(|n| !n.trim().is_empty()) +} + +fn directory_display_name() -> Option { + let mut buf = [0u16; 256]; + let mut size = buf.len() as u32; + // On success `size` is the length copied, without the terminating null. + let ok = unsafe { GetUserNameExW(NAME_DISPLAY, buf.as_mut_ptr(), &mut size) } != 0; + if !ok || size as usize > buf.len() { + return None; + } + String::from_utf16(&buf[..size as usize]).ok() +} + +fn local_full_name() -> Option { + let user = std::env::var("USERNAME").ok().filter(|u| !u.is_empty())?; + let wide: Vec = user.encode_utf16().chain(std::iter::once(0)).collect(); + let mut info: *mut u8 = std::ptr::null_mut(); + // Level 10 is USER_INFO_10 { name, comment, usr_comment, full_name }, four + // wide-string pointers; any user may read it about themselves. + let status = unsafe { NetUserGetInfo(std::ptr::null(), wide.as_ptr(), 10, &mut info) }; + if status != 0 || info.is_null() { + return None; + } + unsafe { + let full = *(info as *const *const u16).add(3); + let text = if full.is_null() { + None + } else { + let mut len = 0usize; + while len < 256 && *full.add(len) != 0 { + len += 1; + } + String::from_utf16(std::slice::from_raw_parts(full, len)).ok() + }; + NetApiBufferFree(info.cast()); + text + } +} + // ── Cursor ──────────────────────────────────────────────────────────────────── /// The 60 Hz poll reads the cursor and flips click-through from it. @@ -173,24 +285,22 @@ fn hwnd_of(win: &WebviewWindow) -> Option { Some(HWND(raw as *mut _)) } -/// Lets dropped files reach the app again. +/// Keeps the settings window from showing the "no drop" cursor. /// /// wry installs its drop target by walking the webview's child windows **once**, /// when the webview is created. WebView2 creates `Chrome_RenderWidgetHostHWND` /// later and registers its own target on it; being the innermost window, that one -/// wins, and since the page has no HTML5 drop handler it refuses everything — the -/// "no drop" cursor, with nothing reaching Tauri. Revoking it makes OLE fall -/// through to the target wry registered on the parent widget, which is the one -/// that feeds Tauri's drag events. +/// wins, and since the page has no HTML5 drop handler it refuses everything. +/// Revoking it makes OLE fall through to wry's target. The island is the other +/// way round: it takes drops through WebView2's own target (see webview_drop.rs), +/// so its windows are left alone. /// /// Cheap and idempotent, so it is simply re-run whenever a drag might be starting. pub fn unblock_webview_drops(app: &AppHandle) { - for label in [WINDOW_LABEL, "settings"] { - let Some(win) = app.get_webview_window(label) else { continue }; - let Some(hwnd) = hwnd_of(&win) else { continue }; - unsafe { - let _ = EnumChildWindows(Some(hwnd), Some(revoke_render_widget), LPARAM(0)); - } + let Some(win) = app.get_webview_window("settings") else { return }; + let Some(hwnd) = hwnd_of(&win) else { return }; + unsafe { + let _ = EnumChildWindows(Some(hwnd), Some(revoke_render_widget), LPARAM(0)); } } @@ -233,3 +343,199 @@ pub fn set_activating(win: &WebviewWindow, activating: bool) { /// Click-through here is the poll's WS_EX_TRANSPARENT toggle, not a region. pub fn set_input_region(_win: &WebviewWindow, _rect: Option<(f64, f64, f64, f64)>) {} + +/// The cursor poll already parks itself with the island (island.rs): nothing to do. +pub fn set_pointer_watch(_active: bool) {} + +/// `set_position` already places a Win32 window on the right display. +pub fn pin_to_monitor(_win: &WebviewWindow, _x: i32, _y: i32) {} + +// ── Session windows ("Open terminal") ───────────────────────────────────────── +// +// See session_window.rs: the relay's ancestors lead to the terminal or editor +// window a Claude Code session runs in. + +/// The process on the other end of a relay connection. +pub fn pipe_client_pid(handle: RawHandle) -> Option { + let mut pid = 0u32; + unsafe { GetNamedPipeClientProcessId(HANDLE(handle), &mut pid).ok()? }; + (pid != 0).then_some(pid) +} + +/// Every process, by ID: its parent and its executable's name. +fn process_table() -> HashMap { + let mut out = HashMap::new(); + unsafe { + let Ok(snapshot) = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) else { return out }; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut more = Process32FirstW(snapshot, &mut entry).is_ok(); + while more { + let len = entry.szExeFile.iter().position(|c| *c == 0).unwrap_or(entry.szExeFile.len()); + out.insert( + entry.th32ProcessID, + Proc { + parent: entry.th32ParentProcessID, + exe: String::from_utf16_lossy(&entry.szExeFile[..len]), + }, + ); + more = Process32NextW(snapshot, &mut entry).is_ok(); + } + let _ = CloseHandle(snapshot); + } + out +} + +/// The ancestors of a process, nearest first, below the desktop shell. +pub fn process_ancestors(pid: u32) -> Vec { + session_window::ancestors(&process_table(), pid) +} + +/// Visible top-level windows that are nobody's dialog and have a title: +/// (handle, process ID, title). +fn top_windows() -> Vec<(isize, u32, String)> { + unsafe extern "system" fn collect(hwnd: HWND, lparam: LPARAM) -> BOOL { + let list = unsafe { &mut *(lparam.0 as *mut Vec<(isize, u32, String)>) }; + unsafe { + if !IsWindowVisible(hwnd).as_bool() || GetWindow(hwnd, GW_OWNER).is_ok() { + return true.into(); + } + let mut pid = 0u32; + GetWindowThreadProcessId(hwnd, Some(&mut pid as *mut u32)); + let mut text = [0u16; 512]; + let len = GetWindowTextW(hwnd, &mut text); + if len > 0 { + list.push((hwnd.0 as isize, pid, String::from_utf16_lossy(&text[..len as usize]))); + } + } + true.into() + } + let mut list: Vec<(isize, u32, String)> = Vec::new(); + unsafe { + let _ = EnumWindows(Some(collect), LPARAM(&mut list as *mut _ as isize)); + } + list +} + +/// The first of `pids` that owns a window. +pub fn first_with_window(pids: &[u32]) -> Option { + let windows = top_windows(); + pids.iter().copied().find(|pid| windows.iter().any(|(_, owner, _)| owner == pid)) +} + +fn bring_forward(raw: isize) -> bool { + let hwnd = HWND(raw as *mut _); + unsafe { + if IsIconic(hwnd).as_bool() { + let _ = ShowWindow(hwnd, SW_RESTORE); + } + // Allowed: the click on the island that asked for this was the last input. + SetForegroundWindow(hwnd).as_bool() + } +} + +/// Brings `pid`'s window forward — the one titled after `folder` if there are several. +pub fn focus_process_window(pid: u32, folder: &str) -> bool { + let candidates: Vec<(isize, String)> = top_windows() + .into_iter() + .filter(|(_, owner, _)| *owner == pid) + .map(|(hwnd, _, title)| (hwnd, title)) + .collect(); + session_window::pick_window(&candidates, folder).is_some_and(|hwnd| bring_forward(*hwnd)) +} + +/// Brings forward the window of a running app, by executable name. +fn focus_app(exe: &str) -> bool { + let pids: Vec = process_table() + .into_iter() + .filter(|(_, p)| p.exe.eq_ignore_ascii_case(exe)) + .map(|(pid, _)| pid) + .collect(); + top_windows() + .into_iter() + .find(|(_, owner, _)| pids.contains(owner)) + .is_some_and(|(hwnd, _, _)| bring_forward(hwnd)) +} + +/// The Claude desktop app: brought forward when it runs (Claude Code's own +/// `claude.exe` has no window of its own, so it is never the one picked), +/// started from where its installer puts it otherwise. +pub fn open_claude_desktop() -> bool { + if focus_app("claude.exe") { + return true; + } + let Some(base) = std::env::var_os("LOCALAPPDATA") else { return false }; + let exe = PathBuf::from(base).join("AnthropicClaude").join("claude.exe"); + exe.is_file() && Command::new(exe).spawn().is_ok() +} + +// ── Global shortcuts ────────────────────────────────────────────────────────── + +/// RegisterHotKey works in every Windows session. +pub fn global_shortcuts_blocked() -> Option<&'static str> { + None +} + +/// The character Ctrl+Alt(+Shift)+`vk` types on one of the installed keyboard +/// layouts, if any. Windows reads Ctrl+Alt as AltGr, so registering such a +/// combination as a global shortcut would stop the user typing that character +/// (AltGr+E is € on most European layouts, AltGr+A is ą in Polish). +pub fn ctrl_alt_types(vk: u16, shift: bool) -> Option { + let mut layouts = [HKL::default(); 32]; + let count = unsafe { GetKeyboardLayoutList(Some(&mut layouts[..])) }; + let count = usize::try_from(count).unwrap_or(0).min(layouts.len()); + + let mut state = [0u8; 256]; + state[VK_CONTROL.0 as usize] = 0x80; + state[VK_MENU.0 as usize] = 0x80; + if shift { + state[VK_SHIFT.0 as usize] = 0x80; + } + for &layout in &layouts[..count] { + let scan = unsafe { MapVirtualKeyExW(u32::from(vk), MAPVK_VK_TO_VSC, Some(layout)) }; + let mut buf = [0u16; 8]; + // Flag 0x4: leave the keyboard state alone, so a dead key met here + // doesn't change what the user types next (Windows 10 1607 and later). + let n = unsafe { ToUnicodeEx(u32::from(vk), scan, &state, &mut buf, 0x4, Some(layout)) }; + let typed = match n { + // A dead key (´ ^ ¨…) is still something the user types with it. + n if n < 0 => String::from_utf16_lossy(&buf[..1]), + 0 => continue, + n => String::from_utf16_lossy(&buf[..(n as usize).min(buf.len())]), + }; + if typed.chars().any(|c| !c.is_control()) { + return Some(typed); + } + } + None +} + +// ── Desktop Mochi window ────────────────────────────────────────────────────── + +/// Windows places a window wherever it is asked, and the cursor poll is there. +pub fn desktop_mode() -> super::DesktopMode { + super::DesktopMode::Poll +} + +/// Same window styles as the island: never takes focus, never in Alt-Tab (it +/// is created without a taskbar button). +pub fn prepare_desktop_window(win: &WebviewWindow, _mode: super::DesktopMode) -> bool { + make_non_activating(win); + true +} + +/// The cursor poll toggles WS_EX_TRANSPARENT from the body hit test instead. +pub fn set_desktop_shape(_win: &WebviewWindow, _shape: super::MouseShape) {} + +/// Layer-shell only (Linux). +pub fn set_layer_margins(_win: &WebviewWindow, _x: f64, _y: f64) {} + +/// Layer-shell only (Linux). +pub fn set_layer_overlay(_win: &WebviewWindow, _on: bool) {} + +/// Layer-shell only (Linux): the logical size of the display the island is on. +pub fn layer_display(_island: &WebviewWindow, _mochi: &WebviewWindow) -> Option<(f64, f64)> { + None +} diff --git a/windows/src-tauri/src/recap.rs b/windows/src-tauri/src/recap.rs new file mode 100644 index 000000000..5e8af0e05 --- /dev/null +++ b/windows/src-tauri/src/recap.rs @@ -0,0 +1,1049 @@ +// Weekly recap history — port of RecapStore.swift. +// +// Every hook event the relay forwards also passes through here. A "turn" runs +// from a prompt (or the first tool call) to Stop, and only its counts and two +// names are kept: the agent's pill ID and the project folder's last path +// component. Never a command, a file path, file contents or a prompt. +// +// The history lives in recap.json under platform::local_dir() +// (%LOCALAPPDATA%\Coucou on Windows, ~/.local/share/coucou on Linux), keeps a +// 12-week rolling window and a hard cap on its length, and is written whole to +// a temporary file then renamed over the old one, so a crash never leaves half +// a file. Nothing in it ever leaves the machine. +// +// Aggregation (weeks, top agent, busiest day…) happens in the island +// (src/recap/summary.ts): it needs the user's time zone, which JavaScript has +// and plain Rust does not. + +use std::collections::{HashMap, HashSet}; +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, PoisonError}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::{AppHandle, Emitter, Manager, State}; + +use crate::island::WINDOW_LABEL; +use crate::platform; + +/// 12 weeks, like the Mac. +pub const WINDOW_SECS: i64 = 12 * 7 * 86_400; +/// A turn with no event for two hours is closed at its last event (agent +/// crashed, or never sent Stop) — RecapStore.pruneStale. +const STALE_SECS: i64 = 2 * 3600; +/// Hard caps, whatever the window holds: about 2 MB of JSON at most. +const MAX_TURNS: usize = 10_000; +const MAX_DECISIONS: usize = 10_000; +const MAX_DRAFTS: usize = 256; +const MAX_REQUESTS: usize = 64; +const MAX_NAME_CHARS: usize = 64; +/// A recap.json larger than this was not written by us: set it aside. +const MAX_FILE_BYTES: u64 = 16 << 20; +/// A 1080 × 1920 PNG is ~1 MB; anything far larger is not our image. +const MAX_PNG_BYTES: usize = 24 << 20; + +/// Same limits as DiffEngine.swift: beyond them, lines are counted, not diffed. +const DIFF_MAX_BYTES: usize = 200 * 1024; +const DIFF_MAX_LINES: usize = 4000; +const DIFF_MAX_CELLS: usize = 1_000_000; + +/// Tool names that run a shell command, across the agents the relay serves. +const COMMAND_TOOLS: &[&str] = &[ + "Bash", + "PowerShell", + "Execute", + "mcp__ide__executeCode", + "run_shell_command", + "shell", + "local_shell", + "exec_command", + // Antigravity, and Copilot CLI's toolName, after the relay's normaliser. + "run_command", + "bash", +]; + +// ── Persisted model ─────────────────────────────────────────────────────────── + +/// One finished turn. Times are Unix seconds. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Turn { + pub agent: String, + pub project: String, + pub start: i64, + pub end: i64, + pub files_changed: u32, + pub lines_added: u32, + pub lines_removed: u32, + pub commands_run: u32, + pub questions: u32, +} + +/// A click on Allow or Deny in the island. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Decision { + pub agent: String, + pub date: i64, + /// "allow" or "deny". + pub decision: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Prefs { + /// "Keep a history of my coding sessions" — on by default, like the Mac. + pub enabled: bool, + /// Leave the project name out of the shared image. + pub hide_projects: bool, + /// Monday (YYYY-MM-DD) of the week the recap was last shown on its own. + pub last_shown_week: String, +} + +impl Default for Prefs { + fn default() -> Self { + Self { enabled: true, hide_projects: false, last_shown_week: String::new() } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct History { + schema_version: u32, + turns: Vec, + decisions: Vec, + prefs: Prefs, +} + +impl Default for History { + fn default() -> Self { + Self { schema_version: 1, turns: Vec::new(), decisions: Vec::new(), prefs: Prefs::default() } + } +} + +/// What the island reads: the part of the history it asked for. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HistoryView { + pub turns: Vec, + pub decisions: Vec, + pub prefs: Prefs, +} + +/// A turn in progress, in memory only. The paths are only there to count +/// distinct files and are never written anywhere. +#[derive(Debug)] +struct Draft { + agent: String, + project: String, + start: i64, + last_event: i64, + paths: HashSet, + lines_added: u32, + lines_removed: u32, + commands_run: u32, + questions: u32, +} + +impl Draft { + fn into_turn(self, end: i64) -> Turn { + Turn { + agent: self.agent, + project: self.project, + start: self.start, + end: end.max(self.start), + files_changed: self.paths.len().min(u32::MAX as usize) as u32, + lines_added: self.lines_added, + lines_removed: self.lines_removed, + commands_run: self.commands_run, + questions: self.questions, + } + } +} + +// ── Store ───────────────────────────────────────────────────────────────────── + +pub struct Recap { + path: PathBuf, + history: History, + drafts: HashMap, + /// Permission request ID → agent, until the island answers it. + requests: HashMap, + /// The last image saved, so "Show in folder" never takes a path from the page. + last_saved: Option, +} + +impl Recap { + /// Reads `path`. A file that cannot be used is renamed aside, never lost. + pub fn load(path: PathBuf, now: i64) -> Self { + let history = read_history(&path); + let mut recap = Self { + path, + history, + drafts: HashMap::new(), + requests: HashMap::new(), + last_saved: None, + }; + recap.prune(now); + recap + } + + /// One hook event, as the relay delivered it. + pub fn observe(&mut self, payload: &Value, now: i64) { + if !self.history.prefs.enabled { + return; + } + self.close_stale(now); + let event = text(payload, "hook_event_name"); + let agent = agent_id(payload); + let key = session_key(payload, &agent); + + match event { + "UserPromptSubmit" => { + self.draft(&key, &agent, payload, now); + } + "PreToolUse" => { + let tool = text(payload, "tool_name"); + let draft = self.draft(&key, &agent, payload, now); + if COMMAND_TOOLS.contains(&tool) { + draft.commands_run = draft.commands_run.saturating_add(1); + } + if tool == "AskUserQuestion" { + draft.questions = draft.questions.saturating_add(1); + } + } + "PostToolUse" => { + let tool = text(payload, "tool_name"); + let input = payload.get("tool_input").unwrap_or(&Value::Null); + let change = file_change(tool, input); + let draft = self.draft(&key, &agent, payload, now); + if let Some((path, added, removed)) = change { + draft.paths.insert(path); + draft.lines_added = draft.lines_added.saturating_add(added); + draft.lines_removed = draft.lines_removed.saturating_add(removed); + } + } + // Unlike the Mac, SessionEnd closes the turn instead of dropping it: + // a session quit mid-turn still worked. + "Stop" | "StopFailure" | "Interrupt" | "SessionEnd" => { + if let Some(draft) = self.drafts.remove(&key) { + self.history.turns.push(draft.into_turn(now)); + self.prune(now); + self.save(); + } + } + _ => {} + } + } + + /// The draft for `key`, created on the first event of a turn. + fn draft(&mut self, key: &str, agent: &str, payload: &Value, now: i64) -> &mut Draft { + if !self.drafts.contains_key(key) && self.drafts.len() >= MAX_DRAFTS { + self.close_oldest_draft(); + } + let draft = self.drafts.entry(key.to_string()).or_insert_with(|| Draft { + agent: agent.to_string(), + project: project_name(text(payload, "cwd")), + start: now, + last_event: now, + paths: HashSet::new(), + lines_added: 0, + lines_removed: 0, + commands_run: 0, + questions: 0, + }); + draft.last_event = now; + draft + } + + fn close_oldest_draft(&mut self) { + let Some(key) = self + .drafts + .iter() + .min_by_key(|(_, d)| d.last_event) + .map(|(k, _)| k.clone()) + else { + return; + }; + if let Some(draft) = self.drafts.remove(&key) { + let end = draft.last_event; + self.history.turns.push(draft.into_turn(end)); + } + } + + /// RecapStore.pruneStale: turns silent for two hours count up to their + /// last event instead of being lost. + fn close_stale(&mut self, now: i64) { + let stale: Vec = self + .drafts + .iter() + .filter(|(_, d)| d.last_event < now - STALE_SECS) + .map(|(k, _)| k.clone()) + .collect(); + if stale.is_empty() { + return; + } + for key in stale { + if let Some(draft) = self.drafts.remove(&key) { + let end = draft.last_event; + self.history.turns.push(draft.into_turn(end)); + } + } + self.prune(now); + self.save(); + } + + /// A permission request reached the island: remember whose it is. + pub fn note_request(&mut self, request_id: &str, payload: &Value) { + if self.requests.len() >= MAX_REQUESTS { + self.requests.clear(); + } + self.requests.insert(request_id.to_string(), agent_id(payload)); + } + + /// The request was handed back to the terminal: nothing to record. + pub fn forget_request(&mut self, request_id: &str) { + self.requests.remove(request_id); + } + + /// A click on Allow or Deny. + pub fn record_decision(&mut self, request_id: &str, decision: &str, now: i64) { + let agent = self + .requests + .remove(request_id) + .unwrap_or_else(|| "integration_claude".to_string()); + if !self.history.prefs.enabled { + return; + } + let word = match decision { + "allow" | "always" => "allow", + "deny" => "deny", + _ => return, + }; + self.history.decisions.push(Decision { agent, date: now, decision: word.to_string() }); + self.prune(now); + self.save(); + } + + /// Everything from `since` on (Unix seconds). + pub fn view(&self, since: i64) -> HistoryView { + HistoryView { + turns: self.history.turns.iter().filter(|t| t.start >= since).cloned().collect(), + decisions: self.history.decisions.iter().filter(|d| d.date >= since).cloned().collect(), + prefs: self.history.prefs.clone(), + } + } + + pub fn prefs(&self) -> &Prefs { + &self.history.prefs + } + + pub fn set_enabled(&mut self, enabled: bool) { + self.history.prefs.enabled = enabled; + if !enabled { + self.drafts.clear(); + self.requests.clear(); + } + self.save(); + } + + pub fn set_hide_projects(&mut self, hide: bool) { + self.history.prefs.hide_projects = hide; + self.save(); + } + + /// `week` is the Monday the recap was shown for, as YYYY-MM-DD. + pub fn mark_shown(&mut self, week: &str) { + if !is_week_key(week) { + return; + } + self.history.prefs.last_shown_week = week.to_string(); + self.save(); + } + + /// Settings → General → Clear history. The preferences stay. + pub fn clear(&mut self) { + self.history.turns.clear(); + self.history.decisions.clear(); + self.drafts.clear(); + self.save(); + } + + /// 12-week window, then the hard caps (oldest first out). + fn prune(&mut self, now: i64) { + let cutoff = now - WINDOW_SECS; + self.history.turns.retain(|t| t.start >= cutoff); + self.history.decisions.retain(|d| d.date >= cutoff); + if self.history.turns.len() > MAX_TURNS { + self.history.turns.sort_by_key(|t| t.start); + let excess = self.history.turns.len() - MAX_TURNS; + self.history.turns.drain(..excess); + } + if self.history.decisions.len() > MAX_DECISIONS { + self.history.decisions.sort_by_key(|d| d.date); + let excess = self.history.decisions.len() - MAX_DECISIONS; + self.history.decisions.drain(..excess); + } + } + + fn save(&self) { + if let Err(err) = write_history(&self.path, &self.history) { + note(format!("could not save the weekly recap history: {err}")); + } + } +} + +// ── Reading and writing recap.json ──────────────────────────────────────────── + +fn read_history(path: &Path) -> History { + let too_big = std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_FILE_BYTES); + let bytes = if too_big { + None + } else { + match std::fs::read(path) { + Ok(bytes) => Some(bytes), + Err(err) if err.kind() == ErrorKind::NotFound => return History::default(), + Err(err) => { + note(format!("can't read {}: {err}", path.display())); + // Unreadable is not corrupt: leave it, and start over in memory. + // The first save will replace it, which only costs old counts. + return History::default(); + } + } + }; + if let Some(history) = bytes.and_then(|b| serde_json::from_slice::(&b).ok()) { + return history; + } + // Keep the file as recap.json.corrupt-YYYYMMDD-HHMMSS, like the Mac. + let t = platform::local_time(); + let aside = path.with_extension(format!( + "json.corrupt-{:04}{:02}{:02}-{:02}{:02}{:02}", + t.year, t.month, t.day, t.hour, t.minute, t.second + )); + match std::fs::rename(path, &aside) { + Ok(()) => note(format!("{} was not usable — kept as {}", path.display(), aside.display())), + Err(err) => note(format!("{} was not usable and could not be set aside: {err}", path.display())), + } + History::default() +} + +/// Whole file to a temporary sibling, flushed, then renamed over the old one. +fn write_history(path: &Path, history: &History) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + platform::ensure_private_dir(dir)?; + } + let json = serde_json::to_vec(history) + .map_err(|e| std::io::Error::new(ErrorKind::InvalidData, e))?; + let temp = path.with_extension(format!("json.coucou-{}", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let written = options + .open(&temp) + .and_then(|mut file| { + file.write_all(&json)?; + file.sync_all() + }) + .and_then(|()| std::fs::rename(&temp, path)); + if written.is_err() { + let _ = std::fs::remove_file(&temp); + } + written +} + +/// One line in coucou.log. Tests must never write to the real one. +fn note(message: String) { + #[cfg(not(test))] + crate::log::line(message); + #[cfg(test)] + eprintln!("{message}"); +} + +// ── Reading a hook payload ──────────────────────────────────────────────────── + +fn text<'a>(payload: &'a Value, key: &str) -> &'a str { + payload.get(key).and_then(Value::as_str).unwrap_or_default() +} + +/// Same routing as hooks.ts, so a turn counts for the pill it showed on: a +/// valid `coucou_agent` → `agent_` (every agent the relay normalises, +/// Claude Desktop included); otherwise Claude Code's own pill — Cursor's when +/// it runs in Cursor's terminal, VS Code's else. "claude" is reserved. +fn agent_id(payload: &Value) -> String { + let raw = text(payload, "coucou_agent"); + let valid = !raw.is_empty() + && raw.len() <= 24 + && raw != "claude" + && raw.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + if valid { + format!("agent_{raw}") + } else if text(payload, "term_editor") == "cursor" { + "agent_cursor".to_string() + } else { + "integration_claude".to_string() + } +} + +/// Concurrent sessions are tracked apart; one without an ID is keyed by its +/// agent and folder, as HookServer.swift does. +fn session_key(payload: &Value, agent: &str) -> String { + let id = text(payload, "session_id"); + if id.is_empty() || id == "unknown" { + format!("{agent}+{}", text(payload, "cwd")) + } else { + id.to_string() + } +} + +/// The working folder's last component — the only part of a path ever kept. +fn project_name(cwd: &str) -> String { + let trimmed = cwd.trim_end_matches(['/', '\\']); + let last = trimmed.rsplit(['/', '\\']).next().unwrap_or_default(); + clean_name(last) +} + +fn clean_name(raw: &str) -> String { + raw.chars().filter(|c| !c.is_control()).take(MAX_NAME_CHARS).collect() +} + +/// The file an Edit, MultiEdit or Write touched, with lines added and removed. +fn file_change(tool: &str, input: &Value) -> Option<(String, u32, u32)> { + let path = input.get("file_path").and_then(Value::as_str)?; + let (added, removed) = match tool { + "Edit" => { + let old = input.get("old_string").and_then(Value::as_str)?; + let new = input.get("new_string").and_then(Value::as_str)?; + line_diff(old, new) + } + "MultiEdit" => { + let edits = input.get("edits").and_then(Value::as_array)?; + edits.iter().fold((0u32, 0u32), |(a, r), edit| { + let old = edit.get("old_string").and_then(Value::as_str).unwrap_or_default(); + let new = edit.get("new_string").and_then(Value::as_str).unwrap_or_default(); + let (da, dr) = line_diff(old, new); + (a.saturating_add(da), r.saturating_add(dr)) + }) + } + "Write" => { + let content = input.get("content").and_then(Value::as_str)?; + (count(split_lines(content).len()), 0) + } + _ => return None, + }; + (added > 0 || removed > 0).then(|| (path.to_string(), added, removed)) +} + +fn count(n: usize) -> u32 { + n.min(u32::MAX as usize) as u32 +} + +/// DiffEngine.splitLines: CRLF → LF, no trailing empty line. +fn split_lines(text: &str) -> Vec<&str> { + if text.is_empty() { + return Vec::new(); + } + let mut lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect(); + if lines.last() == Some(&"") { + lines.pop(); + } + lines +} + +/// Lines added and removed between `old` and `new`, from their longest common +/// subsequence — the counts DiffEngine.fromEdit gives. Past its size limits +/// every old line counts as removed and every new one as added. +pub fn line_diff(old: &str, new: &str) -> (u32, u32) { + let a = split_lines(old); + let b = split_lines(new); + let fallback = (count(b.len()), count(a.len())); + if old.len() + new.len() > DIFF_MAX_BYTES + || a.len() + b.len() > DIFF_MAX_LINES + || a.len().saturating_mul(b.len()) > DIFF_MAX_CELLS + { + return fallback; + } + let mut prev = vec![0usize; b.len() + 1]; + let mut row = vec![0usize; b.len() + 1]; + for x in &a { + for (j, y) in b.iter().enumerate() { + row[j + 1] = if x == y { prev[j] + 1 } else { prev[j + 1].max(row[j]) }; + } + std::mem::swap(&mut prev, &mut row); + } + let lcs = prev[b.len()]; + (count(b.len() - lcs), count(a.len() - lcs)) +} + +fn is_week_key(week: &str) -> bool { + week.len() == 10 + && week.bytes().enumerate().all(|(i, b)| if i == 4 || i == 7 { b == b'-' } else { b.is_ascii_digit() }) +} + +// ── Saving the shared image ─────────────────────────────────────────────────── + +/// Standard base64, with or without a `data:…;base64,` prefix. +fn decode_base64(data: &str) -> Option> { + let body = match data.split_once(',') { + Some((head, rest)) if head.starts_with("data:") => rest, + _ => data, + }; + let mut out = Vec::with_capacity(body.len() / 4 * 3); + let mut acc = 0u32; + let mut bits = 0u32; + let mut padding = false; + for b in body.bytes() { + let v = match b { + b'A'..=b'Z' => b - b'A', + b'a'..=b'z' => b - b'a' + 26, + b'0'..=b'9' => b - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' => { + padding = true; + continue; + } + b'\r' | b'\n' => continue, + _ => return None, + }; + if padding { + return None; + } + acc = (acc << 6) | u32::from(v); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} + +const PNG_SIGNATURE: &[u8] = &[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1A, b'\n']; + +/// Writes `bytes` as `.png` in `dir`, or ` (2).png`… when that name +/// is taken. An existing file is never replaced. +fn write_unique(dir: &Path, stem: &str, bytes: &[u8]) -> std::io::Result { + for n in 1..1000 { + let name = if n == 1 { format!("{stem}.png") } else { format!("{stem} ({n}).png") }; + let path = dir.join(name); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(mut file) => { + let written = file.write_all(bytes).and_then(|()| file.sync_all()); + drop(file); + if let Err(err) = written { + let _ = std::fs::remove_file(&path); + return Err(err); + } + return Ok(path); + } + Err(err) if err.kind() == ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(std::io::Error::new(ErrorKind::AlreadyExists, "too many recap images with this name")) +} + +/// The PNG the page rendered, checked and written to the first pictures folder +/// that exists. +fn save_png(dirs: &[PathBuf], data: &str, week: &str) -> Result { + if data.len() > MAX_PNG_BYTES / 3 * 4 + 64 { + return Err(crate::i18n::t("The image is too large.")); + } + let bytes = decode_base64(data).ok_or_else(|| crate::i18n::t("The image could not be read."))?; + if !bytes.starts_with(PNG_SIGNATURE) { + return Err(crate::i18n::t("The image could not be read.")); + } + let dir = dirs + .iter() + .find(|d| d.is_dir()) + .ok_or_else(|| crate::i18n::t("No Pictures or Downloads folder to save into."))?; + let stem = if is_week_key(week) { + format!("Coucou weekly recap {week}") + } else { + "Coucou weekly recap".to_string() + }; + write_unique(dir, &stem, &bytes) + .map_err(|err| crate::i18n::tf("Could not save the image: {error}", &[("error", &err.to_string())])) +} + +// ── Tauri glue ──────────────────────────────────────────────────────────────── + +pub struct Store(Mutex); + +impl Store { + fn lock(&self) -> MutexGuard<'_, Recap> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +fn now() -> i64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0) +} + +pub fn load() -> Store { + Store(Mutex::new(Recap::load(platform::local_dir().join("recap.json"), now()))) +} + +/// Called by the relay server for every hook event. An agent starting work is +/// also one of the moments the Monday recap may show up. +pub fn observe(app: &AppHandle, payload: &Value) { + if let Some(store) = app.try_state::() { + store.lock().observe(payload, now()); + } + if matches!(text(payload, "hook_event_name"), "SessionStart" | "UserPromptSubmit") { + let _ = app.emit_to(WINDOW_LABEL, "recap-check", ()); + } +} + +pub fn note_request(app: &AppHandle, request_id: &str, payload: &Value) { + if let Some(store) = app.try_state::() { + store.lock().note_request(request_id, payload); + } +} + +pub fn forget_request(app: &AppHandle, request_id: &str) { + if let Some(store) = app.try_state::() { + store.lock().forget_request(request_id); + } +} + +pub fn record_decision(app: &AppHandle, request_id: &str, decision: &str) { + if let Some(store) = app.try_state::() { + store.lock().record_decision(request_id, decision, now()); + } +} + +#[tauri::command] +pub fn recap_history(store: State, since: i64) -> HistoryView { + store.lock().view(since) +} + +#[tauri::command] +pub fn recap_prefs(store: State) -> Prefs { + store.lock().prefs().clone() +} + +#[tauri::command] +pub fn recap_set_enabled(store: State, enabled: bool) { + store.lock().set_enabled(enabled); +} + +#[tauri::command] +pub fn recap_set_hide_projects(store: State, hide: bool) { + store.lock().set_hide_projects(hide); +} + +#[tauri::command] +pub fn recap_mark_shown(store: State, week: String) { + store.lock().mark_shown(&week); +} + +#[tauri::command] +pub fn recap_clear(store: State) { + store.lock().clear(); +} + +/// `data` is the canvas's PNG as a data URL. Returns where it was written. +#[tauri::command] +pub fn recap_save_png(store: State, data: String, week: String) -> Result { + let path = save_png(&platform::picture_dirs(), &data, &week)?; + let shown = path.to_string_lossy().to_string(); + store.lock().last_saved = Some(path); + Ok(shown) +} + +/// Opens the folder of the image saved last — never a path from the page. +#[tauri::command] +pub fn recap_reveal_saved(store: State) { + let dir = store.lock().last_saved.as_ref().and_then(|p| p.parent().map(Path::to_path_buf)); + if let Some(dir) = dir { + platform::reveal_folder(&dir.to_string_lossy()); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const HOUR: i64 = 3600; + /// Monday 5 October 2026, 00:00 UTC — the exact zone does not matter here. + const T0: i64 = 1_791_158_400; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("coucou-recap-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn event(name: &str, session: &str, extra: Value) -> Value { + let mut v = json!({ "hook_event_name": name, "session_id": session, "cwd": "/home/me/code/coucou" }); + if let (Some(obj), Value::Object(more)) = (v.as_object_mut(), extra) { + obj.extend(more); + } + v + } + + fn on_disk(path: &Path) -> Value { + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() + } + + #[test] + fn a_turn_is_counted_from_prompt_to_stop() { + let dir = scratch("turn"); + let file = dir.join("recap.json"); + let mut r = Recap::load(file.clone(), T0); + r.observe(&event("UserPromptSubmit", "s1", json!({ "prompt": "secret plan" })), T0); + r.observe(&event("PreToolUse", "s1", json!({ "tool_name": "Bash", "tool_input": { "command": "rm -rf /tmp/x" } })), T0 + 10); + r.observe(&event("PreToolUse", "s1", json!({ "tool_name": "Read" })), T0 + 20); + r.observe(&event("PreToolUse", "s1", json!({ "tool_name": "AskUserQuestion" })), T0 + 25); + r.observe(&event("PostToolUse", "s1", json!({ "tool_name": "Edit", "tool_input": { + "file_path": "/home/me/code/coucou/a.rs", "old_string": "a\nb\nc", "new_string": "a\nB\nc\nd" } })), T0 + 30); + r.observe(&event("PostToolUse", "s1", json!({ "tool_name": "Write", "tool_input": { + "file_path": "/home/me/code/coucou/b.rs", "content": "one\ntwo\nthree\n" } })), T0 + 40); + r.observe(&event("PostToolUse", "s1", json!({ "tool_name": "Edit", "tool_input": { + "file_path": "/home/me/code/coucou/a.rs", "old_string": "x", "new_string": "y" } })), T0 + 50); + r.observe(&event("Stop", "s1", json!({})), T0 + HOUR); + + let view = r.view(0); + assert_eq!(view.turns, vec![Turn { + agent: "integration_claude".into(), + project: "coucou".into(), + start: T0, + end: T0 + HOUR, + files_changed: 2, + lines_added: 2 + 3 + 1, + lines_removed: 1 + 1, + commands_run: 1, + questions: 1, + }]); + + // Only counts and names reach the disk. + let written = std::fs::read_to_string(&file).unwrap(); + for secret in ["secret plan", "rm -rf", "a.rs", "/home/me", "one\\ntwo"] { + assert!(!written.contains(secret), "{secret} must not be stored"); + } + assert_eq!(on_disk(&file)["turns"][0]["project"], "coucou"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn concurrent_sessions_and_agents_are_kept_apart() { + let dir = scratch("sessions"); + let mut r = Recap::load(dir.join("recap.json"), T0); + r.observe(&event("UserPromptSubmit", "a", json!({})), T0); + r.observe(&event("UserPromptSubmit", "b", json!({ "coucou_agent": "gemini", "cwd": "C:\\Users\\me\\side-project\\" })), T0 + 60); + r.observe(&event("PreToolUse", "b", json!({ "coucou_agent": "gemini", "tool_name": "run_shell_command" })), T0 + 70); + r.observe(&event("Stop", "a", json!({})), T0 + 120); + r.observe(&event("Stop", "b", json!({ "coucou_agent": "gemini" })), T0 + 180); + let turns = r.view(0).turns; + assert_eq!(turns.len(), 2); + assert_eq!((turns[0].agent.as_str(), turns[0].commands_run), ("integration_claude", 0)); + assert_eq!(turns[1].agent, "agent_gemini"); + assert_eq!(turns[1].project, "side-project"); + assert_eq!(turns[1].commands_run, 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn invalid_or_reserved_agent_tags_fall_back_to_claude_code() { + for tag in ["claude", "Gemini", "has space", "a-very-long-agent-name-over-24"] { + assert_eq!(agent_id(&json!({ "coucou_agent": tag })), "integration_claude", "{tag}"); + } + assert_eq!(agent_id(&json!({ "coucou_agent": "codex" })), "agent_codex"); + } + + #[test] + fn a_turn_counts_for_the_pill_it_showed_on() { + // Claude Code in Cursor's terminal is the Cursor pill, as in hooks.ts. + assert_eq!(agent_id(&json!({ "term_editor": "cursor" })), "agent_cursor"); + // An explicit agent wins over the terminal it runs in. + assert_eq!(agent_id(&json!({ "coucou_agent": "claude-desktop", "term_editor": "cursor" })), "agent_claude-desktop"); + assert_eq!(agent_id(&json!({ "coucou_agent": "copilot" })), "agent_copilot"); + assert_eq!(agent_id(&json!({})), "integration_claude"); + } + + #[test] + fn a_turn_silent_for_two_hours_ends_at_its_last_event() { + let dir = scratch("stale"); + let mut r = Recap::load(dir.join("recap.json"), T0); + r.observe(&event("UserPromptSubmit", "s1", json!({})), T0); + r.observe(&event("PreToolUse", "s1", json!({ "tool_name": "Bash" })), T0 + 600); + // Next event, from another session, three hours later. + r.observe(&event("UserPromptSubmit", "s2", json!({})), T0 + 3 * HOUR); + let turns = r.view(0).turns; + assert_eq!(turns.len(), 1); + assert_eq!((turns[0].start, turns[0].end), (T0, T0 + 600)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn decisions_are_recorded_with_the_agent_that_asked() { + let dir = scratch("decisions"); + let mut r = Recap::load(dir.join("recap.json"), T0); + r.note_request("r1", &json!({ "coucou_agent": "codex" })); + r.record_decision("r1", "always", T0); + r.record_decision("unknown", "deny", T0 + 1); + r.record_decision("r3", "maybe", T0 + 2); + r.note_request("r4", &json!({})); + r.forget_request("r4"); + let d = r.view(0).decisions; + assert_eq!(d.len(), 2); + assert_eq!((d[0].agent.as_str(), d[0].decision.as_str()), ("agent_codex", "allow")); + assert_eq!((d[1].agent.as_str(), d[1].decision.as_str()), ("integration_claude", "deny")); + assert!(r.requests.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn twelve_weeks_are_kept_and_older_entries_pruned() { + let dir = scratch("prune"); + let file = dir.join("recap.json"); + let mut r = Recap::load(file.clone(), T0); + let old = T0 - WINDOW_SECS - 1; + let edge = T0 - WINDOW_SECS; + for start in [old, edge, T0 - HOUR] { + r.history.turns.push(Turn { + agent: "integration_claude".into(), project: "p".into(), start, end: start + 60, + files_changed: 0, lines_added: 0, lines_removed: 0, commands_run: 0, questions: 0, + }); + } + r.history.decisions.push(Decision { agent: "integration_claude".into(), date: old, decision: "allow".into() }); + r.prune(T0); + r.save(); + assert_eq!(r.view(0).turns.iter().map(|t| t.start).collect::>(), [edge, T0 - HOUR]); + assert!(r.view(0).decisions.is_empty()); + // Loading later prunes again. + let later = Recap::load(file, T0 + 2 * HOUR); + assert_eq!(later.view(0).turns.len(), 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_history_never_grows_past_its_cap() { + let dir = scratch("cap"); + let mut r = Recap::load(dir.join("recap.json"), T0); + for i in 0..(MAX_TURNS as i64 + 5) { + r.history.turns.push(Turn { + agent: "a".into(), project: "p".into(), start: T0 - i, end: T0, + files_changed: 0, lines_added: 0, lines_removed: 0, commands_run: 0, questions: 0, + }); + } + r.prune(T0); + assert_eq!(r.history.turns.len(), MAX_TURNS); + // The oldest went. + assert_eq!(r.history.turns.first().unwrap().start, T0 - MAX_TURNS as i64 + 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn names_are_cut_short_and_cleaned() { + let long = "x".repeat(200); + assert_eq!(project_name(&format!("/a/{long}")).chars().count(), MAX_NAME_CHARS); + assert_eq!(project_name("/a/b\u{7}c/"), "bc"); + assert_eq!(project_name(""), ""); + assert_eq!(project_name("C:\\work\\korus"), "korus"); + } + + #[test] + fn line_counts_match_the_diff_engine() { + assert_eq!(line_diff("a\nb\nc", "a\nb\nc"), (0, 0)); + assert_eq!(line_diff("a\nb\nc", "a\nx\nc"), (1, 1)); + assert_eq!(line_diff("", "a\nb\n"), (2, 0)); + assert_eq!(line_diff("a\r\nb\r\n", "a\nb\nc\n"), (1, 0)); + // Too big to diff: everything counts. + let big_old = "l\n".repeat(3000); + let big_new = "l\n".repeat(1500); + assert_eq!(line_diff(&big_old, &big_new), (1500, 3000)); + assert_eq!(file_change("Read", &json!({ "file_path": "/x" })), None); + assert_eq!(file_change("Edit", &json!({ "file_path": "/x", "old_string": "a", "new_string": "a" })), None); + let multi = json!({ "file_path": "/x", "edits": [ + { "old_string": "a", "new_string": "b" }, { "old_string": "", "new_string": "c\nd" } ] }); + assert_eq!(file_change("MultiEdit", &multi), Some(("/x".into(), 3, 1))); + } + + #[test] + fn a_disabled_history_records_nothing_and_clear_keeps_the_prefs() { + let dir = scratch("disabled"); + let file = dir.join("recap.json"); + let mut r = Recap::load(file.clone(), T0); + r.observe(&event("UserPromptSubmit", "s", json!({})), T0); + r.observe(&event("Stop", "s", json!({})), T0 + 60); + r.set_hide_projects(true); + r.mark_shown("2026-10-05"); + r.mark_shown("not a week"); + r.clear(); + assert!(r.view(0).turns.is_empty()); + let reloaded = Recap::load(file.clone(), T0); + assert_eq!(reloaded.prefs(), &Prefs { enabled: true, hide_projects: true, last_shown_week: "2026-10-05".into() }); + + r.set_enabled(false); + r.observe(&event("UserPromptSubmit", "s", json!({})), T0); + r.observe(&event("Stop", "s", json!({})), T0 + 60); + r.note_request("r", &json!({})); + r.record_decision("r", "allow", T0); + assert!(r.view(0).turns.is_empty() && r.view(0).decisions.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_corrupt_file_is_set_aside_and_writes_leave_nothing_behind() { + let dir = scratch("corrupt"); + let file = dir.join("recap.json"); + std::fs::write(&file, b"{ not json").unwrap(); + let mut r = Recap::load(file.clone(), T0); + assert!(r.view(0).turns.is_empty()); + let names = |d: &Path| -> Vec { + let mut n: Vec = std::fs::read_dir(d).unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect(); + n.sort(); + n + }; + let aside = names(&dir); + assert_eq!(aside.len(), 1); + assert!(aside[0].starts_with("recap.json.corrupt-"), "{aside:?}"); + assert_eq!(std::fs::read(dir.join(&aside[0])).unwrap(), b"{ not json"); + + r.observe(&event("UserPromptSubmit", "s", json!({})), T0); + r.observe(&event("Stop", "s", json!({})), T0 + 60); + let mut expected = vec!["recap.json".to_string(), aside[0].clone()]; + expected.sort(); + assert_eq!(names(&dir), expected); + assert_eq!(on_disk(&file)["schemaVersion"], 1); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn base64_round_trips_and_rejects_junk() { + assert_eq!(decode_base64("data:image/png;base64,aGVsbG8=").unwrap(), b"hello"); + assert_eq!(decode_base64("aGk=").unwrap(), b"hi"); + assert_eq!(decode_base64("YWJj").unwrap(), b"abc"); + assert!(decode_base64("a$b").is_none()); + assert!(decode_base64("aGk=aGk=").is_none()); + } + + #[test] + fn a_saved_image_never_replaces_an_existing_file() { + let dir = scratch("png"); + let missing = dir.join("missing"); + let png = "data:image/png;base64,iVBORw0KGgo="; + let first = save_png(&[missing.clone(), dir.clone()], png, "2026-10-05").unwrap(); + let second = save_png(&[dir.clone()], png, "2026-10-05").unwrap(); + assert_eq!(first, dir.join("Coucou weekly recap 2026-10-05.png")); + assert_eq!(second, dir.join("Coucou weekly recap 2026-10-05 (2).png")); + // The week is a date or nothing: no path tricks through it. + let third = save_png(&[dir.clone()], png, "../../x").unwrap(); + assert_eq!(third, dir.join("Coucou weekly recap.png")); + // Not a PNG: refused. + assert!(save_png(&[dir.clone()], "data:image/png;base64,aGVsbG8=", "").is_err()); + assert!(save_png(&[missing], png, "").is_err()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/windows/src-tauri/src/secrets.rs b/windows/src-tauri/src/secrets.rs index 5c37b6529..7b7a76717 100644 --- a/windows/src-tauri/src/secrets.rs +++ b/windows/src-tauri/src/secrets.rs @@ -8,6 +8,10 @@ const SERVICE: &str = "fr.louisraille.coucou"; /// Every key Coucou may store. Anything outside this list is refused. pub const KNOWN_KEYS: &[&str] = &[ "anthropic-api-key", + "openai-api-key", + "google-api-key", + "openrouter-api-key", + "openai-compatible-key", "n8n-url", "n8n-api-key", "vercel-token", diff --git a/windows/src-tauri/src/session_window.rs b/windows/src-tauri/src/session_window.rs new file mode 100644 index 000000000..8d094e1ac --- /dev/null +++ b/windows/src-tauri/src/session_window.rs @@ -0,0 +1,233 @@ +// "Open terminal" brings the window a Claude Code session runs in to the front, +// instead of opening its folder in VS Code (Mac 0.1.0: jump to the terminal). +// +// The relay is a grandchild of Claude Code, which runs in a shell, which runs +// in the terminal or editor whose window we want. When the relay connects, its +// process is still alive, so walking up from it finds that window's process. +// What is found is kept per session ID, here in memory only, and looked up when +// the button is clicked. Where nothing is found — a classic console, whose +// window belongs to a conhost that is no ancestor, or Linux — the island falls +// back to VS Code as before. +// +// The OS calls live in platform/; this file is the logic, tested on fixtures. + +// The process-tree half only runs on Windows; Linux keeps the fallback. +#![cfg_attr(not(windows), allow(dead_code))] + +use std::collections::HashMap; +use std::sync::Mutex; + +/// One process as a snapshot lists it. +#[derive(Debug, Clone)] +pub struct Proc { + pub parent: u32, + /// The executable's file name, as the snapshot gives it (`Code.exe`). + pub exe: String, +} + +/// Never the window of a session: the desktop shell and the services that sit +/// at the top of every process tree. Reaching one of them means the terminal +/// window was not an ancestor (a classic console window belongs to conhost). +const TREE_TOPS: &[&str] = &[ + "explorer.exe", "services.exe", "wininit.exe", "winlogon.exe", "svchost.exe", + "smss.exe", "csrss.exe", "system", "sihost.exe", "userinit.exe", +]; + +/// How far up we look. A session sits a handful of levels below its window. +const MAX_DEPTH: usize = 16; + +/// The ancestors of `start`, nearest first, stopping before the top of the +/// tree. `start` itself — the relay — is not included. A loop in the parent +/// links (a parent ID reused by a newer process) ends the walk. +pub fn ancestors(procs: &HashMap, start: u32) -> Vec { + let mut out = Vec::new(); + let mut seen = vec![start]; + let mut current = start; + while out.len() < MAX_DEPTH { + let Some(proc) = procs.get(¤t) else { break }; + let parent = proc.parent; + if parent == 0 || seen.contains(&parent) { + break; + } + let Some(up) = procs.get(&parent) else { break }; + if TREE_TOPS.contains(&up.exe.to_ascii_lowercase().as_str()) { + break; + } + out.push(parent); + seen.push(parent); + current = parent; + } + out +} + +/// The nearest ancestor that owns a window: the terminal or the editor. The +/// Windows side walks `ancestors` itself (platform/windows.rs); this states the +/// rule the tests pin down. +#[cfg(test)] +pub fn window_owner(procs: &HashMap, start: u32, has_window: impl Fn(u32) -> bool) -> Option { + ancestors(procs, start).into_iter().find(|pid| has_window(*pid)) +} + +/// Which of a process's windows to bring forward: the one whose title names +/// the session's folder (an editor with several projects open), else the first. +pub fn pick_window<'a, W>(windows: &'a [(W, String)], folder: &str) -> Option<&'a W> { + let folder = folder.to_lowercase(); + let named = (!folder.is_empty()) + .then(|| windows.iter().find(|(_, title)| title.to_lowercase().contains(&folder))) + .flatten(); + named.or_else(|| windows.first()).map(|(w, _)| w) +} + +/// The last folder name of a path, either separator. +pub fn folder_name(path: &str) -> &str { + path.trim_end_matches(['/', '\\']).rsplit(['/', '\\']).next().unwrap_or_default() +} + +// ── Sessions seen so far ────────────────────────────────────────────────────── + +/// Session ID → the process that owns its window. Small: an old session is +/// dropped once there are more than this many. +const MAX_SESSIONS: usize = 64; + +static SESSIONS: Mutex> = Mutex::new(Vec::new()); + +/// Remembered for a session whose window could not be found, so it is not +/// looked for on every event. No process has this ID. +pub const NO_WINDOW: u32 = 0; + +/// A session ID arrives in a hook payload: only what Claude Code sends (a +/// UUID) is kept, at most 128 characters. +fn valid_session(id: &str) -> bool { + !id.is_empty() && id.len() <= 128 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +pub fn remember(session: &str, owner: u32) { + if !valid_session(session) { + return; + } + let mut list = SESSIONS.lock().unwrap_or_else(|e| e.into_inner()); + list.retain(|(s, _)| s != session); + list.push((session.to_string(), owner)); + let excess = list.len().saturating_sub(MAX_SESSIONS); + list.drain(..excess); +} + +/// True once the session has been looked at, window found or not. +pub fn known(session: &str) -> bool { + let list = SESSIONS.lock().unwrap_or_else(|e| e.into_inner()); + list.iter().any(|(s, _)| s == session) +} + +/// The process owning the session's window, if one was found. +pub fn lookup(session: &str) -> Option { + let list = SESSIONS.lock().unwrap_or_else(|e| e.into_inner()); + list.iter().find(|(s, _)| s == session).map(|(_, pid)| *pid).filter(|pid| *pid != NO_WINDOW) +} + +pub fn forget(session: &str) { + SESSIONS.lock().unwrap_or_else(|e| e.into_inner()).retain(|(s, _)| s != session); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tree(entries: &[(u32, u32, &str)]) -> HashMap { + entries + .iter() + .map(|(pid, parent, exe)| (*pid, Proc { parent: *parent, exe: exe.to_string() })) + .collect() + } + + #[test] + fn windows_terminal_is_found_above_the_shells() { + // WindowsTerminal → pwsh → claude (node) → bash → coucou-hook + let procs = tree(&[ + (4, 0, "System"), + (100, 4, "explorer.exe"), + (200, 100, "WindowsTerminal.exe"), + (300, 200, "pwsh.exe"), + (400, 300, "node.exe"), + (500, 400, "bash.exe"), + (600, 500, "coucou-hook.exe"), + ]); + assert_eq!(ancestors(&procs, 600), [500, 400, 300, 200]); + assert_eq!(window_owner(&procs, 600, |pid| pid == 200 || pid == 100), Some(200)); + } + + #[test] + fn vs_code_is_found_through_its_pty_host() { + let procs = tree(&[ + (100, 1, "explorer.exe"), + (210, 100, "Code.exe"), + (220, 210, "Code.exe"), + (300, 220, "powershell.exe"), + (400, 300, "node.exe"), + (600, 400, "coucou-hook.exe"), + ]); + assert_eq!(window_owner(&procs, 600, |pid| pid == 210), Some(210)); + } + + #[test] + fn a_classic_console_finds_nothing_rather_than_the_desktop() { + // cmd.exe's window belongs to conhost, which is not an ancestor; the + // walk must stop at explorer instead of picking the taskbar. + let procs = tree(&[ + (100, 1, "explorer.exe"), + (300, 100, "cmd.exe"), + (400, 300, "node.exe"), + (600, 400, "coucou-hook.exe"), + ]); + assert_eq!(window_owner(&procs, 600, |pid| pid == 100), None); + } + + #[test] + fn a_relay_already_gone_or_a_loop_ends_the_walk() { + assert!(ancestors(&tree(&[]), 600).is_empty()); + let looped = tree(&[(1, 2, "a.exe"), (2, 1, "b.exe"), (3, 1, "coucou-hook.exe")]); + assert_eq!(ancestors(&looped, 3), [1, 2]); + let deep: Vec<(u32, u32, &str)> = (1..100).map(|i| (i, i + 1, "x.exe")).collect(); + assert_eq!(ancestors(&tree(&deep), 1).len(), MAX_DEPTH); + } + + #[test] + fn the_window_named_after_the_project_wins() { + let windows = vec![ + (1, "notes — Visual Studio Code".to_string()), + (2, "app.ts — coucou — Visual Studio Code".to_string()), + ]; + assert_eq!(pick_window(&windows, "Coucou"), Some(&2)); + assert_eq!(pick_window(&windows, "other"), Some(&1)); + assert_eq!(pick_window(&windows, ""), Some(&1)); + assert_eq!(pick_window::(&[], "x"), None); + assert_eq!(folder_name(r"C:\Users\me\coucou\"), "coucou"); + assert_eq!(folder_name("/home/me/proj"), "proj"); + } + + #[test] + fn sessions_are_remembered_by_id_and_only_plausible_ids_are_kept() { + remember("s-test-1", 42); + remember("s-test-1", 43); + assert_eq!(lookup("s-test-1"), Some(43)); + remember("not a session/../x", 7); + assert!(!known("not a session/../x")); + forget("s-test-1"); + assert!(!known("s-test-1")); + + // A session without a window is settled, with nothing to show. + remember("s-test-2", NO_WINDOW); + assert!(known("s-test-2")); + assert_eq!(lookup("s-test-2"), None); + forget("s-test-2"); + + // Only the latest sessions are kept. (One test: the list is shared.) + for i in 0..(MAX_SESSIONS + 5) { + remember(&format!("s-cap-{i}"), 1); + } + assert!(!known("s-cap-0")); + assert!(known(&format!("s-cap-{}", MAX_SESSIONS + 4))); + for i in 0..(MAX_SESSIONS + 5) { + forget(&format!("s-cap-{i}")); + } + } +} diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 17f63e1c1..0d5c84561 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use std::collections::BTreeMap; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, PoisonError}; @@ -17,12 +18,66 @@ pub struct Settings { pub auto_close_interval: f64, pub absence_interval: f64, pub active_integrations: Vec, + /// The always-on workspace pill (src/core/pills.ts checks it is one). + pub main_pill: String, /// "primary" = the main display, "cursor" = whichever display the mouse is on. pub screen: String, pub autostart: bool, pub hooks_installed: bool, /// Claude model used by the chat. Changeable in the settings window. pub model: String, + /// Show the Claude plan pill (5 h and weekly limits) in the island's header. + /// Off until the user turns it on, so the header stays as it shipped. + pub show_plan_in_notch: bool, + /// Coucou's status line relay is the one in Claude Code's settings.json. + /// Like `hooks_installed`, the real state wins at launch over what was stored. + pub plan_relay_installed: bool, + /// Show the Codex plan pill (5 h / weekly limits from `codex app-server`). + /// Off by default; nothing is installed for it. + pub show_codex_plan_in_notch: bool, + /// Who the chat talks to: "anthropic", a cloud provider of + /// openai_compat.rs ("openai", "google", "openrouter"), or a model server + /// of local_chat.rs ("ollama", "lmstudio", "custom"). Picked in the chat view. + pub chat_provider: String, + /// The model picked for each provider other than Anthropic (whose model is + /// `model`), by provider id. + pub chat_models: BTreeMap, + /// Addresses of the model servers once connected; empty means not connected. + pub ollama_url: String, + pub lmstudio_url: String, + /// Any other OpenAI-compatible server; its key, if any, is in the keychain. + pub custom_url: String, + /// Global shortcuts the user changed, by action id; the others keep their + /// default (see shortcuts.rs). + pub shortcuts: crate::shortcuts::Bindings, + /// Mochi's outfit, picked in the wardrobe: "auto" (dresses for the + /// season), "none" or an outfit id — the Mac's raw values. The island reads + /// anything it doesn't know as "auto", so the value is stored as it comes. + pub mochi_outfit: String, + /// Interface language: "" follows the system, else one of i18n::LANGUAGES + /// ("fr", "pt-BR", "zh-Hans"…). Kept as it comes, like `mochi_outfit`: a + /// code this build doesn't know reads as "". + pub language: String, + /// Mochi on the desktop: whether he lives there, and his spot. Owned by + /// the Rust side (desktop.rs) — what a webview sends back is ignored. + pub desktop_mochi: DesktopMochiPref, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct DesktopMochiPref { + /// He was on the desktop when the app quit: he flies back out at launch. + pub on_desktop: bool, + /// Top-left corner of his window where the user last left him. + pub spot: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DesktopSpot { + pub x: f64, + pub y: f64, + /// What x and y are measured in (`DesktopMode::space`). + pub space: String, } fn default_model() -> String { @@ -42,10 +97,23 @@ impl Default for Settings { "integration_vercel".into(), "integration_github".into(), ], + main_pill: "integration_claude".into(), screen: "primary".into(), autostart: false, hooks_installed: false, model: default_model(), + show_plan_in_notch: false, + plan_relay_installed: false, + show_codex_plan_in_notch: false, + chat_provider: crate::chat::ANTHROPIC.into(), + chat_models: BTreeMap::new(), + ollama_url: String::new(), + lmstudio_url: String::new(), + custom_url: String::new(), + shortcuts: Default::default(), + mochi_outfit: "auto".into(), + language: String::new(), + desktop_mochi: DesktopMochiPref::default(), } } } @@ -303,10 +371,23 @@ mod tests { "autoCloseInterval": 30.0, "absenceInterval": 60.0, "activeIntegrations": ["integration_notion"], + "mainPill": "agent_cursor", "screen": "cursor", "autostart": true, "hooksInstalled": true, - "model": "some-model" + "model": "some-model", + "showPlanInNotch": true, + "planRelayInstalled": true, + "showCodexPlanInNotch": true, + "chatProvider": "ollama", + "chatModels": { "ollama": "llama3.2", "openai": "gpt-x" }, + "ollamaUrl": "http://127.0.0.1:11434", + "lmstudioUrl": "http://127.0.0.1:1234", + "customUrl": "https://llm.example.com", + "shortcuts": { "openChat": { "keys": "Ctrl+Shift+K", "enabled": false } }, + "mochiOutfit": "witchHat", + "language": "pt-BR", + "desktopMochi": { "onDesktop": true, "spot": { "x": 1500.5, "y": -300.0, "space": "screen" } } }"#; fn custom() -> Value { @@ -396,6 +477,49 @@ mod tests { assert_eq!(loaded, expected); } + #[test] + fn a_file_from_before_the_wardrobe_dresses_mochi_for_the_seasons() { + let loaded = parse(&custom_with("mochiOutfit", None)).unwrap(); + assert_eq!(loaded.mochi_outfit, "auto"); + assert_eq!(loaded.model, "some-model"); + assert!(!loaded.sound_enabled); + } + + #[test] + fn a_file_from_before_the_desktop_mochi_keeps_him_in_the_island() { + let loaded = parse(&custom_with("desktopMochi", None)).unwrap(); + assert_eq!(loaded.desktop_mochi, DesktopMochiPref::default()); + assert!(!loaded.desktop_mochi.on_desktop); + assert_eq!(loaded.mochi_outfit, "witchHat"); + } + + #[test] + fn a_half_written_desktop_spot_costs_only_the_spot() { + let loaded = parse(&custom_with( + "desktopMochi", + Some(json!({ "onDesktop": true, "spot": { "x": "left" } })), + )) + .unwrap(); + // The whole field falls back, and nothing else does. + assert_eq!(loaded.desktop_mochi, DesktopMochiPref::default()); + assert_eq!(loaded.model, "some-model"); + + let loaded = parse(&custom_with("desktopMochi", Some(json!({ "onDesktop": true })))).unwrap(); + assert!(loaded.desktop_mochi.on_desktop); + assert_eq!(loaded.desktop_mochi.spot, None); + } + + #[test] + fn an_outfit_this_build_does_not_know_is_kept_as_written() { + // A newer build may add outfits: the island shows "auto" for it, but + // the choice must survive a save made by this one. + let loaded = parse(&custom_with("mochiOutfit", Some(json!("topHat")))).unwrap(); + assert_eq!(loaded.mochi_outfit, "topHat"); + // The language too: "" (follow the system) when absent, as it comes otherwise. + assert_eq!(parse(&custom_with("language", None)).unwrap().language, ""); + assert_eq!(parse(&custom_with("language", Some(json!("xx")))).unwrap().language, "xx"); + } + #[test] fn a_missing_field_takes_its_default_and_the_rest_are_kept() { for key in custom().as_object().unwrap().keys() { @@ -413,7 +537,9 @@ mod tests { ("soundVolume", json!("loud")), ("soundEnabled", json!(1)), ("activeIntegrations", json!("integration_n8n")), + ("mainPill", json!(["agent_cursor"])), ("screen", Value::Null), + ("shortcuts", json!("Ctrl+Alt+A")), ] { let loaded = parse(&custom_with(key, Some(wrong))) .unwrap_or_else(|| panic!("a file with a bad {key} was thrown away")); @@ -628,10 +754,23 @@ mod tests { "autoCloseInterval", "absenceInterval", "activeIntegrations", + "mainPill", "screen", "autostart", "hooksInstalled", "model", + "showPlanInNotch", + "planRelayInstalled", + "showCodexPlanInNotch", + "chatProvider", + "chatModels", + "ollamaUrl", + "lmstudioUrl", + "customUrl", + "shortcuts", + "mochiOutfit", + "language", + "desktopMochi", ] ); let _ = std::fs::remove_dir_all(&dir); diff --git a/windows/src-tauri/src/shortcuts.rs b/windows/src-tauri/src/shortcuts.rs new file mode 100644 index 000000000..a47c7c4e6 --- /dev/null +++ b/windows/src-tauri/src/shortcuts.rs @@ -0,0 +1,551 @@ +// Global keyboard shortcuts — port of HotKeyCenter.swift + ShortcutLogic.swift. +// +// The shortcuts are registered from Rust through tauri-plugin-global-shortcut +// (RegisterHotKey on Windows, XGrabKey on X11). A press is handed to the island +// as a `shortcut` event carrying the action id; the island does the rest, the +// same way it handles the tray menu. The wardrobe is the exception: it goes out +// as its own `open-wardrobe` event, which the wardrobe view listens to. +// +// Default keys. The Mac uses ⌃⌥ + a letter. On Windows Ctrl+Alt *is* AltGr on +// most European layouts, so a global Ctrl+Alt+E would swallow every € typed on +// a French or German keyboard. The defaults below were checked against the +// AltGr layer of the French (AZERTY), German (QWERTZ), Spanish, Italian, +// Portuguese and Brazilian (ABNT2) layouts, which between them put a character +// on E, Q, M, W, C and on every digit and most punctuation keys: +// +// Ctrl+Alt+Space open the chat Ctrl+Alt+→ / ← next / previous pill +// Ctrl+Alt+A waiting permission Ctrl+Alt+S mute Mochi +// Ctrl+Alt+T open the terminal Ctrl+Alt+G wardrobe +// Ctrl+Alt+N open / close the island (off by default, as on the Mac) +// +// ⌃⌥[ and ⌃⌥] became the arrows (brackets are AltGr characters almost +// everywhere) and ⌃⌥M became S (AltGr+M is µ in German). Layouts outside that +// list can still clash — Polish puts ą on AltGr+A and ś on AltGr+S — so on +// Windows every Ctrl+Alt combination is also checked against the keyboard +// layouts actually installed (platform::ctrl_alt_types) and left unregistered, +// flagged in Settings, when it types a character. The same table lives in +// src/core/shortcuts.ts; tests/shortcuts.test.mjs keeps the two in step. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::str::FromStr; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager, Runtime}; +use tauri_plugin_global_shortcut::{Code, GlobalShortcut, Modifiers, Shortcut, ShortcutState}; + +use crate::island::WINDOW_LABEL; + +/// One global action, as on the Mac (`ShortcutAction`). The ids are the Mac's +/// raw values and are stored in settings.json: never rename one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActionDef { + pub id: &'static str, + pub default_keys: &'static str, + pub enabled_by_default: bool, + /// False for the Mac actions this version cannot do yet. Their ids and + /// defaults stay reserved so a later port lands on the same keys. + pub ported: bool, +} + +const fn action(id: &'static str, keys: &'static str, on: bool, ported: bool) -> ActionDef { + ActionDef { id, default_keys: keys, enabled_by_default: on, ported } +} + +/// Same order as `ShortcutAction.allCases`. +pub const ACTIONS: &[ActionDef] = &[ + action("toggleIsland", "Ctrl+Alt+N", false, true), + action("openChat", "Ctrl+Alt+Space", true, true), + action("goToAlert", "Ctrl+Alt+A", true, true), + action("jumpToTerminal", "Ctrl+Alt+T", true, true), + // Dragging Mochi onto a window is not in this version. + action("attachFrontWindow", "Ctrl+Alt+F", true, false), + action("nextPill", "Ctrl+Alt+Right", true, true), + action("prevPill", "Ctrl+Alt+Left", true, true), + action("muteToggle", "Ctrl+Alt+S", true, true), + // Mochi on the desktop is not in this version. + action("desktopToggle", "Ctrl+Alt+D", true, false), + action("wardrobeToggle", "Ctrl+Alt+G", true, true), +]; + +pub fn find(id: &str) -> Option<&'static ActionDef> { + ACTIONS.iter().find(|a| a.id == id) +} + +/// What the user chose for one action. An action missing from settings.json +/// uses its default; `keys` empty means no key at all. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Binding { + pub keys: String, + pub enabled: bool, +} + +impl Default for Binding { + fn default() -> Self { + Self { keys: String::new(), enabled: true } + } +} + +/// `settings.shortcuts`: only the actions the user changed. +pub type Bindings = BTreeMap; + +/// The binding in force for `def`: the stored one, or the default. +pub fn effective(def: &ActionDef, stored: &Bindings) -> Binding { + stored.get(def.id).cloned().unwrap_or_else(|| Binding { + keys: def.default_keys.to_string(), + enabled: def.enabled_by_default, + }) +} + +// ── Status shown in Settings ────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Status { + /// Registered and listening. + Active, + /// Turned off, or no key. + Off, + /// Another app already holds this combination. + InUse, + /// Another Coucou shortcut has the same combination. + Duplicate, + /// Not a combination the OS can register. + Invalid, + /// Ctrl+Alt+key types a character on one of the keyboard layouts. + TypesCharacter, + /// Global shortcuts can't be registered in this session (Wayland). + Unsupported, + /// The action itself isn't in this version. + NotPorted, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionStatus { + pub id: &'static str, + pub status: Status, + /// The character a `TypesCharacter` combination types. + #[serde(skip_serializing_if = "Option::is_none")] + pub typed: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Report { + pub actions: Vec, + /// Why nothing could be registered at all, when that is the case. + pub blocked: Option, + /// The command a desktop's own shortcut settings can run instead: + /// ` --shortcut `. + pub command: String, +} + +/// What is registered right now: hot-key id → action id, plus the last report. +#[derive(Default)] +pub struct Registry { + by_id: Mutex>, + report: Mutex>, +} + +// ── Pure helpers ────────────────────────────────────────────────────────────── + +/// Parses an accelerator the way the plugin will. `None` for an empty or +/// unknown one, and for a bare or Shift-only key: a global shortcut needs +/// Ctrl, Alt or the Windows key, or it would take that key away from every +/// other app (same rule as parseKeys in src/core/shortcuts.ts). +pub fn parse(keys: &str) -> Option { + if keys.trim().is_empty() { + return None; + } + let shortcut = Shortcut::from_str(keys).ok()?; + let has_modifier = shortcut + .mods + .intersects(Modifiers::CONTROL | Modifiers::ALT | Modifiers::SUPER | Modifiers::META); + (has_modifier && shortcut.key != Code::Escape).then_some(shortcut) +} + +/// True when Windows reads the combination as AltGr: Ctrl and Alt held, no +/// Windows key. +pub fn is_altgr_like(shortcut: &Shortcut) -> bool { + shortcut.mods.contains(Modifiers::CONTROL | Modifiers::ALT) + && !shortcut.mods.intersects(Modifiers::SUPER | Modifiers::META) +} + +/// The Windows virtual-key code global-hotkey registers for `code`, for the +/// keys that can type a character. Others (arrows, F-keys…) never do: `None`. +pub fn character_vk(code: Code) -> Option { + use Code::*; + let letters = [ + KeyA, KeyB, KeyC, KeyD, KeyE, KeyF, KeyG, KeyH, KeyI, KeyJ, KeyK, KeyL, KeyM, KeyN, + KeyO, KeyP, KeyQ, KeyR, KeyS, KeyT, KeyU, KeyV, KeyW, KeyX, KeyY, KeyZ, + ]; + if let Some(i) = letters.iter().position(|c| *c == code) { + return Some(0x41 + i as u16); + } + let digits = [Digit0, Digit1, Digit2, Digit3, Digit4, Digit5, Digit6, Digit7, Digit8, Digit9]; + if let Some(i) = digits.iter().position(|c| *c == code) { + return Some(0x30 + i as u16); + } + Some(match code { + Space => 0x20, + Semicolon => 0xBA, + Equal => 0xBB, + Comma => 0xBC, + Minus => 0xBD, + Period => 0xBE, + Slash => 0xBF, + Backquote => 0xC0, + BracketLeft => 0xDB, + Backslash => 0xDC, + BracketRight => 0xDD, + Quote => 0xDE, + IntlBackslash => 0xE2, + _ => return None, + }) +} + +/// What each action should do, before the OS is asked anything: the shortcut +/// to register, or the reason it won't be. `types` answers whether a Ctrl+Alt +/// combination types a character (see platform::ctrl_alt_types). +pub fn plan( + stored: &Bindings, + types: impl Fn(&Shortcut) -> Option, +) -> Vec<(&'static ActionDef, Result)> { + let mut seen = HashSet::new(); + ACTIONS + .iter() + .map(|def| { + let refuse = |status, typed| Err(ActionStatus { id: def.id, status, typed }); + let binding = effective(def, stored); + let outcome = if !def.ported { + refuse(Status::NotPorted, None) + } else if !binding.enabled || binding.keys.trim().is_empty() { + refuse(Status::Off, None) + } else { + match parse(&binding.keys) { + None => refuse(Status::Invalid, None), + Some(shortcut) => { + if let Some(typed) = types(&shortcut) { + refuse(Status::TypesCharacter, Some(typed)) + } else if !seen.insert(shortcut.id()) { + refuse(Status::Duplicate, None) + } else { + Ok(shortcut) + } + } + } + }; + (def, outcome) + }) + .collect() +} + +/// `coucou --shortcut ` → the action id, when it names a ported one. +pub fn from_args(args: &[String]) -> Option<&'static str> { + let at = args.iter().position(|a| a == "--shortcut")?; + let def = find(args.get(at + 1)?)?; + def.ported.then_some(def.id) +} + +// ── Registration ────────────────────────────────────────────────────────────── + +/// The plugin, with every press routed through `dispatch`. +pub fn plugin() -> tauri::plugin::TauriPlugin { + tauri_plugin_global_shortcut::Builder::new() + .with_handler(|app, shortcut, event| { + if event.state != ShortcutState::Pressed { + return; + } + let Some(registry) = app.try_state::() else { return }; + let action = registry.by_id.lock().unwrap().get(&shortcut.id()).copied(); + if let Some(action) = action { + dispatch(app, action); + } + }) + .build() +} + +/// Hands an action to the island. +pub fn dispatch(app: &AppHandle, action: &str) { + crate::log::line(format!("shortcut {action}")); + if action == "wardrobeToggle" { + let _ = app.emit_to(WINDOW_LABEL, "open-wardrobe", ()); + } else { + let _ = app.emit_to(WINDOW_LABEL, "shortcut", action.to_string()); + } +} + +/// Unregisters everything Coucou holds. +fn release(app: &AppHandle) { + if let Some(gs) = app.try_state::>() { + if let Err(err) = gs.unregister_all() { + crate::log::line(format!("shortcuts: unregister failed: {err}")); + } + } + if let Some(registry) = app.try_state::() { + registry.by_id.lock().unwrap().clear(); + } +} + +/// Registers the shortcuts in `stored` in place of the current ones, and +/// tells the settings window how each one went. Never fails: a shortcut that +/// can't be had is reported, not fatal. +pub fn apply(app: &AppHandle, stored: &Bindings) { + release(app); + let blocked = crate::platform::global_shortcuts_blocked(); + let gs = app.try_state::>(); + + let mut by_id = HashMap::new(); + let mut report = Vec::new(); + for (def, outcome) in plan(stored, typed_character) { + let status = match outcome { + Err(status) => status, + Ok(_) if blocked.is_some() || gs.is_none() => { + ActionStatus { id: def.id, status: Status::Unsupported, typed: None } + } + Ok(shortcut) => { + // No lock of ours is held here: the plugin calls our handler + // with its own lock taken, on the thread that registers. + let result = gs.as_ref().map(|gs| gs.register(shortcut)); + let status = match result { + Some(Ok(())) => { + by_id.insert(shortcut.id(), def.id); + Status::Active + } + Some(Err(err)) => { + crate::log::line(format!("shortcuts: {} not registered: {err}", def.id)); + Status::InUse + } + None => Status::Unsupported, + }; + ActionStatus { id: def.id, status, typed: None } + } + }; + report.push(status); + } + + if let Some(registry) = app.try_state::() { + *registry.by_id.lock().unwrap() = by_id; + *registry.report.lock().unwrap() = report; + } + let _ = app.emit("shortcuts-status", status(app)); +} + +/// Lets go of every shortcut while a new one is being recorded in Settings, +/// so pressing a combination Coucou already holds records it instead of +/// running it. +pub fn suspend(app: &AppHandle) { + release(app); +} + +pub fn status(app: &AppHandle) -> Report { + let actions = app + .try_state::() + .map(|r| r.report.lock().unwrap().clone()) + .unwrap_or_default(); + Report { + actions, + blocked: crate::platform::global_shortcuts_blocked().map(str::to_string), + command: format!("{} --shortcut", launch_command()), + } +} + +/// How a desktop shortcut should start us: the AppImage when we run from one +/// (the mounted executable moves on every launch), this executable otherwise. +fn launch_command() -> String { + let exe = std::env::var_os("APPIMAGE") + .map(std::path::PathBuf::from) + .or_else(|| std::env::current_exe().ok()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "coucou".to_string()); + if exe.contains(' ') { + format!("\"{exe}\"") + } else { + exe + } +} + +fn typed_character(shortcut: &Shortcut) -> Option { + if !is_altgr_like(shortcut) { + return None; + } + let vk = character_vk(shortcut.key)?; + crate::platform::ctrl_alt_types(vk, shortcut.mods.contains(Modifiers::SHIFT)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MAC_IDS: [&str; 10] = [ + "toggleIsland", "openChat", "goToAlert", "jumpToTerminal", "attachFrontWindow", + "nextPill", "prevPill", "muteToggle", "desktopToggle", "wardrobeToggle", + ]; + + fn never(_: &Shortcut) -> Option { + None + } + + // testDefaultsExhaustive + #[test] + fn every_mac_action_has_a_default_and_keeps_its_id() { + let ids: Vec<_> = ACTIONS.iter().map(|a| a.id).collect(); + assert_eq!(ids, MAC_IDS); + } + + // testAllDefaultsHaveModifier + #[test] + fn every_default_parses_and_has_a_modifier() { + for def in ACTIONS { + let shortcut = parse(def.default_keys) + .unwrap_or_else(|| panic!("{} default does not parse", def.id)); + assert!(is_altgr_like(&shortcut), "{} default is not Ctrl+Alt", def.id); + } + } + + // testNoDefaultDuplicates + #[test] + fn no_two_defaults_share_a_combination() { + let mut seen = HashSet::new(); + for def in ACTIONS { + assert!(seen.insert(parse(def.default_keys).unwrap().id()), "{} duplicates", def.id); + } + } + + // testEnabledByDefault + #[test] + fn only_the_island_toggle_is_off_by_default() { + for def in ACTIONS { + assert_eq!(def.enabled_by_default, def.id != "toggleIsland", "{}", def.id); + } + } + + #[test] + fn the_actions_not_ported_yet_are_reserved_not_registered() { + let plan = plan(&Bindings::new(), never); + for (def, outcome) in plan { + let reserved = matches!(def.id, "attachFrontWindow" | "desktopToggle"); + assert_eq!(def.ported, !reserved); + match outcome { + Ok(_) => assert!(def.ported && def.enabled_by_default, "{}", def.id), + Err(s) if reserved => assert_eq!(s.status, Status::NotPorted), + Err(s) => assert_eq!((def.id, s.status), ("toggleIsland", Status::Off)), + } + } + } + + #[test] + fn parsing_needs_a_modifier_and_a_known_key() { + assert!(parse("").is_none()); + assert!(parse("A").is_none(), "a bare key would take A from every app"); + assert!(parse("Shift+A").is_none(), "so would Shift+A"); + assert!(parse("Ctrl+Escape").is_none()); + assert!(parse("Ctrl+Alt+Nope").is_none()); + assert!(parse("Ctrl+Alt").is_none()); + let s = parse("Ctrl+Alt+Space").unwrap(); + assert_eq!(s.key, Code::Space); + assert!(s.mods.contains(Modifiers::CONTROL | Modifiers::ALT)); + assert!(!s.mods.contains(Modifiers::SHIFT)); + let all = parse("Ctrl+Alt+Shift+Super+K").unwrap(); + assert!(all.mods.contains(Modifiers::CONTROL | Modifiers::ALT | Modifiers::SHIFT | Modifiers::SUPER)); + // Case and spelling don't change the combination. + assert_eq!(parse("ctrl+alt+right").unwrap().id(), parse("Control+Alt+ArrowRight").unwrap().id()); + } + + #[test] + fn only_ctrl_alt_without_the_windows_key_reads_as_altgr() { + assert!(is_altgr_like(&parse("Ctrl+Alt+E").unwrap())); + assert!(is_altgr_like(&parse("Ctrl+Alt+Shift+E").unwrap())); + assert!(!is_altgr_like(&parse("Ctrl+Shift+E").unwrap())); + assert!(!is_altgr_like(&parse("Ctrl+Alt+Super+E").unwrap())); + } + + #[test] + fn virtual_keys_match_the_ones_registered() { + assert_eq!(character_vk(Code::KeyA), Some(0x41)); + assert_eq!(character_vk(Code::KeyZ), Some(0x5A)); + assert_eq!(character_vk(Code::Digit0), Some(0x30)); + assert_eq!(character_vk(Code::Digit9), Some(0x39)); + assert_eq!(character_vk(Code::BracketRight), Some(0xDD)); + assert_eq!(character_vk(Code::Space), Some(0x20)); + assert_eq!(character_vk(Code::ArrowLeft), None); + assert_eq!(character_vk(Code::F5), None); + } + + // testDuplicateDetection + #[test] + fn a_combination_used_twice_is_registered_once() { + let mut stored = Bindings::new(); + let same = Binding { keys: "Ctrl+Alt+A".into(), enabled: true }; + stored.insert("jumpToTerminal".into(), same.clone()); + let plan = plan(&stored, never); + let alert = plan.iter().find(|(d, _)| d.id == "goToAlert").unwrap(); + let term = plan.iter().find(|(d, _)| d.id == "jumpToTerminal").unwrap(); + assert!(alert.1.is_ok()); + assert_eq!(term.1.as_ref().unwrap_err().status, Status::Duplicate); + + // Same key, other modifiers: not a duplicate. + stored.insert("jumpToTerminal".into(), Binding { keys: "Ctrl+Shift+A".into(), enabled: true }); + let plan = super::plan(&stored, never); + assert!(plan.iter().all(|(d, o)| o.is_ok() || !d.ported || d.id == "toggleIsland")); + + // A disabled action doesn't hold its keys. + stored.insert("goToAlert".into(), Binding { keys: "Ctrl+Alt+A".into(), enabled: false }); + stored.insert("jumpToTerminal".into(), same); + let plan = super::plan(&stored, never); + let term = plan.iter().find(|(d, _)| d.id == "jumpToTerminal").unwrap(); + assert!(term.1.is_ok()); + } + + #[test] + fn a_combination_that_types_a_character_is_not_registered() { + let euro = |s: &Shortcut| (s.key == Code::KeyA).then(|| "ą".to_string()); + let plan = plan(&Bindings::new(), euro); + let alert = plan.iter().find(|(d, _)| d.id == "goToAlert").unwrap(); + let refused = alert.1.as_ref().unwrap_err(); + assert_eq!(refused.status, Status::TypesCharacter); + assert_eq!(refused.typed.as_deref(), Some("ą")); + } + + #[test] + fn stored_bindings_override_the_defaults_and_the_rest_fall_back() { + let mut stored = Bindings::new(); + stored.insert("openChat".into(), Binding { keys: "Ctrl+Shift+K".into(), enabled: true }); + stored.insert("muteToggle".into(), Binding { keys: String::new(), enabled: true }); + let chat = effective(find("openChat").unwrap(), &stored); + assert_eq!(chat.keys, "Ctrl+Shift+K"); + let alert = effective(find("goToAlert").unwrap(), &stored); + assert_eq!(alert, Binding { keys: "Ctrl+Alt+A".into(), enabled: true }); + let toggle = effective(find("toggleIsland").unwrap(), &stored); + assert!(!toggle.enabled); + let plan = plan(&stored, never); + let mute = plan.iter().find(|(d, _)| d.id == "muteToggle").unwrap(); + assert_eq!(mute.1.as_ref().unwrap_err().status, Status::Off); + } + + // testLoadSaveRoundTrip + #[test] + fn bindings_round_trip_through_json() { + let mut stored = Bindings::new(); + stored.insert("openChat".into(), Binding { keys: "Ctrl+Alt+K".into(), enabled: false }); + let json = serde_json::to_string(&stored).unwrap(); + assert_eq!(json, r#"{"openChat":{"keys":"Ctrl+Alt+K","enabled":false}}"#); + assert_eq!(serde_json::from_str::(&json).unwrap(), stored); + // A hand-edited entry without `enabled` stays on. + let partial: Bindings = serde_json::from_str(r#"{"openChat":{"keys":"Ctrl+Alt+K"}}"#).unwrap(); + assert!(partial["openChat"].enabled); + } + + #[test] + fn the_command_line_names_an_action() { + let args = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(from_args(&args(&["coucou", "--shortcut", "openChat"])), Some("openChat")); + assert_eq!(from_args(&args(&["coucou", "--shortcut", "wardrobeToggle"])), Some("wardrobeToggle")); + assert_eq!(from_args(&args(&["coucou", "--shortcut"])), None); + assert_eq!(from_args(&args(&["coucou", "--shortcut", "rm -rf"])), None); + assert_eq!(from_args(&args(&["coucou", "--shortcut", "desktopToggle"])), None); + assert_eq!(from_args(&args(&["coucou"])), None); + } +} diff --git a/windows/src-tauri/src/tray.rs b/windows/src-tauri/src/tray.rs index f6aa0c8fc..73008e84c 100644 --- a/windows/src-tauri/src/tray.rs +++ b/windows/src-tauri/src/tray.rs @@ -1,20 +1,37 @@ -// Notification-area icon: Open, Settings, Pause, Quit. +// Notification-area icon: Open, Weekly recap, Wardrobe, Settings, Pause, Quit. use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; use tauri::tray::TrayIconBuilder; -use tauri::{AppHandle, Emitter}; +use tauri::{AppHandle, Emitter, Manager, Wry}; +use crate::i18n::{n_, t}; use crate::island::WINDOW_LABEL; +/// The menu's items, id and English label, in order. The labels are shown in +/// the interface language (i18n.rs) and follow it when it changes. +const ITEMS: [(&str, &str); 6] = [ + ("open", n_("Open Coucou")), + ("recap", n_("Weekly recap")), + ("wardrobe", n_("Wardrobe…")), + ("settings", n_("Settings…")), + ("pause", n_("Pause")), + ("quit", n_("Quit")), +]; + +/// Kept so a language change relabels the menu without rebuilding the tray. +struct Items(Vec<(&'static str, MenuItem)>); + pub fn build(app: &AppHandle) -> tauri::Result<()> { - let open = MenuItem::with_id(app, "open", "Open Coucou", true, None::<&str>)?; - let settings = MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?; - let pause = MenuItem::with_id(app, "pause", "Pause", true, None::<&str>)?; - let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; + let mut items = Vec::new(); + for (id, label) in ITEMS { + items.push((label, MenuItem::with_id(app, id, t(label), true, None::<&str>)?)); + } + let [open, recap, wardrobe, settings, pause, quit] = [0, 1, 2, 3, 4, 5].map(|i| &items[i].1); let sep1 = PredefinedMenuItem::separator(app)?; let sep2 = PredefinedMenuItem::separator(app)?; - let menu = Menu::with_items(app, &[&open, &sep1, &settings, &pause, &sep2, &quit])?; + let menu = Menu::with_items(app, &[open, recap, &sep1, wardrobe, settings, pause, &sep2, quit])?; + app.manage(Items(items)); let mut builder = TrayIconBuilder::with_id("coucou") .tooltip("Coucou") @@ -34,3 +51,11 @@ pub fn build(app: &AppHandle) -> tauri::Result<()> { builder.build(app)?; Ok(()) } + +/// Relabels the menu in the current language. +pub fn retitle(app: &AppHandle) { + let Some(items) = app.try_state::() else { return }; + for (label, item) in &items.0 { + let _ = item.set_text(t(label)); + } +} diff --git a/windows/src-tauri/src/webview_drop.rs b/windows/src-tauri/src/webview_drop.rs new file mode 100644 index 000000000..93775d9a2 --- /dev/null +++ b/windows/src-tauri/src/webview_drop.rs @@ -0,0 +1,70 @@ +// Files dropped on the island, taken by WebView2 itself. +// +// Tauri's drag-drop goes through drop targets wry registers on the webview's +// child windows. The innermost windows, though, belong to msedgewebview2.exe, +// and drags from the classic Explorer folder view (Downloads, Documents…) never +// walked up to wry's target: "no drop" cursor, nothing reached the app. Only the +// newer WinUI views (Home, Recent) got through. +// +// So `dragDropEnabled` is off for the island and WebView2 takes drops the way +// Edge does. The page accepts the HTML5 drag (src/core/bridge.ts) and hands the +// dropped File objects back with `chrome.webview.postMessageWithAdditionalObjects`; +// here they turn back into real paths and reach the island as a `file-drag` drop. + +use tauri::{AppHandle, Emitter}; +use webview2_com::Microsoft::Web::WebView2::Win32::{ + ICoreWebView2File, ICoreWebView2WebMessageReceivedEventArgs2, +}; +use webview2_com::{take_pwstr, WebMessageReceivedEventHandler}; +use wv2_core::{Interface, PWSTR}; + +use crate::island::{self, WINDOW_LABEL}; + +#[derive(serde::Serialize, Clone)] +struct FileDrop { + #[serde(rename = "type")] + kind: &'static str, + paths: Vec, +} + +pub fn install(app: &AppHandle) { + let Some(win) = island::window(app) else { return }; + let handle = app.clone(); + let result = win.with_webview(move |webview| unsafe { + let core = match webview.controller().CoreWebView2() { + Ok(core) => core, + Err(err) => { + crate::log::line(format!("file drop: no webview: {err}")); + return; + } + }; + let handler = WebMessageReceivedEventHandler::create(Box::new(move |_, args| { + let Some(args) = args else { return Ok(()) }; + // Tauri's own IPC messages come through here too; they carry no objects. + let Ok(args) = args.cast::() else { return Ok(()) }; + let Ok(objects) = args.AdditionalObjects() else { return Ok(()) }; + let mut count = 0u32; + objects.Count(&mut count)?; + if count == 0 { + return Ok(()); + } + let mut paths = Vec::new(); + for i in 0..count { + let Ok(file) = objects.GetValueAtIndex(i)?.cast::() else { continue }; + let mut path = PWSTR::null(); + file.Path(&mut path)?; + paths.push(take_pwstr(path)); + } + crate::files::allow_dropped(paths.iter().cloned()); + let _ = handle.emit_to(WINDOW_LABEL, "file-drag", FileDrop { kind: "drop", paths }); + Ok(()) + })); + let mut token = 0i64; + if let Err(err) = core.add_WebMessageReceived(&handler, &mut token) { + crate::log::line(format!("file drop: cannot listen: {err}")); + } + }); + if let Err(err) = result { + crate::log::line(format!("file drop: {err}")); + } +} diff --git a/windows/src-tauri/tauri.conf.json b/windows/src-tauri/tauri.conf.json index 2196b8159..6dfe4099b 100644 --- a/windows/src-tauri/tauri.conf.json +++ b/windows/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Coucou", - "version": "0.1.1", + "version": "0.2.0", "identifier": "fr.louisraille.coucou", "build": { "beforeDevCommand": "npm run dev", @@ -34,7 +34,7 @@ "maximizable": false, "minimizable": false, "closable": false, - "dragDropEnabled": true, + "dragDropEnabled": false, "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required" } ] diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..1bbf25fc8 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -3,9 +3,10 @@ // `npm run dev` alone. import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { emitTo, listen } from "@tauri-apps/api/event"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import type { Settings } from "./state"; +import type { RecapHistory, RecapPrefs } from "../recap/summary"; export const IS_TAURI = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; @@ -32,6 +33,8 @@ export interface BootInfo { export const Bridge = { boot: () => call("boot"), + /** The system's languages as the webview sees them, for Rust's own texts (i18n.rs). */ + setSystemLanguages: (languages: string[]) => call("set_system_languages", { languages }), saveSettings: (settings: Settings) => call("save_settings", { settings }), @@ -50,11 +53,27 @@ export const Bridge = { reposition: () => call("reposition"), + /** Displays the island can be pinned to: `key` is what `settings.screen` stores. */ + listMonitors: () => call<{ key: string; label: string }[]>("list_monitors"), + openUrl: (url: string) => call("open_url", { url }), /** "Open terminal" → opens the folder in VS Code when `code` is on PATH. */ openInVSCode: (path: string | null) => call("open_in_vscode", { path }), + /** + * "Open terminal": the window the session runs in when Rust found it + * (Windows), else the folder in VS Code. + */ + openSession: (sessionId: string | null, path: string | null) => + call("open_session", { sessionId, path }), + + /** The Claude desktop app, for the Claude Desktop pill (Windows only). */ + openClaudeDesktop: () => call("open_claude_desktop"), + + /** The diff card's ↗: an existing file, in VS Code; never launched by its type. */ + openFileInVSCode: (path: string) => call("open_file_in_vscode", { path }), + quit: () => call("quit_app"), openSettingsWindow: () => call("open_settings_window"), @@ -64,6 +83,8 @@ export const Bridge = { // ── Claude Code hooks ───────────────────────────────────────────────────── hooksStatus: () => call("hooks_status"), + /** Pill ID → whether that agent's hooks reach Coucou (read-only, Mac #183). */ + agentHooksStatus: () => call>("agent_hooks_status"), /** Diff to show before anything is written. `install: false` previews removal. */ hooksPreview: (install: boolean) => callOrThrow("hooks_preview", { install }), /** @@ -73,10 +94,38 @@ export const Bridge = { hooksApply: (install: boolean, fingerprint: string) => callOrThrow("hooks_apply", { install, fingerprint }), + // ── Other agents (Gemini CLI, Codex, Cursor…) ───────────────────────────── + agentHooksList: () => call("agent_hooks_list"), + /** Diff to show before anything is written. `install: false` previews removal. */ + agentHooksPreview: (agent: string, install: boolean) => + callOrThrow("agent_hooks_preview", { agent, install }), + /** + * Writes the agent's config — only ever after an explicit click, and only when + * it still matches the preview the user looked at. Returns the backups taken. + */ + agentHooksApply: (agent: string, install: boolean, fingerprint: string) => + callOrThrow("agent_hooks_apply", { agent, install, fingerprint }), + // ── Plan usage: the status line relay, installed apart from the hooks ────── + /** Diff of the status line change. `install: false` previews taking the relay out. */ + statusLinePreview: (install: boolean) => callOrThrow("status_line_preview", { install }), + /** Same rules as hooksApply: an explicit click, and only for the diff that was shown. */ + statusLineApply: (install: boolean, fingerprint: string) => + callOrThrow("status_line_apply", { install, fingerprint }), + /** + * Asks the Codex CLI (`codex app-server`) for its plan limits, as Codex's + * /status does. The raw `account/rateLimits/read` result, or null when Codex + * is missing, not signed in or slow (15 s). + */ + codexPlanUsage: () => call("codex_plan_usage"), + approvalDecision: (requestId: string, decision: "allow" | "deny") => call("approval_decision", { requestId, decision }), /** "The card is up" — until this lands the relay only waits a moment. */ approvalAck: (requestId: string) => call("approval_ack", { requestId }), + /** Answers a question Claude Code asked: question text → chosen label. */ + approvalAnswer: (requestId: string, answers: Record) => + call("approval_answer", { requestId, answers }), + /** "Nobody can act on this" — Claude Code asks in the terminal right away. */ approvalDecline: (requestId: string) => call("approval_decline", { requestId }), @@ -85,6 +134,17 @@ export const Bridge = { chatSend: (query: string, context: ChatContext | null) => callOrThrow<{ text: string }>("chat_send", { query, context }), chatReset: () => call("chat_reset"), + /** + * The models a provider offers, for the picker in the chat view. Rust asks + * the provider only when it has a key (or a server address). + */ + chatModels: (provider: string) => callOrThrow("chat_models", { provider }), + /** Settings → Local models → Connect: does the server answer, and with which models? */ + /** The custom server's key, bound to the address it is entered for. */ + localSetKey: (url: string, key: string) => call("local_set_key", { url, key }), + + localConnect: (provider: "ollama" | "lmstudio" | "custom", url: string) => + callOrThrow("local_connect", { provider, url }), /** Copies a dropped file into the inbox. */ ingestFile: (path: string) => callOrThrow("ingest_file", { path }), /** Only ever tells you whether a key exists — never its value. */ @@ -94,13 +154,85 @@ export const Bridge = { // ── Integrations ────────────────────────────────────────────────────────── refreshIntegration: (id: string) => call("refresh_integration", { id }), + /** The GitHub card is on screen: refetch that part if it is stale. */ + githubRefresh: (section: "pulse" | "activity") => call("github_refresh", { section }), /** Opens the configured n8n instance in the browser. */ openN8n: () => call("open_n8n"), /** Tray → Pause. Stops the integration pollers, not just the island. */ setPaused: (paused: boolean) => call("set_paused", { paused }), + + // ── Global shortcuts ────────────────────────────────────────────────────── + /** How each global shortcut went when Rust last registered them. */ + shortcutsStatus: () => call("shortcuts_status"), + /** Lets go of every global shortcut while Settings records a new one. */ + shortcutsSuspend: (suspended: boolean) => call("shortcuts_suspend", { suspended }), + + // ── Weekly recap ────────────────────────────────────────────────────────── + /** Turns and decisions from `since` (Unix seconds) on, plus the recap prefs. */ + recapHistory: (since: number) => call("recap_history", { since: Math.floor(since) }), + recapPrefs: () => call("recap_prefs"), + recapSetEnabled: (enabled: boolean) => call("recap_set_enabled", { enabled }), + recapSetHideProjects: (hide: boolean) => call("recap_set_hide_projects", { hide }), + /** `week` is the Monday (YYYY-MM-DD) the recap opened on its own for. */ + recapMarkShown: (week: string) => call("recap_mark_shown", { week }), + recapClear: () => call("recap_clear"), + /** Writes the PNG (a data URL) into Pictures or Downloads; returns its path. */ + recapSavePng: (data: string, week: string) => callOrThrow("recap_save_png", { data, week }), + /** Opens the folder of the image saved last. */ + recapRevealSaved: () => call("recap_reveal_saved"), + + // ── Mochi on the desktop (src-tauri/src/desktop.rs) ─────────────────────── + desktopInfo: () => call("desktop_mochi_info"), + /** Dragged out of the island: (x, y) is the pointer in island-window coordinates. */ + desktopPickUp: (x: number, y: number) => call("desktop_mochi_pick_up", { x, y }), + /** Linux: the pointer moved during that drag (Windows carries him from Rust). */ + desktopCarry: (x: number, y: number) => call("desktop_mochi_carry", { x, y }), + desktopCarryEnd: (x: number, y: number) => call("desktop_mochi_carry_end", { x, y }), + /** A drag started on the desktop Mochi; resolves to his top-left corner. */ + desktopDragBegin: () => call<[number, number] | null>("desktop_mochi_drag_begin"), + /** X11: top-left corner, physical pixels. */ + desktopDragMove: (x: number, y: number) => call("desktop_mochi_drag_move", { x, y }), + desktopDragEnd: (x: number, y: number) => call("desktop_mochi_drag_end", { x, y }), + /** From the island to his spot. False: no spot on any connected display. */ + desktopFlyOut: () => call("desktop_mochi_fly_out"), + /** To the island, then hidden. `forget`: he lives in the island again. */ + desktopFlyHome: (forget: boolean) => call("desktop_mochi_fly_home", { forget }), + /** Asleep, the cursor poll stops. */ + desktopSetAsleep: (asleep: boolean) => call("desktop_mochi_set_asleep", { asleep }), }; +export type ShortcutStatus = + | "active" | "off" | "inUse" | "duplicate" | "invalid" + | "typesCharacter" | "unsupported" | "notPorted"; + +export interface ShortcutsReport { + actions: { id: string; status: ShortcutStatus; typed?: string }[]; + /** "wayland" or "no-display" when no global shortcut can be registered. */ + blocked: string | null; + /** ` --shortcut`: append an action id for a desktop shortcut. */ + command: string; +} + +/** How the desktop Mochi's window works here (platform::DesktopMode). */ +export type DesktopMode = "poll" | "window" | "layer" | "off"; + +export interface DesktopInfo { + mode: DesktopMode; + /** He was on the desktop when the app last quit. */ + onDesktop: boolean; +} + +/** An event for one window only (island ⇄ desktop Mochi). Never throws. */ +export async function emitToWindow(label: string, event: string, payload?: unknown) { + if (!IS_TAURI) return; + try { + await emitTo(label, event, payload); + } catch (err) { + console.error(`[coucou] emit ${event} failed`, err); + } +} + export interface IntegrationUpdate { id: string; data: Record; @@ -112,6 +244,19 @@ export type ChatContext = | { kind: "file"; name: string; path: string } | { kind: "window"; appName: string; title: string; url?: string }; +export interface ModelInfo { + id: string; + label: string; +} + +export interface LocalServer { + /** The address as it is stored. */ + url: string; + models: string[]; + /** The address is this machine. */ + loopback: boolean; +} + export interface DroppedFile { name: string; path: string; @@ -120,11 +265,36 @@ export interface DroppedFile { export interface HookStatus { installed: boolean; + /** Coucou's status line relay (plan usage) is the status line in settings.json. */ + planRelayInstalled: boolean; settingsPath: string; hookPath: string; hookReady: boolean; } +/** One agent other than Claude Code, as agents.rs reports it. */ +export interface AgentHookStatus { + /** The `--agent` name; its pill is `agent_`. */ + id: string; + name: string; + installed: boolean; + /** The file (or files, one per line) Coucou writes. */ + path: string; + hookReady: boolean; + /** The island can allow or deny this agent's permission requests. */ + approvals: boolean; + /** What to do once it is written. */ + note: string; +} + +export interface AgentHookPlan { + diff: string; + /** Where each existing file is copied first, one per line; "" when none. */ + backup: string; + path: string; + fingerprint: string; +} + export interface HookPreview { diff: string; backup: string; @@ -150,12 +320,70 @@ export interface DragDropPayload { paths?: string[]; } -/** Files dragged onto the island. Only reaches us when the window takes the mouse. */ +interface WebView2Bridge { + postMessageWithAdditionalObjects(message: unknown, objects: ArrayLike): void; +} + +/** + * Files dragged onto the island. Only reaches us when the window takes the mouse. + * + * On Windows WebView2 takes the drop itself, as in Edge — Tauri's drop handling + * never sees drags from the classic Explorer folder view (see + * src-tauri/src/webview_drop.rs). Enter/over/leave come straight from the page; + * the drop hands the File objects to Rust, which answers with their real paths + * as a `file-drag` event. Elsewhere (Linux) Tauri's own drag events still do it. + */ export async function onDragDrop(handler: (e: DragDropPayload) => void) { if (!IS_TAURI) return () => {}; - return getCurrentWebview().onDragDropEvent((event) => { - handler(event.payload as DragDropPayload); - }); + const webview = (window as unknown as { chrome?: { webview?: WebView2Bridge } }).chrome?.webview; + if (!webview) { + return getCurrentWebview().onDragDropEvent((event) => { + handler(event.payload as DragDropPayload); + }); + } + const hasFiles = (e: DragEvent) => e.dataTransfer?.types.includes("Files") ?? false; + // dragenter/dragleave fire for every element crossed; only the outermost pair counts. + let depth = 0; + + const onEnter = (e: DragEvent) => { + if (!hasFiles(e)) return; + e.preventDefault(); + if (depth++ === 0) handler({ type: "enter" }); + }; + const onOver = (e: DragEvent) => { + if (!hasFiles(e)) return; + // Without this WebView2 refuses the drop — or navigates to the file. + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + handler({ type: "over" }); + }; + const onLeave = (e: DragEvent) => { + if (!hasFiles(e) || depth === 0) return; + if (--depth === 0) handler({ type: "leave" }); + }; + const onDrop = (e: DragEvent) => { + e.preventDefault(); + depth = 0; + const files = e.dataTransfer?.files; + if (!files || files.length === 0) { + handler({ type: "drop", paths: [] }); + return; + } + webview.postMessageWithAdditionalObjects("coucou-file-drop", files); + }; + + window.addEventListener("dragenter", onEnter); + window.addEventListener("dragover", onOver); + window.addEventListener("dragleave", onLeave); + window.addEventListener("drop", onDrop); + const unlisten = await listen("file-drag", (e) => handler(e.payload)); + return () => { + window.removeEventListener("dragenter", onEnter); + window.removeEventListener("dragover", onOver); + window.removeEventListener("dragleave", onLeave); + window.removeEventListener("drop", onDrop); + unlisten(); + }; } export async function onEvent(name: string, handler: (payload: T) => void) { diff --git a/windows/src/core/diff.ts b/windows/src/core/diff.ts new file mode 100644 index 000000000..477471de0 --- /dev/null +++ b/windows/src/core/diff.ts @@ -0,0 +1,326 @@ +// Live diff — port of DiffEngine.swift (NotchBuddy/Sources/CoucouKit). +// +// Line diff (LCS) with 3-line context hunks, used for the Edit / MultiEdit / +// Write tool calls the hooks see. Same size guards as macOS: beyond 200 KB or +// 4 000 lines, or when the LCS table would pass a million cells, only the +// +N −M counts are kept and the diff is marked `tooLarge`. +// +// Also home to `toOneLine` (Claude's final message → one line of plain text) +// and the diff step encoding the ticker reads. + +export type DiffKind = "context" | "added" | "removed"; + +export interface DiffLine { + kind: DiffKind; + text: string; + /** 1-based; -1 for pure adds. */ + origLine: number; + /** 1-based; -1 for pure removes. */ + newLine: number; +} + +export interface DiffHunk { + origStart: number; + newStart: number; + lines: DiffLine[]; +} + +export interface FileDiff { + /** Stable identifier assigned by State.appendSessionDiff. */ + id: number; + path: string; + added: number; + removed: number; + hunks: DiffHunk[]; + tooLarge: boolean; + /** True when produced by fromNew (Write tool). */ + isNewFile: boolean; +} + +/** FileDiff.maxBytes / maxLines on macOS. */ +export const DIFF_MAX_BYTES = 200 * 1024; +export const DIFF_MAX_LINES = 4000; +/** LCS is O(m·n): bail out before the quadratic blowup. */ +const DIFF_MAX_CELLS = 1_000_000; +const CONTEXT = 3; + +const encoder = new TextEncoder(); +const utf8Length = (s: string) => encoder.encode(s).length; + +/** Last path component, Windows or POSIX separators. */ +export function fileName(path: string): string { + const cleaned = path.replace(/[\\/]+$/, ""); + const idx = Math.max(cleaned.lastIndexOf("\\"), cleaned.lastIndexOf("/")); + return idx >= 0 ? cleaned.slice(idx + 1) : cleaned; +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +export function fromEdit(oldText: string, newText: string, path: string): FileDiff { + if (utf8Length(oldText) + utf8Length(newText) > DIFF_MAX_BYTES) { + return countFallback(oldText, newText, path); + } + const oldLines = splitLines(oldText); + const newLines = splitLines(newText); + if (oldLines.length + newLines.length > DIFF_MAX_LINES) { + return countFallback(oldText, newText, path); + } + if (oldLines.length * newLines.length > DIFF_MAX_CELLS) { + return countFallback(oldText, newText, path); + } + const flat = buildDiffLines(oldLines, newLines); + let added = 0; + let removed = 0; + for (const l of flat) { + if (l.kind === "added") added++; + else if (l.kind === "removed") removed++; + } + return { + id: 0, path, added, removed, hunks: buildHunks(flat, CONTEXT), tooLarge: false, isNewFile: false, + }; +} + +export function fromNew(content: string, path: string): FileDiff { + if (utf8Length(content) > DIFF_MAX_BYTES) { + const lineCount = content.split("\n").length; + return { id: 0, path, added: lineCount, removed: 0, hunks: [], tooLarge: true, isNewFile: true }; + } + const lines = splitLines(content); + if (lines.length > DIFF_MAX_LINES) { + return { id: 0, path, added: lines.length, removed: 0, hunks: [], tooLarge: true, isNewFile: true }; + } + const diffLines: DiffLine[] = lines.map((text, i) => ({ + kind: "added", text, origLine: -1, newLine: i + 1, + })); + return { + id: 0, + path, + added: diffLines.length, + removed: 0, + hunks: diffLines.length ? [{ origStart: 0, newStart: 1, lines: diffLines }] : [], + tooLarge: false, + isNewFile: true, + }; +} + +/** + * HookServer.buildFileDiff — the diff of one Edit, MultiEdit or Write tool call, + * or null when the call changes nothing (or is not a file edit at all). + */ +export function buildFileDiff(tool: string, input: Record): FileDiff | null { + const str = (v: unknown): string | null => (typeof v === "string" ? v : null); + const path = str(input.file_path); + switch (tool) { + case "Edit": { + const oldText = str(input.old_string); + const newText = str(input.new_string); + if (oldText == null || newText == null || path == null) return null; + if (!oldText && !newText) return null; + const d = fromEdit(oldText, newText, path); + return d.added > 0 || d.removed > 0 ? d : null; + } + case "MultiEdit": { + const edits = input.edits; + if (path == null || !Array.isArray(edits) || edits.length === 0) return null; + let added = 0; + let removed = 0; + let tooLarge = false; + const hunks: DiffHunk[] = []; + for (const edit of edits) { + if (!edit || typeof edit !== "object") continue; + const e = edit as Record; + const oldText = str(e.old_string); + const newText = str(e.new_string); + if (oldText == null || newText == null) continue; + const d = fromEdit(oldText, newText, path); + added += d.added; + removed += d.removed; + hunks.push(...d.hunks); + if (d.tooLarge) tooLarge = true; + } + if (added === 0 && removed === 0) return null; + return { id: 0, path, added, removed, hunks, tooLarge, isNewFile: false }; + } + case "Write": { + const content = str(input.content); + if (path == null || !content) return null; + const d = fromNew(content, path); + return d.added > 0 || d.removed > 0 ? d : null; + } + default: + return null; + } +} + +// ── Line splitting ──────────────────────────────────────────────────────────── + +function splitLines(text: string): string[] { + const parts = text.replace(/\r\n/g, "\n").split("\n"); + // A trailing newline leaves one empty element behind. + if (parts[parts.length - 1] === "") parts.pop(); + return parts; +} + +// ── LCS diff ────────────────────────────────────────────────────────────────── + +function buildDiffLines(oldLines: string[], newLines: string[]): DiffLine[] { + const m = oldLines.length; + const n = newLines.length; + const w = n + 1; + // dp[i*w + j] = LCS length of oldLines[0.. 0 && j > 0) { + if (oldLines[i - 1] === newLines[j - 1]) { + matches.push([i - 1, j - 1]); + i--; + j--; + } else if (dp[(i - 1) * w + j] >= dp[i * w + j - 1]) { + i--; + } else { + j--; + } + } + matches.reverse(); + + const result: DiffLine[] = []; + let prevOld = -1; + let prevNew = -1; + const removedLine = (k: number): DiffLine => ({ kind: "removed", text: oldLines[k], origLine: k + 1, newLine: -1 }); + const addedLine = (k: number): DiffLine => ({ kind: "added", text: newLines[k], origLine: -1, newLine: k + 1 }); + for (const [oi, ni] of matches) { + for (let k = prevOld + 1; k < oi; k++) result.push(removedLine(k)); + for (let k = prevNew + 1; k < ni; k++) result.push(addedLine(k)); + result.push({ kind: "context", text: oldLines[oi], origLine: oi + 1, newLine: ni + 1 }); + prevOld = oi; + prevNew = ni; + } + for (let k = prevOld + 1; k < m; k++) result.push(removedLine(k)); + for (let k = prevNew + 1; k < n; k++) result.push(addedLine(k)); + return result; +} + +// ── Hunks ───────────────────────────────────────────────────────────────────── + +function buildHunks(lines: DiffLine[], context: number): DiffHunk[] { + const merged: [number, number][] = []; + lines.forEach((line, i) => { + if (line.kind === "context") return; + const start = Math.max(0, i - context); + const end = Math.min(lines.length - 1, i + context); + const last = merged[merged.length - 1]; + if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end); + else merged.push([start, end]); + }); + return merged.map(([start, end]) => { + const hunkLines = lines.slice(start, end + 1); + return { + origStart: hunkLines.find((l) => l.origLine > 0)?.origLine ?? 1, + newStart: hunkLines.find((l) => l.newLine > 0)?.newLine ?? 1, + lines: hunkLines, + }; + }); +} + +// ── Count fallback (too large) ──────────────────────────────────────────────── + +function countFallback(oldText: string, newText: string, path: string): FileDiff { + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + const oldSet = new Set(oldLines); + const newSet = new Set(newLines); + const added = newLines.filter((l) => l !== "" && !oldSet.has(l)).length; + const removed = oldLines.filter((l) => l !== "" && !newSet.has(l)).length; + return { id: 0, path, added, removed, hunks: [], tooLarge: true, isNewFile: false }; +} + +// ── toOneLine ───────────────────────────────────────────────────────────────── + +/** + * A possibly multi-line Markdown message as one line of plain text: only the + * first non-empty paragraph (a blank line, a horizontal rule or a table row ends + * it), without `**`, `__`, backticks, leading `#` or list markers. + */ +export function toOneLine(text: string, maxChars = 200): string { + const trimWS = (s: string) => s.replace(/^[ \t]+|[ \t]+$/g, ""); + const paragraphs: string[][] = []; + let current: string[] = []; + for (const line of text.replace(/\r\n/g, "\n").split("\n")) { + const t = trimWS(line); + const isHR = t.length >= 3 && (/^-+$/.test(t) || /^\*+$/.test(t) || /^_+$/.test(t)); + if (t === "" || isHR || t.startsWith("|")) { + if (current.length) paragraphs.push(current); + current = []; + } else { + current.push(line); + } + } + if (current.length) paragraphs.push(current); + + for (const para of paragraphs) { + const s = para.join("\n").replaceAll("**", "").replaceAll("__", "").replaceAll("`", ""); + const processed: string[] = []; + for (const line of s.split("\n")) { + let l = trimWS(line.replace(/^#+/, "")); + if (l.startsWith("- ") || l.startsWith("* ") || l.startsWith("• ")) l = l.slice(2); + else l = l.replace(/^\d+\.\s+/, ""); + l = trimWS(l); + if (l) processed.push(l); + } + const collapsed = processed.join(" ").split(/\s+/).filter(Boolean).join(" "); + if (collapsed) return Array.from(collapsed).slice(0, maxChars).join(""); + } + return ""; +} + +// ── Diff steps ──────────────────────────────────────────────────────────────── +// A diff shows up in the ticker as a step string the user never sees raw: +// `"\t::"` — same format as macOS. + +export const DIFF_STEP_MARKER = ""; + +export interface DiffStep { + filename: string; + added: number; + removed: number; + diffId: number; +} + +export function isDiffStep(step: string): boolean { + return step.startsWith(DIFF_STEP_MARKER); +} + +export function makeDiffStep(filename: string, added: number, removed: number, diffId: number): string { + return `${DIFF_STEP_MARKER}${filename}\t${added}:${removed}:${diffId}`; +} + +export function parseDiffStep(step: string): DiffStep | null { + if (!isDiffStep(step)) return null; + const body = step.slice(DIFF_STEP_MARKER.length); + const tab = body.indexOf("\t"); + if (tab < 0) return null; + const parts = body.slice(tab + 1).split(":"); + if (parts.length !== 3 || !parts.every((p) => /^-?\d+$/.test(p))) return null; + const [added, removed, diffId] = parts.map(Number); + return { filename: body.slice(0, tab), added, removed, diffId }; +} + +/** The last step that is text, never a diff marker. */ +export function lastTextStep(steps: readonly string[]): string | undefined { + for (let i = steps.length - 1; i >= 0; i--) { + if (!isDiffStep(steps[i])) return steps[i]; + } + return undefined; +} diff --git a/windows/src/core/fonts.ts b/windows/src/core/fonts.ts new file mode 100644 index 000000000..6e7e3c891 --- /dev/null +++ b/windows/src/core/fonts.ts @@ -0,0 +1,9 @@ +// Fonts that carry the scripts of the ten interface languages, appended to the +// existing stacks before the generic family. Latin and Cyrillic text keeps +// rendering in the fonts it always had; these only fill in the characters +// those lack: Devanagari and Bengali (Nirmala UI), Simplified Chinese +// (Microsoft YaHei) and Arabic (Segoe UI, already first) on Windows, the Noto +// families on Linux. +export const SCRIPT_FONTS = + `"Nirmala UI", "Microsoft YaHei UI", "Microsoft YaHei", "Noto Sans", "Noto Sans Devanagari", ` + + `"Noto Sans Bengali", "Noto Sans Arabic", "Noto Sans CJK SC", "Noto Sans SC", "Droid Sans Fallback"`; diff --git a/windows/src/core/github.ts b/windows/src/core/github.ts new file mode 100644 index 000000000..866f8f1f6 --- /dev/null +++ b/windows/src/core/github.ts @@ -0,0 +1,312 @@ +// GitHub pulse and contribution grid — the pure half of the GitHub card. +// Port of the helpers around GitHubPulseCardView, GitHubDetailView and +// GitHubActivityDetailContent (IslandViewContent.swift), GitHubActivity.swift and +// AppState.handleGitHubEvents. No DOM here, so all of it is tested in Node. +// +// Parsing and alert detection happen in Rust (src-tauri/src/github.rs); this +// reads what Rust sends and turns it into what the card shows. + +import { dayMonth, t, tn } from "../i18n/i18n"; + +export type CIState = "pending" | "success" | "failure" | "unknown"; +export type ReviewState = "approved" | "changesRequested" | "pending" | "unknown"; +export type GitHubSection = "myPRs" | "toReview" | "mainCI" | "activity"; + +export interface GitHubPR { + /** "owner/repo#number" */ + id: string; + title: string; + url: string; + repo: string; + number: number; + isDraft: boolean; + ci: CIState; + review: ReviewState; + headSha: string | null; +} + +export interface GitHubRepoCI { + repo: string; + url: string; + branch: string; + ci: CIState; + headSha: string | null; +} + +export interface GitHubPulse { + login: string; + myPRs: GitHubPR[]; + toReview: GitHubPR[]; + mainCI: GitHubRepoCI[]; + fetchedAt: number; +} + +export interface ContributionDay { + /** YYYY-MM-DD */ + date: string; + count: number; + /** 0…4 */ + level: number; + /** 0 = Sunday … 6 = Saturday */ + weekday: number; +} + +export interface GitHubActivity { + total: number; + /** Oldest week first; the last one is usually incomplete. */ + weeks: ContributionDay[][]; + fetchedAt: number; +} + +export interface GitHubStats { + totalRepos: number; + totalStars: number; +} + +export type GitHubEvent = + | { kind: "ciFailed"; prId: string } + | { kind: "ciPassed"; prId: string } + | { kind: "mainFailed"; repo: string } + | { kind: "reviewRequested"; prId: string }; + +/** Every word the GitHub card shows, in the current language (src/i18n). */ +export const GH_STRINGS = { + title: "GitHub", + get overview() { return t("Overview"); }, + get totalStars() { return t("Total stars"); }, + get repositories() { return t("Repositories"); }, + get myPRs() { return t("My PRs"); }, + get toReview() { return t("To review"); }, + get mainCI() { return t("Default branch CI"); }, + get activity() { return t("Activity"); }, + get nothingHere() { return t("Nothing here"); }, + get loading() { return t("Loading…"); }, + get draft() { return t("Draft"); }, + get failing() { return t("failing"); }, + get running() { return t("running"); }, + get passing() { return t("passing"); }, + get allGreen() { return t("all green"); }, + get noRepos() { return t("no repos"); }, + get unknown() { return t("unknown"); }, + get noContributions() { return t("No contributions"); }, + get oneContribution() { return tn("{count} contribution", "{count} contributions", 1); }, + contributions: (n: number) => tn("{count} contribution", "{count} contributions", n), + pastYear: (total: string) => t("{total} past year", { total }), + repos: (n: number) => tn("{count} repo", "{count} repos", n), +}; + +export const CI_COLORS: Record = { + failure: "#F4505E", + pending: "#F5A524", + success: "#22C55E", + unknown: "#6B7079", +}; + +/** contributionColor() — GitHub's dark-theme greens, empty days barely visible. */ +export function contributionColor(level: number): string { + switch (level) { + case 1: return "#0E4429"; + case 2: return "#006D32"; + case 3: return "#26A641"; + case 4: return "#39D353"; + default: return "rgba(255,255,255,0.06)"; + } +} + +// ── Reading what Rust sent ──────────────────────────────────────────────────── + +const isObj = (v: unknown): v is Record => typeof v === "object" && v != null; + +export function readPulse(data: Record): GitHubPulse | null { + const p = data.pulse; + if (!isObj(p) || !Array.isArray(p.myPRs) || !Array.isArray(p.toReview) || !Array.isArray(p.mainCI)) { + return null; + } + return p as unknown as GitHubPulse; +} + +export function readActivity(data: Record): GitHubActivity | null { + const a = data.activity; + if (!isObj(a) || !Array.isArray(a.weeks) || typeof a.total !== "number") return null; + return a as unknown as GitHubActivity; +} + +export function readStats(data: Record): GitHubStats | null { + if (data.totalRepos == null) return null; + return { totalRepos: Number(data.totalRepos ?? 0), totalStars: Number(data.totalStars ?? 0) }; +} + +// ── Card summaries ──────────────────────────────────────────────────────────── + +/** ciWorstState / mainCIWorst: failure > pending > success > unknown. */ +export function worstCI(states: CIState[]): CIState { + if (states.includes("failure")) return "failure"; + if (states.includes("pending")) return "pending"; + if (states.includes("success")) return "success"; + return "unknown"; +} + +/** "My PRs" value: count, plus how many fail or that some are still running. */ +export function myPRsValue(prs: GitHubPR[]): string { + const n = prs.length; + if (n === 0) return "0"; + const failing = prs.filter((p) => p.ci === "failure").length; + if (failing > 0) return `${n} · ${failing} ${GH_STRINGS.failing}`; + if (prs.some((p) => p.ci === "pending")) return `${n} · ${GH_STRINGS.running}`; + return String(n); +} + +/** The "Default branch CI" row: which icon, its colour and the value. */ +export function mainCISummary(repos: GitHubRepoCI[]): { failing: boolean; color: string; value: string } { + const worst = worstCI(repos.map((r) => r.ci)); + switch (worst) { + case "failure": { + const n = repos.filter((r) => r.ci === "failure").length; + return { failing: true, color: CI_COLORS.failure, value: `${n} ${GH_STRINGS.failing}` }; + } + case "pending": + return { failing: false, color: CI_COLORS.pending, value: GH_STRINGS.running }; + case "success": + return { failing: false, color: CI_COLORS.success, value: GH_STRINGS.allGreen }; + default: + return { + failing: false, + color: CI_COLORS.unknown, + value: repos.length === 0 ? GH_STRINGS.noRepos : GH_STRINGS.unknown, + }; + } +} + +/** The word at the end of a default-branch row; nothing when unknown. */ +export function ciWord(ci: CIState): string | null { + switch (ci) { + case "failure": return GH_STRINGS.failing; + case "pending": return GH_STRINGS.running; + case "success": return GH_STRINGS.passing; + default: return null; + } +} + +export function sectionTitle(section: GitHubSection): string { + return GH_STRINGS[section]; +} + +/** "1.2k" above a thousand, like the Mac's formatCount. */ +export function formatCount(n: number): string { + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); +} + +/** "owner/repo" → "repo". */ +export function shortRepo(repo: string): string { + return repo.split("/").pop() || repo; +} + +// ── Links ───────────────────────────────────────────────────────────────────── + +/** Only http(s) links to github.com open, like safeWebURL + the host check on macOS. */ +export function safeGitHubUrl(raw: string): string | null { + let url: URL; + try { + url = new URL(raw); + } catch { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + if (url.hostname !== "github.com") return null; + return url.toString(); +} + +/** A repository's Actions page, where a red default branch is explained. */ +export function actionsUrl(repoUrl: string): string { + return repoUrl.endsWith("/") ? `${repoUrl}actions` : `${repoUrl}/actions`; +} + +export function profileUrl(login: string): string { + return `https://github.com/${encodeURIComponent(login)}`; +} + +// ── Contribution grid ───────────────────────────────────────────────────────── + +/** 7 pt squares, 1.5 pt apart, in the 202 pt the card has: 23 weeks. */ +export const GRID_SQUARE = 7; +export const GRID_GAP = 1.5; +export const GRID_WEEKS = Math.floor((202 + GRID_GAP) / (GRID_SQUARE + GRID_GAP)); + +/** The last `n` weeks, oldest first; the last may be incomplete. */ +export function lastWeeks(activity: GitHubActivity, n: number): ContributionDay[][] { + if (n <= 0) return []; + return activity.weeks.slice(Math.max(0, activity.weeks.length - n)); +} + +/** The last `n` days, oldest first. */ +export function lastDays(activity: GitHubActivity, n: number): ContributionDay[] { + if (n <= 0) return []; + const all = activity.weeks.flat(); + return all.slice(Math.max(0, all.length - n)); +} + +/** One column of the grid: seven slots, Sunday first, null where a day is missing. */ +export function weekColumn(week: ContributionDay[]): (ContributionDay | null)[] { + return Array.from({ length: 7 }, (_, dow) => week.find((d) => d.weekday === dow) ?? null); +} + +/** "2026-01-05" → "Jan 5"; anything else is returned as is. */ +export function dayLabel(date: string): string { + const parts = date.split("-"); + if (parts.length !== 3) return date; + const month = Number(parts[1]); + const day = Number(parts[2]); + if (!Number.isInteger(month) || month < 1 || month > 12 || !Number.isInteger(day)) return date; + return dayMonth(month - 1, day); +} + +export function contributionsLabel(count: number): string { + if (count === 0) return GH_STRINGS.noContributions; + if (count === 1) return GH_STRINGS.oneContribution; + return GH_STRINGS.contributions(count); +} + +/** 1234 → "1,234", whatever the system locale. */ +export function totalLabel(n: number): string { + return n.toLocaleString("en-US"); +} + +/** Right side of the Activity header: the picked day, or the year's total. */ +export function activityHeader( + activity: GitHubActivity | null, + stats: GitHubStats | null, + picked: ContributionDay | null, +): string { + if (picked) return `${dayLabel(picked.date)} · ${contributionsLabel(picked.count)}`; + if (!activity) return ""; + const total = GH_STRINGS.pastYear(totalLabel(activity.total)); + return stats ? `${total} · ${GH_STRINGS.repos(stats.totalRepos)}` : total; +} + +// ── Alerts ──────────────────────────────────────────────────────────────────── + +export interface GitHubAlert { + badge: "error" | "finished"; + sound: "error" | "question" | "finish"; +} + +/** handleGitHubEvents: the loudest event wins — red CI > review request > green CI. */ +export function gitHubAlert(events: GitHubEvent[]): GitHubAlert | null { + let level = 0; + let alert: GitHubAlert | null = null; + for (const e of events) { + switch (e.kind) { + case "ciFailed": + case "mainFailed": + if (level < 3) { level = 3; alert = { badge: "error", sound: "error" }; } + break; + case "reviewRequested": + if (level < 2) { level = 2; alert = { badge: "finished", sound: "question" }; } + break; + case "ciPassed": + if (level < 1) { level = 1; alert = { badge: "finished", sound: "finish" }; } + break; + } + } + return alert; +} diff --git a/windows/src/core/layout.ts b/windows/src/core/layout.ts index 6f70eea4c..9853591e2 100644 --- a/windows/src/core/layout.ts +++ b/windows/src/core/layout.ts @@ -21,7 +21,9 @@ export type IslandViewName = | "result" | "note" | "settings" - | "greeting"; + | "greeting" + | "recap" + | "wardrobe"; export type BotStateName = | "idle" @@ -86,12 +88,20 @@ export const VIEW_LAYOUTS: Record = { note: { height: 160, botX: 60, botY: null, botDiameter: 50, agentMode: "column" }, settings: { height: 160, botX: 54, botY: null, botDiameter: 46, agentMode: "none" }, greeting: { height: 150, botX: 320, botY: 90, botDiameter: 0, agentMode: "none" }, + // Mac: 160. The extra 24 hold the two lines with top agent, project, busiest + // day, longest session, permissions and questions, which the Mac card leaves + // to the shared image. + recap: { height: 184, botX: 62, botY: null, botDiameter: 58, agentMode: "column" }, + wardrobe: { height: 160, botX: 68, botY: null, botDiameter: 58, agentMode: "none" }, }; // The upload views above are only the fallback geometry. Once a file is actually // dropped the whole sequence — Mochi included — is drawn by src/upload, which // owns its own constants (USC) straight from UploadSequenceEngine.swift. +/** The question view with options to pick from: room for two rows of them. */ +export const QUESTION_PICKER_H = 200; + /** Chat view grows with the conversation — IslandContainer.chatPromptHeight. */ export function chatPromptHeight(messageCount: number): number { return Math.min(300, 240 + messageCount * 40); diff --git a/windows/src/core/pills.ts b/windows/src/core/pills.ts new file mode 100644 index 000000000..7a7976de5 --- /dev/null +++ b/windows/src/core/pills.ts @@ -0,0 +1,242 @@ +// Every pill the island can show — port of PillCatalog.swift, the single source +// of truth on macOS. IDs, names, colours, categories and subtitles are the Mac's, +// in the Mac's order: they are contract values (settings, hook routing), so an +// ID here never changes once shipped. +// +// What is added for Windows and Linux is `support` — not every Mac pill has +// something behind it here — and `connect`, which says what makes the pill +// "connected": its hooks being installed, a key in the OS credential store, or +// nothing at all. + +import type { AgentSource } from "./state"; +import { N_ } from "../i18n/i18n"; + +export type PillCategory = "workspace" | "agent" | "ai" | "service"; + +/** Section titles (English keys, shown with `t()`), in display order. */ +export const PILL_CATEGORIES: { id: PillCategory; title: string }[] = [ + { id: "workspace", title: N_("Where you code") }, + { id: "agent", title: N_("Agents") }, + { id: "ai", title: N_("AI for the chat") }, + { id: "service", title: N_("Services") }, +]; + +/** + * Whether the pill does anything on this build: + * - `yes`: works on Windows and Linux; + * - `windows`: Windows only (the app behind it has no Linux build); + * - `soon`: can be declared, shows "Coming soon" (macOS has it, this build not yet); + * - `no`: macOS only, never offered here. + */ +export type PillSupport = "yes" | "windows" | "soon" | "no"; + +/** What makes the pill connected. */ +export type PillConnect = + /** Hook events: connected once the hooks are installed (Mac #183). */ + | { kind: "hooks" } + /** A key in the credential store. */ + | { kind: "key"; key: string } + /** A model server the chat is connected to (Settings → Local models). */ + | { kind: "server"; field: "ollamaUrl" | "lmstudioUrl" } + /** Nothing to set up. */ + | { kind: "none" }; + +export interface PillDefinition { + id: string; + name: string; + color: string; + category: PillCategory; + /** Label next to the pill name in the idle card header (an English key, shown with `t()`). */ + subtitle: string; + source: AgentSource; + support: PillSupport; + connect: PillConnect; +} + +export type HostOs = "windows" | "linux"; + +const hooks: PillConnect = { kind: "hooks" }; +const key = (k: string): PillConnect => ({ kind: "key", key: k }); +const none: PillConnect = { kind: "none" }; +const server = (field: "ollamaUrl" | "lmstudioUrl"): PillConnect => ({ kind: "server", field }); + +/** ChatProvider.accentHex on macOS. */ +const ACCENT = { + anthropic: "#E07950", + google: "#4285F4", + openai: "#10A37F", + ollama: "#FACC15", + lmstudio: "#A3E635", +}; + +export const PILL_CATALOG: readonly PillDefinition[] = [ + // ── Where you code ───────────────────────────────────────────────────────── + { id: "integration_claude", name: "VS Code", color: "#F5F6F8", category: "workspace", + subtitle: N_("Integration"), source: "claudeCode", support: "yes", connect: hooks }, + // Claude Code in Cursor's terminal: the same hooks as Claude Code. + { id: "agent_cursor", name: "Cursor", color: "#C0C4CC", category: "workspace", + subtitle: N_("Integration"), source: "agent", support: "yes", connect: hooks }, + { id: "agent_antigravity", name: "Antigravity", color: "#E879F9", category: "workspace", + subtitle: N_("Integration"), source: "agent", support: "yes", connect: hooks }, + { id: "agent_codex", name: "Codex", color: "#2DD4BF", category: "workspace", + subtitle: N_("Integration"), source: "agent", support: "yes", connect: hooks }, + // ── Agents ───────────────────────────────────────────────────────────────── + { id: "agent_gemini", name: "Gemini CLI", color: "#8AB4F8", category: "agent", + subtitle: N_("Agent"), source: "agent", support: "yes", connect: hooks }, + { id: "agent_copilot", name: "Copilot CLI", color: "#818CF8", category: "agent", + subtitle: N_("Agent"), source: "agent", support: "yes", connect: hooks }, + { id: "agent_muse", name: "Muse Code", color: "#38BDF8", category: "agent", + subtitle: N_("Agent"), source: "agent", support: "yes", connect: hooks }, + // A plugin that starts the relay, written from Settings → Agents (agents.rs). + { id: "agent_opencode", name: "OpenCode", color: "#4ADE80", category: "agent", + subtitle: N_("Agent"), source: "agent", support: "yes", connect: hooks }, + { id: "agent_amp", name: "Amp", color: "#F59E0B", category: "agent", + subtitle: N_("Agent"), source: "agent", support: "yes", connect: hooks }, + { id: "agent_hermes", name: "Hermes", color: "#C084FC", category: "agent", + subtitle: N_("Agent"), source: "agent", support: "yes", connect: hooks }, + // Claude Code sessions run from the Claude desktop app: the relay tags them + // from CLAUDE_CODE_ENTRYPOINT, so there is nothing to install. The app has no + // Linux build. + { id: "agent_claude-desktop", name: "Claude Desktop", color: "#D97757", category: "agent", + subtitle: N_("Agent"), source: "agent", support: "windows", connect: none }, + // ── AI for the chat ──────────────────────────────────────────────────────── + { id: "ai_anthropic", name: "Anthropic", color: ACCENT.anthropic, category: "ai", + subtitle: N_("Chat"), source: "n8n", support: "yes", connect: key("anthropic-api-key") }, + // The chat talks to each of them (src-tauri/src/chat.rs): a key for the + // cloud ones, a connected server for the local ones. + { id: "ai_google", name: "Google AI", color: ACCENT.google, category: "ai", + subtitle: N_("Chat"), source: "n8n", support: "yes", connect: key("google-api-key") }, + { id: "ai_openai", name: "OpenAI", color: ACCENT.openai, category: "ai", + subtitle: N_("Chat"), source: "n8n", support: "yes", connect: key("openai-api-key") }, + { id: "ai_ollama", name: "Ollama", color: ACCENT.ollama, category: "ai", + subtitle: N_("Chat"), source: "n8n", support: "yes", connect: server("ollamaUrl") }, + { id: "ai_lmstudio", name: "LM Studio", color: ACCENT.lmstudio, category: "ai", + subtitle: N_("Chat"), source: "n8n", support: "yes", connect: server("lmstudioUrl") }, + // ── Services ─────────────────────────────────────────────────────────────── + { id: "integration_resend", name: "Resend", color: "#22C55E", category: "service", + subtitle: N_("Integration"), source: "n8n", support: "yes", connect: key("resend-api-key") }, + { id: "integration_n8n", name: "n8n", color: "#F29B38", category: "service", + subtitle: N_("Integration"), source: "n8n", support: "yes", connect: key("n8n-api-key") }, + { id: "integration_vercel", name: "Vercel", color: "#7C5CFF", category: "service", + subtitle: N_("Integration"), source: "n8n", support: "yes", connect: key("vercel-token") }, + { id: "integration_github", name: "GitHub", color: "#F4505E", category: "service", + subtitle: N_("Integration"), source: "n8n", support: "yes", connect: key("github-token") }, + { id: "integration_notion", name: "Notion", color: "#8C8C8C", category: "service", + subtitle: N_("Integration"), source: "n8n", support: "yes", connect: key("notion-api-key") }, + { id: "integration_calcom", name: "Cal.com", color: "#C9956A", category: "service", + subtitle: N_("Integration"), source: "n8n", support: "yes", connect: key("calcom-api-key") }, + { id: "integration_stripe", name: "Stripe", color: "#0570DE", category: "service", + subtitle: N_("Integration"), source: "n8n", support: "yes", connect: key("stripe-api-key") }, + { id: "integration_music", name: "Apple Music", color: "#FA2D48", category: "service", + subtitle: N_("Integration"), source: "n8n", support: "no", connect: none }, +]; + +/** The always-on pill unless the user picks another workspace tool. */ +export const DEFAULT_MAIN_PILL = "integration_claude"; + +/** How many declared pills may sit next to the main one. */ +export const MAX_DECLARED = 4; + +/** The OS this page runs on. Only the Windows webview says "Windows". */ +export const HOST_OS: HostOs = + typeof navigator !== "undefined" && /Windows/.test(navigator.userAgent ?? "") ? "windows" : "linux"; + +export function pillDefinition(id: string): PillDefinition | undefined { + return PILL_CATALOG.find((p) => p.id === id); +} + +function offeredOn(def: PillDefinition, os: HostOs): boolean { + return def.support === "yes" || def.support === "soon" || (def.support === "windows" && os === "windows"); +} + +/** The pills this build offers, in catalog order. */ +export function availablePills(os: HostOs = HOST_OS): PillDefinition[] { + return PILL_CATALOG.filter((p) => offeredOn(p, os)); +} + +export function isComingSoon(id: string): boolean { + return pillDefinition(id)?.support === "soon"; +} + +/** True for pills whose sessions arrive as hook events (Mac #183). */ +export function isHookPill(id: string): boolean { + return pillDefinition(id)?.connect.kind === "hooks"; +} + +/** Workspace tools that can be the main pill. */ +export function mainPillChoices(os: HostOs = HOST_OS): PillDefinition[] { + return availablePills(os).filter((p) => p.category === "workspace" && p.support !== "soon"); +} + +/** PillDefinition.sessionSubtitle — next to the name in a live session's card. */ +export function sessionSubtitle(id: string): string { + switch (id) { + case "integration_claude": return "Claude Code"; + case "agent_cursor": return "Cursor"; + case "agent_codex": return "Codex"; + case "agent_hermes": return "Hermes"; + case "agent_claude-desktop": return "Claude Desktop"; + default: return N_("Agent"); + } +} + +export interface Declared { + mainPill: string; + activeIntegrations: string[]; +} + +/** + * The declaration as it may be used: a main pill this build can run (else the + * default), and declared pills this build offers, without duplicates and + * without the main pill, which never takes a slot. Same rules as + * AppState.loadIntegrationTasks on macOS. + */ +export function sanitizeDeclared(d: Declared, os: HostOs = HOST_OS): Declared { + const mains = new Set(mainPillChoices(os).map((p) => p.id)); + const mainPill = mains.has(d.mainPill) ? d.mainPill : DEFAULT_MAIN_PILL; + const offered = new Set(availablePills(os).map((p) => p.id)); + const activeIntegrations: string[] = []; + for (const id of d.activeIntegrations ?? []) { + if (id !== mainPill && offered.has(id) && !activeIntegrations.includes(id)) { + activeIntegrations.push(id); + } + } + return { mainPill, activeIntegrations }; +} + +/** + * Declares or undeclares a pill. Returns the new list, or null when the click + * changes nothing: the main pill is never toggled, an unknown pill never + * declared, and a fifth pill never added. + */ +export function toggleDeclared(d: Declared, id: string, os: HostOs = HOST_OS): string[] | null { + if (id === d.mainPill) return null; + if (!availablePills(os).some((p) => p.id === id)) return null; + if (d.activeIntegrations.includes(id)) return d.activeIntegrations.filter((x) => x !== id); + if (d.activeIntegrations.length >= MAX_DECLARED) return null; + return [...d.activeIntegrations, id]; +} + +/** + * Picks a new main pill. The old main pill is not declared on its own, and the + * new one leaves the declared list so it does not take a slot twice. + */ +export function chooseMainPill(d: Declared, id: string, os: HostOs = HOST_OS): Declared | null { + if (!mainPillChoices(os).some((p) => p.id === id)) return null; + return { mainPill: id, activeIntegrations: d.activeIntegrations.filter((x) => x !== id) }; +} + +/** + * Pill order: the main pill first, then pills from outside the catalog (any + * other tagged agent), then the catalog's pills in catalog order. Pills of equal + * rank keep their order. Mirrors sortTasksByCatalog on macOS, with the main + * pill (not always VS Code here) leading. + */ +export function orderPills(tasks: T[], mainPill: string): T[] { + const index = new Map(PILL_CATALOG.map((p, i) => [p.id, i])); + const rank = (t: T) => (t.id === mainPill ? -2 : index.has(t.id) ? index.get(t.id)! : -1); + return tasks + .map((t, i) => ({ t, i })) + .sort((a, b) => rank(a.t) - rank(b.t) || a.i - b.i) + .map(({ t }) => t); +} diff --git a/windows/src/core/plan.ts b/windows/src/core/plan.ts new file mode 100644 index 000000000..47bac317c --- /dev/null +++ b/windows/src/core/plan.ts @@ -0,0 +1,224 @@ +// Plan usage gauges — the logic of ClaudePlanGauge.swift and CodexPlanGauge.swift +// on the Mac, with no DOM so it can be tested on its own. The pills and cards +// that show it are in views/usage.ts. +// +// Claude: the numbers come from Claude Code's own status line (`rate_limits` in +// what it hands its status line command), through the relay. Pro and Max only. +// Codex: the numbers come from the Codex CLI itself (`codex app-server`, +// `account/rateLimits/read`, the source of Codex's /status), asked by the app +// when the pill shows. Nothing is read from any credentials, nothing is fetched +// by Coucou itself. + +export interface PlanWindow { + /** 0–100, clamped. */ + usedPct: number; + /** Epoch milliseconds. */ + resetsAt: number; +} + +export interface PlanUsage { + fiveHour?: PlanWindow; + sevenDay?: PlanWindow; + /** When the numbers arrived (epoch ms). */ + updatedAt: number; +} + +export interface CodexPlanUsage extends PlanUsage { + /** Free full resets available. */ + resetCredits?: number; + /** Soonest expiry among the available ones (epoch ms). */ + resetCreditExpiresAt?: number; + planType?: string; +} + +import { dayMonth, t, weekdayShort } from "../i18n/i18n"; + +const DAY_MS = 86_400_000; + +/** Every user-visible string of the gauges, in the current language (src/i18n). */ +export const PLAN_TEXT = { + get claudeTitle() { return t("Claude plan"); }, + get codexTitle() { return t("Codex plan"); }, + get claudePillTitle() { return t("Claude plan usage"); }, + get codexPillTitle() { return t("Codex plan usage"); }, + get waiting() { return t("Waiting for a response from Claude Code"); }, + get askingCodex() { return t("Asking Codex…"); }, + get justNow() { return t("just now"); }, + minAgo: (n: number) => t("{n} min ago", { n }), + hAgo: (n: number) => t("{n} h ago", { n }), + get fiveHours() { return t("5 hours"); }, + get week() { return t("Week"); }, + get resets() { return t("Resets"); }, + get resetting() { return t("Resetting…"); }, + inHM: (h: number, m: number) => t("in {h} h {m}", { h, m }), + inM: (m: number) => t("in {m} min", { m }), + available: (n: number) => t("{n} available", { n }), + until: (date: string) => t(" · until {date}", { date }), + none: "—", +}; + +const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); +const asObj = (v: unknown): Record | null => + v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : null; + +// ── Claude ──────────────────────────────────────────────────────────────────── + +function parseClaudeWindow(raw: unknown, now: number): PlanWindow | undefined { + const w = asObj(raw); + const pct = w?.used_percentage; + const epoch = w?.resets_at; + if (!isNum(pct) || !isNum(epoch)) return undefined; + // Anything outside 0–200 is not a percentage; 100–200 is a plan over its limit, shown full. + if (pct < 0 || pct > 200) return undefined; + // resets_at is epoch seconds. A date more than 400 days away is milliseconds in disguise. + if (epoch <= 0 || epoch * 1000 > now + 400 * DAY_MS) return undefined; + return { usedPct: Math.min(100, pct), resetsAt: epoch * 1000 }; +} + +/** ClaudePlanGauge.parse: `rate_limits` of a status line call, or null when it holds neither window. */ +export function parseClaudePlan(rateLimits: unknown, now = Date.now()): PlanUsage | null { + const rl = asObj(rateLimits); + const fiveHour = parseClaudeWindow(rl?.five_hour, now); + const sevenDay = parseClaudeWindow(rl?.seven_day, now); + if (!fiveHour && !sevenDay) return null; + const usage: PlanUsage = { updatedAt: now }; + if (fiveHour) usage.fiveHour = fiveHour; + if (sevenDay) usage.sevenDay = sevenDay; + return usage; +} + +// ── Codex ───────────────────────────────────────────────────────────────────── + +/** + * CodexPlanGauge.parse: the result of `account/rateLimits/read`. `primary` and + * `secondary` are not tied to a window, so they are sorted by duration: a day or + * less is the 5-hour window, anything else (or no duration) the week. + */ +export function parseCodexPlan(result: unknown, now = Date.now()): CodexPlanUsage | null { + const r = asObj(result); + const limits = asObj(r?.rateLimits); + if (!limits) return null; + const usage: CodexPlanUsage = { updatedAt: now }; + if (typeof limits.planType === "string" && limits.planType) usage.planType = limits.planType; + for (const key of ["primary", "secondary"]) { + const w = asObj(limits[key]); + if (!w || !isNum(w.usedPercent) || !isNum(w.resetsAt)) continue; + const window: PlanWindow = { + usedPct: Math.min(100, Math.max(0, w.usedPercent)), + resetsAt: w.resetsAt * 1000, + }; + if (Number.isInteger(w.windowDurationMins) && (w.windowDurationMins as number) <= 24 * 60) { + usage.fiveHour = window; + } else { + usage.sevenDay = window; + } + } + const credits = asObj(r?.rateLimitResetCredits); + if (credits) { + if (Number.isInteger(credits.availableCount)) usage.resetCredits = credits.availableCount as number; + const expiries = (Array.isArray(credits.credits) ? credits.credits : []) + .map(asObj) + .filter((c) => c?.status === "available" && isNum(c.expiresAt)) + .map((c) => (c!.expiresAt as number) * 1000); + if (expiries.length) usage.resetCreditExpiresAt = Math.min(...expiries); + } + return usage.fiveHour || usage.sevenDay ? usage : null; +} + +// ── Shared ──────────────────────────────────────────────────────────────────── + +/** What to show for a window: 0 once its reset time has passed. */ +export const effectivePct = (w: PlanWindow, now = Date.now()): number => (w.resetsAt <= now ? 0 : w.usedPct); + +/** The higher of the two effective percentages; null when there are no windows. */ +export function dominantPct(u: PlanUsage | null | undefined, now = Date.now()): number | null { + const pcts = [u?.fiveHour, u?.sevenDay].filter((w): w is PlanWindow => !!w).map((w) => effectivePct(w, now)); + return pcts.length ? Math.max(...pcts) : null; +} + +/** Green below 50 %, orange up to 80 %, red above, grey without data. */ +export function planColor(pct: number | null): string { + if (pct == null) return "#6B7079"; + if (pct < 50) return "#22C55E"; + if (pct < 80) return "#F59E0B"; + return "#F4505E"; +} + +/** "Claude 73%" / "Codex 12%" on the pill; "Claude —" while there are no numbers. */ +export function pillLabel(name: "Claude" | "Codex", u: PlanUsage | null | undefined, now = Date.now()): string { + const pct = dominantPct(u, now); + return pct == null ? `${name} ${PLAN_TEXT.none}` : `${name} ${Math.round(pct)}%`; +} + +/** "just now", "12 min ago", "3 h ago". */ +export function ageLabel(updatedAt: number, now = Date.now()): string { + const secs = (now - updatedAt) / 1000; + if (secs < 60) return PLAN_TEXT.justNow; + const mins = Math.floor(secs / 60); + return mins < 60 ? PLAN_TEXT.minAgo(mins) : PLAN_TEXT.hAgo(Math.floor(mins / 60)); +} + +/** "in 1 h 20" / "in 5 min" for the 5-hour window, "Mon 9:00" for the week. */ +export function resetLabel(w: PlanWindow, weekly: boolean, now = Date.now()): string { + const secs = (w.resetsAt - now) / 1000; + if (secs <= 0) return PLAN_TEXT.resetting; + if (weekly) { + const d = new Date(w.resetsAt); + return `${weekdayShort(d.getDay())} ${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`; + } + const hours = Math.floor(secs / 3600); + const mins = Math.floor((secs % 3600) / 60); + return hours > 0 ? PLAN_TEXT.inHM(hours, mins) : PLAN_TEXT.inM(mins); +} + +/** The Claude card's subtitle. */ +export function claudeSubtitle(u: PlanUsage | null, now = Date.now()): string { + return u ? ageLabel(u.updatedAt, now) : PLAN_TEXT.waiting; +} + +/** The Codex card's subtitle: "plus · 3 min ago". */ +export function codexSubtitle(u: CodexPlanUsage | null, now = Date.now()): string { + if (!u) return PLAN_TEXT.askingCodex; + return (u.planType ? `${u.planType} · ` : "") + ageLabel(u.updatedAt, now); +} + +/** "2 available · until Oct 9", or "—" when Codex said nothing about resets. */ +export function codexResetsLabel(u: CodexPlanUsage | null): string { + if (u?.resetCredits == null) return PLAN_TEXT.none; + let text = PLAN_TEXT.available(u.resetCredits); + if (u.resetCredits > 0 && u.resetCreditExpiresAt != null) { + const d = new Date(u.resetCreditExpiresAt); + text += PLAN_TEXT.until(dayMonth(d.getMonth(), d.getDate())); + } + return text; +} + +/** How long a Codex answer stays good before the pill asks again (as on the Mac). */ +export const CODEX_STALE_MS = 60_000; + +/** True when the Codex numbers are missing or older than a minute. */ +export const codexIsStale = (u: CodexPlanUsage | null, now = Date.now()): boolean => + !u || now - u.updatedAt >= CODEX_STALE_MS; + +/** A stored Claude usage, if it still looks like one (the last numbers survive a restart). */ +export function restorePlanUsage(raw: string | null): PlanUsage | null { + if (!raw) return null; + try { + const v = asObj(JSON.parse(raw)); + if (!v || !isNum(v.updatedAt)) return null; + const win = (w: unknown): PlanWindow | undefined => { + const o = asObj(w); + return o && isNum(o.usedPct) && isNum(o.resetsAt) && o.usedPct >= 0 && o.usedPct <= 100 + ? { usedPct: o.usedPct, resetsAt: o.resetsAt } + : undefined; + }; + const usage: PlanUsage = { updatedAt: v.updatedAt }; + const fh = win(v.fiveHour); + const sd = win(v.sevenDay); + if (fh) usage.fiveHour = fh; + if (sd) usage.sevenDay = sd; + return fh || sd ? usage : null; + } catch { + return null; + } +} diff --git a/windows/src/core/providers.ts b/windows/src/core/providers.ts new file mode 100644 index 000000000..54a578298 --- /dev/null +++ b/windows/src/core/providers.ts @@ -0,0 +1,106 @@ +// Who the chat can talk to — the island's side of chat.rs. The Mac's +// ChatProvider (IslandTypes.swift) plus OpenRouter and any OpenAI-compatible +// server. Pure data and helpers, so they can be tested without a webview. + +import type { Settings } from "./state"; +import { N_ } from "../i18n/i18n"; + +export type ProviderId = + | "anthropic" | "openai" | "google" | "openrouter" + | "ollama" | "lmstudio" | "custom"; + +export interface ProviderDef { + id: ProviderId; + /** Shown on the chip in the chat's model picker. */ + name: string; + accent: string; + /** Credential store entry of its key; null for the model servers. */ + key: string | null; + /** Settings field holding a model server's address. */ + urlField: "ollamaUrl" | "lmstudioUrl" | "customUrl" | null; + defaultModel: string; + /** When the saved model is not offered, the first one containing this is picked. */ + prefer: string | null; +} + +export const PROVIDERS: readonly ProviderDef[] = [ + { id: "anthropic", name: "Anthropic", accent: "#E07950", key: "anthropic-api-key", urlField: null, defaultModel: "claude-opus-5", prefer: "opus" }, + { id: "google", name: "Google", accent: "#4285F4", key: "google-api-key", urlField: null, defaultModel: "gemini-2.0-flash", prefer: "flash" }, + { id: "openai", name: "OpenAI", accent: "#10A37F", key: "openai-api-key", urlField: null, defaultModel: "gpt-4o", prefer: "mini" }, + { id: "openrouter", name: "OpenRouter", accent: "#6467F2", key: "openrouter-api-key", urlField: null, defaultModel: "openrouter/auto", prefer: null }, + { id: "ollama", name: "Ollama", accent: "#FACC15", key: null, urlField: "ollamaUrl", defaultModel: "", prefer: null }, + { id: "lmstudio", name: "LM Studio", accent: "#A3E635", key: null, urlField: "lmstudioUrl", defaultModel: "", prefer: null }, + { id: "custom", name: N_("Custom server"), accent: "#C0C4CC", key: null, urlField: "customUrl", defaultModel: "", prefer: null }, +]; + +/** Credential store entry of the custom server's optional key. */ +export const CUSTOM_SERVER_KEY = "openai-compatible-key"; + +export function providerDef(id: string): ProviderDef { + return PROVIDERS.find((p) => p.id === id) ?? PROVIDERS[0]; +} + +/** The model the chat uses for the active provider. */ +export function activeModel(settings: Settings): string { + const p = providerDef(settings.chatProvider); + if (p.id === "anthropic") return settings.model || p.defaultModel; + return settings.chatModels[p.id] || p.defaultModel; +} + +/** `settings` with `model` picked for `provider`. */ +export function withModel(settings: Settings, provider: ProviderId, model: string): Settings { + if (provider === "anthropic") return { ...settings, model }; + return { ...settings, chatModels: { ...settings.chatModels, [provider]: model } }; +} + +/** + * The chips of the picker: every cloud provider (one without a key says so + * when picked), and a model server once it is connected — or while it is the + * active one, so the picker never hides where the chat goes. + */ +export function visibleProviders(settings: Settings): ProviderDef[] { + return PROVIDERS.filter( + (p) => !p.urlField || settings[p.urlField] !== "" || settings.chatProvider === p.id, + ); +} + +/** The model to keep once the list arrives: the saved one if offered, else a sensible one. */ +export function pickModel(provider: ProviderDef, offered: string[], current: string): string | null { + if (offered.length === 0) return null; + if (offered.includes(current)) return current; + const preferred = provider.prefer ? offered.find((id) => id.includes(provider.prefer!)) : undefined; + if (preferred) return preferred; + return offered.includes(provider.defaultModel) ? provider.defaultModel : offered[0]; +} + +// ── Where an address points ─────────────────────────────────────────────────── + +/** Mirrors net.rs: this machine, by name or address. */ +export function isLoopbackHost(host: string): boolean { + const h = host.replace(/^\[|\]$/g, "").toLowerCase(); + if (h === "localhost" || h.endsWith(".localhost")) return true; + if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return true; + return h === "0.0.0.0" || h === "::1" || h === "::" || h === "::ffff:127.0.0.1" || h === "::ffff:7f00:1"; +} + +/** + * Where a server address sends what you type: this machine, another one over + * https, another one in clear text (http), or nowhere valid. An empty field + * means the usual address on this machine. + */ +export type Exposure = "local" | "remote" | "remote-http" | "invalid"; + +export function urlExposure(raw: string): Exposure { + const text = raw.trim(); + if (!text) return "local"; + let url: URL; + try { + url = new URL(text.includes("://") ? text : `http://${text}`); + } catch { + return "invalid"; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return "invalid"; + if (!url.hostname || url.username || url.password) return "invalid"; + if (isLoopbackHost(url.hostname)) return "local"; + return url.protocol === "https:" ? "remote" : "remote-http"; +} diff --git a/windows/src/core/shortcuts.ts b/windows/src/core/shortcuts.ts new file mode 100644 index 000000000..545d8000f --- /dev/null +++ b/windows/src/core/shortcuts.ts @@ -0,0 +1,392 @@ +// Keyboard shortcuts — the pure part, port of ShortcutLogic.swift. +// +// Global shortcuts are registered by Rust (src-tauri/src/shortcuts.rs), which +// owns the same table of defaults; tests/shortcuts.test.mjs keeps the two in +// step. This file parses and formats accelerators, turns a key press into one +// (the recorder in Settings), spots duplicates, and maps the keys pressed +// inside the open island to what they do. +// +// Why Ctrl+Alt+Space / A / T / S / G / ← → rather than the Mac's letters: see +// the top of src-tauri/src/shortcuts.rs. In short, Windows reads Ctrl+Alt as +// AltGr, and AltGr+E, Q, M, W, C, the digits and most punctuation type a +// character on at least one of the French, German, Spanish, Italian, +// Portuguese or Brazilian layouts (ALTGR_CHARACTERS below). + +// ── Strings shown to the user ───────────────────────────────────────────────── +// English keys: Settings shows them through `t()` (src/i18n). Key names +// (Ctrl, Alt, Space…) are never translated. + +import { N_ } from "../i18n/i18n"; + +export const SHORTCUT_TEXT = { + toggleIsland: N_("Open or close the island"), + openChat: N_("Open the chat"), + goToAlert: N_("Go to the waiting permission or question"), + jumpToTerminal: N_("Open the terminal"), + attachFrontWindow: N_("Attach the front window to the chat"), + nextPill: N_("Next pill"), + prevPill: N_("Previous pill"), + muteToggle: N_("Mute or unmute Mochi"), + desktopToggle: N_("Send Mochi to the desktop"), + wardrobeToggle: N_("Open the wardrobe"), + island: { + nextPrev: N_("Next or previous pill"), + byNumber: N_("Go to pill 1 to 9"), + send: N_("Send the message"), + newChat: N_("Start a new chat"), + settings: N_("Open Settings"), + pin: N_("Keep the island open"), + close: N_("Close the island"), + }, +} as const; + +// ── Actions ─────────────────────────────────────────────────────────────────── + +/** Mac `ShortcutAction` raw values. Stored in settings.json: never rename one. */ +export type ShortcutId = + | "toggleIsland" + | "openChat" + | "goToAlert" + | "jumpToTerminal" + | "attachFrontWindow" + | "nextPill" + | "prevPill" + | "muteToggle" + | "desktopToggle" + | "wardrobeToggle"; + +export interface ShortcutDef { + id: ShortcutId; + defaultKeys: string; + enabledByDefault: boolean; + /** False for the Mac actions this version can't do yet: reserved, never registered. */ + ported: boolean; +} + +const def = (id: ShortcutId, defaultKeys: string, enabledByDefault: boolean, ported: boolean): ShortcutDef => + ({ id, defaultKeys, enabledByDefault, ported }); + +/** Same order and defaults as ACTIONS in src-tauri/src/shortcuts.rs. */ +export const SHORTCUTS: readonly ShortcutDef[] = [ + def("toggleIsland", "Ctrl+Alt+N", false, true), + def("openChat", "Ctrl+Alt+Space", true, true), + def("goToAlert", "Ctrl+Alt+A", true, true), + def("jumpToTerminal", "Ctrl+Alt+T", true, true), + def("attachFrontWindow", "Ctrl+Alt+F", true, false), + def("nextPill", "Ctrl+Alt+Right", true, true), + def("prevPill", "Ctrl+Alt+Left", true, true), + def("muteToggle", "Ctrl+Alt+S", true, true), + def("desktopToggle", "Ctrl+Alt+D", true, false), + def("wardrobeToggle", "Ctrl+Alt+G", true, true), +]; + +export interface Binding { + /** Canonical accelerator, e.g. "Ctrl+Alt+Space"; empty for no key. */ + keys: string; + enabled: boolean; +} + +/** `settings.shortcuts`: only the actions the user changed. */ +export type Bindings = Record; + +/** The binding in force: what the user stored, or the default. */ +export function effective(d: ShortcutDef, stored: Bindings | undefined): Binding { + const own = stored?.[d.id]; + return own ? { keys: own.keys, enabled: own.enabled } : { keys: d.defaultKeys, enabled: d.enabledByDefault }; +} + +// ── Accelerators ────────────────────────────────────────────────────────────── + +export interface Combo { + ctrl: boolean; + alt: boolean; + shift: boolean; + /** The Windows key / Super. */ + meta: boolean; + /** Canonical key name (see KEY_NAMES). */ + key: string; +} + +const PUNCTUATION: Record = { + Comma: ",", Period: ".", Slash: "/", Semicolon: ";", Quote: "'", Backquote: "`", + BracketLeft: "[", BracketRight: "]", Backslash: "\\", Minus: "-", Equal: "=", +}; + +const NAMED = [ + "Space", "Enter", "Tab", "Backspace", "Delete", "Insert", "Home", "End", + "PageUp", "PageDown", "Up", "Down", "Left", "Right", +]; + +/** Every main key an accelerator may name — all of them parse in Rust too. */ +export const KEY_NAMES: readonly string[] = [ + ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + ...Array.from({ length: 24 }, (_, i) => `F${i + 1}`), + ...NAMED, + ...Object.keys(PUNCTUATION), +]; + +const KEY_ALIASES: Record = { + ARROWUP: "Up", ARROWDOWN: "Down", ARROWLEFT: "Left", ARROWRIGHT: "Right", + RETURN: "Enter", ESC: "Escape", DEL: "Delete", " ": "Space", + ...Object.fromEntries(Object.entries(PUNCTUATION).map(([name, ch]) => [ch, name])), +}; + +function canonicalKey(token: string): string | null { + const up = token.toUpperCase(); + if (KEY_ALIASES[token]) return KEY_ALIASES[token]; + if (KEY_ALIASES[up]) return KEY_ALIASES[up]; + if (/^KEY[A-Z]$/.test(up)) return up.slice(3); + if (/^DIGIT[0-9]$/.test(up)) return up.slice(5); + return KEY_NAMES.find((k) => k.toUpperCase() === up) ?? null; +} + +/** + * "Ctrl+Alt+Space" → a Combo. Accepts the spellings Rust accepts (Control, + * Option, Super/Cmd, ArrowLeft…). `null` for anything else, and for a bare key: + * a global shortcut always needs Ctrl, Alt or the Windows key. + */ +export function parseKeys(text: string): Combo | null { + if (!text.trim()) return null; + // "Ctrl+Alt++" doesn't happen: the plus key is spelled Equal. + const tokens = text.split("+").map((t) => t.trim()); + if (tokens.some((t) => !t)) return null; + const combo: Combo = { ctrl: false, alt: false, shift: false, meta: false, key: "" }; + for (const [i, token] of tokens.entries()) { + const last = i === tokens.length - 1; + switch (token.toUpperCase()) { + case "CTRL": case "CONTROL": case "COMMANDORCONTROL": case "CMDORCTRL": + if (last) return null; + combo.ctrl = true; + continue; + case "ALT": case "OPTION": + if (last) return null; + combo.alt = true; + continue; + case "SHIFT": + if (last) return null; + combo.shift = true; + continue; + case "SUPER": case "META": case "WIN": case "CMD": case "COMMAND": + if (last) return null; + combo.meta = true; + continue; + } + if (!last) return null; + const key = canonicalKey(token); + if (!key || key === "Escape") return null; + combo.key = key; + } + if (!combo.key || !(combo.ctrl || combo.alt || combo.meta)) return null; + return combo; +} + +/** Combo → "Ctrl+Alt+Shift+Super+Key", the form stored and handed to Rust. */ +export function formatKeys(c: Combo): string { + const parts: string[] = []; + if (c.ctrl) parts.push("Ctrl"); + if (c.alt) parts.push("Alt"); + if (c.shift) parts.push("Shift"); + if (c.meta) parts.push("Super"); + parts.push(c.key); + return parts.join("+"); +} + +/** Canonical form of an accelerator, or null when it isn't one. */ +export function normalizeKeys(text: string): string | null { + const c = parseKeys(text); + return c ? formatKeys(c) : null; +} + +const KEY_GLYPHS: Record = { Up: "↑", Down: "↓", Left: "←", Right: "→", ...PUNCTUATION }; + +/** What a person reads: "Ctrl+Alt+→", "Ctrl+Shift+K", "Win+Space". */ +export function displayKeys(text: string): string { + const c = parseKeys(text); + if (!c) return text.trim() ? text : "—"; + const parts: string[] = []; + if (c.ctrl) parts.push("Ctrl"); + if (c.alt) parts.push("Alt"); + if (c.shift) parts.push("Shift"); + if (c.meta) parts.push("Win"); + parts.push(KEY_GLYPHS[c.key] ?? c.key); + return parts.join("+"); +} + +// ── Recording a key press ───────────────────────────────────────────────────── + +/** The parts of a KeyboardEvent the recorder reads. */ +export interface KeyPress { + key: string; + code: string; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + metaKey: boolean; +} + +export type Recorded = + /** Only modifiers so far: keep listening. */ + | { kind: "pending" } + | { kind: "cancel" } + | { kind: "clear" } + | { kind: "keys"; keys: string } + /** Needs Ctrl, Alt or the Windows key. */ + | { kind: "needsModifier" } + /** On this keyboard, Ctrl+Alt+key is AltGr and types `typed`. */ + | { kind: "typesCharacter"; typed: string; keys: string } + | { kind: "unsupported" }; + +const MODIFIER_KEYS = new Set(["Control", "Alt", "Shift", "Meta", "AltGraph", "OS", "Super", "Hyper"]); + +/** The main key of a press, by name. Letters follow the layout, so Ctrl+Alt+A + * is the key labelled A on AZERTY too — which is what RegisterHotKey and X11 + * grab. Everything else goes by its position. */ +function pressedKey(e: KeyPress): string | null { + if (/^[a-z]$/i.test(e.key)) return e.key.toUpperCase(); + if (/^Key[A-Z]$/.test(e.code)) return e.code.slice(3); + if (/^Digit[0-9]$/.test(e.code)) return e.code.slice(5); + if (/^Arrow(Up|Down|Left|Right)$/.test(e.code)) return e.code.slice(5); + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(e.code)) return e.code; + if (NAMED.includes(e.code) || e.code in PUNCTUATION) return e.code; + if (e.code === "NumpadEnter") return "Enter"; + return null; +} + +/** Turns a key press in the recorder into what to do with it. */ +export function recordPress(e: KeyPress): Recorded { + if (MODIFIER_KEYS.has(e.key)) return { kind: "pending" }; + const bare = !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey; + if (e.key === "Escape" && bare) return { kind: "cancel" }; + if ((e.key === "Backspace" || e.key === "Delete") && bare) return { kind: "clear" }; + const key = pressedKey(e); + if (!key) return { kind: "unsupported" }; + if (!(e.ctrlKey || e.altKey || e.metaKey)) return { kind: "needsModifier" }; + const keys = formatKeys({ ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey, key }); + // Ctrl+Alt is AltGr on Windows. When the press typed something other than a + // plain letter, digit or space, this combination is how the user types it. + if (e.ctrlKey && e.altKey && !e.metaKey) { + if (e.key === "Dead") return { kind: "typesCharacter", typed: "´", keys }; + if ([...e.key].length === 1 && !/^[a-z0-9 ]$/i.test(e.key)) { + return { kind: "typesCharacter", typed: e.key, keys }; + } + } + return { kind: "keys", keys }; +} + +// ── Conflicts ───────────────────────────────────────────────────────────────── + +/** Actions whose combination another enabled action also uses. */ +export function duplicates(entries: Iterable<[string, string]>): Set { + const seen = new Map(); + const dups = new Set(); + for (const [id, keys] of entries) { + const norm = normalizeKeys(keys); + if (!norm) continue; + const other = seen.get(norm); + if (other != null) { + dups.add(id); + dups.add(other); + } else { + seen.set(norm, id); + } + } + return dups; +} + +/** The (id, keys) pairs Coucou would register with `stored`. */ +export function activeKeys(stored: Bindings | undefined): [string, string][] { + return SHORTCUTS.filter((d) => d.ported) + .map((d) => [d.id, effective(d, stored)] as const) + .filter(([, b]) => b.enabled && b.keys) + .map(([id, b]) => [id, b.keys]); +} + +/** + * What AltGr (= Ctrl+Alt on Windows) types on the keys of the layouts the + * defaults were checked against: letters and digits only, the keys a shortcut + * is likely to use. Standard Windows layouts. + */ +export const ALTGR_CHARACTERS: Record> = { + "French (AZERTY)": { + E: "€", "2": "~", "3": "#", "4": "{", "5": "[", "6": "|", "7": "`", "8": "\\", "9": "^", "0": "@", + }, + "German (QWERTZ)": { Q: "@", E: "€", M: "µ", "2": "²", "3": "³", "7": "{", "8": "[", "9": "]", "0": "}" }, + Spanish: { E: "€", "1": "|", "2": "@", "3": "#", "4": "~", "5": "€", "6": "¬" }, + Italian: { E: "€", "5": "€" }, + Portuguese: { E: "€", "2": "@", "3": "£", "4": "§", "5": "€", "7": "{", "8": "[", "9": "]", "0": "}" }, + "Brazilian (ABNT2)": { + Q: "/", W: "?", E: "°", C: "₢", "1": "¹", "2": "²", "3": "³", "4": "£", "5": "¢", "6": "¬", + }, +}; + +/** The layouts on which `keys` would type a character instead of running. */ +export function altGrClashes(keys: string): string[] { + const c = parseKeys(keys); + if (!c || !c.ctrl || !c.alt || c.meta) return []; + return Object.entries(ALTGR_CHARACTERS) + .filter(([, chars]) => chars[c.key] != null) + .map(([layout]) => layout); +} + +// ── Inside the open island ──────────────────────────────────────────────────── + +/** Same as ShortcutLogic.navigate: next card-list selection, clamped. */ +export function navigate(selection: number | null, delta: number, itemCount: number): number | null { + if (itemCount <= 0) return null; + if (selection != null) return Math.max(0, Math.min(itemCount - 1, selection + delta)); + return delta > 0 ? 0 : itemCount - 1; +} + +/** The pill `delta` steps from `current`, wrapping around (cyclePill). */ +export function cyclePill(ids: readonly string[], current: string | null, delta: number): string | null { + if (ids.length === 0) return null; + const at = Math.max(0, current == null ? 0 : ids.indexOf(current)); + return ids[(((at + delta) % ids.length) + ids.length) % ids.length]; +} + +/** Pill number `n`, counting from 1 (switchToPill). */ +export function pillByNumber(ids: readonly string[], n: number): string | null { + return n >= 1 && n <= ids.length ? ids[n - 1] : null; +} + +/** Shown read-only in Settings, like ShortcutLogic.islandShortcuts. The Mac's + * ⌘↑ ⌘↓ ⌘O (card lists) and ⌘E (diff) have no counterpart here yet. */ +export const ISLAND_SHORTCUTS: readonly { keys: string; description: string }[] = [ + { keys: "Ctrl+→ / Ctrl+←", description: SHORTCUT_TEXT.island.nextPrev }, + { keys: "Ctrl+1 – Ctrl+9", description: SHORTCUT_TEXT.island.byNumber }, + { keys: "Ctrl+Enter", description: SHORTCUT_TEXT.island.send }, + { keys: "Ctrl+K", description: SHORTCUT_TEXT.island.newChat }, + { keys: "Ctrl+,", description: SHORTCUT_TEXT.island.settings }, + { keys: "Ctrl+P", description: SHORTCUT_TEXT.island.pin }, + { keys: "Esc", description: SHORTCUT_TEXT.island.close }, +]; + +export type IslandKeyAction = + | { kind: "cycle"; delta: 1 | -1 } + | { kind: "pill"; number: number } + | { kind: "newChat" } + | { kind: "settings" } + | { kind: "pin" }; + +/** + * A key pressed while the island has the keyboard → what it does, or null to + * leave it alone. Ctrl stands in for ⌘. In a text field Ctrl+← and Ctrl+→ + * keep moving by word. + */ +export function islandKeyAction( + e: KeyPress, + ctx: { view: string; inTextField: boolean }, +): IslandKeyAction | null { + if (!e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return null; + if (e.key === "ArrowRight" || e.key === "ArrowLeft") { + if (ctx.inTextField) return null; + return { kind: "cycle", delta: e.key === "ArrowRight" ? 1 : -1 }; + } + const digit = /^Digit([1-9])$/.exec(e.code); + if (digit) return { kind: "pill", number: Number(digit[1]) }; + const letter = /^[a-z]$/i.test(e.key) ? e.key.toLowerCase() : /^Key([A-Z])$/.exec(e.code)?.[1].toLowerCase(); + if (letter === "k") return ctx.view === "prompt" ? { kind: "newChat" } : null; + if (letter === "p") return { kind: "pin" }; + if (e.key === "," || e.code === "Comma") return { kind: "settings" }; + return null; +} diff --git a/windows/src/core/sound.ts b/windows/src/core/sound.ts index b5178554f..afe9342a3 100644 --- a/windows/src/core/sound.ts +++ b/windows/src/core/sound.ts @@ -1,5 +1,5 @@ // SoundEngine — port of SoundEngine.swift. -// The 28 WAVs are the macOS app's own files (see SOUNDS_DIR in vite.config.ts); +// The 29 WAVs are the macOS app's own files (see SOUNDS_DIR in vite.config.ts); // they are served at /sounds/.wav. Default volume 0.12, slider range 0–0.2, // exactly like the Mac player, and several sounds may overlap. @@ -7,7 +7,7 @@ export const SOUND_NAMES = [ "peek", "open", "close", "hover", "blip", "slap", "annoyed", "dizzy", "greet", "work", "finish", "error", "approval", "question", "approve", "gulp", "tick", "send", "love", "pop", "proud", "wink", "yawn", "attach", "think", "search", - "rate", "sleep", + "rate", "sleep", "greeting", ] as const; export type SoundName = (typeof SOUND_NAMES)[number]; @@ -21,6 +21,8 @@ class SoundEngine { private buffers = new Map(); private loading: Promise | null = null; private idleTimer: number | null = null; + /** Sounds still playing, by name, so one can be faded out (the greeting). */ + private playing = new Map>(); /** Creates the context and decodes every WAV. Safe to call more than once. */ preload(): Promise { @@ -97,9 +99,37 @@ class SoundEngine { if (ctx.state === "suspended") void ctx.resume(); const src = ctx.createBufferSource(); src.buffer = buf; - src.connect(master); + const gain = ctx.createGain(); + src.connect(gain); + gain.connect(master); + const voice = { src, gain }; + let voices = this.playing.get(name); + if (!voices) this.playing.set(name, (voices = new Set())); + voices.add(voice); + src.onended = () => { + voices.delete(voice); + gain.disconnect(); + }; src.start(); } + + /** Fades every playing copy of `name` to silence over `seconds`, then stops it. */ + fadeOut(name: string, seconds: number) { + const ctx = this.ctx; + const voices = this.playing.get(name); + if (!ctx || !voices) return; + const t = ctx.currentTime; + for (const { src, gain } of voices) { + gain.gain.cancelScheduledValues(t); + gain.gain.setValueAtTime(gain.gain.value, t); + gain.gain.linearRampToValueAtTime(0, t + seconds); + try { + src.stop(t + seconds); + } catch { + /* already stopped */ + } + } + } } export const Sound = new SoundEngine(); diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..0951ef015 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -2,6 +2,15 @@ import type { BotEmoteName, BotStateName, IslandMode, IslandViewName } from "./layout"; import type { EyeShape } from "../mochi/engine"; +import { + DEFAULT_MAIN_PILL, HOST_OS, availablePills, orderPills, pillDefinition, sanitizeDeclared, + toggleDeclared, type HostOs, type PillDefinition, +} from "./pills"; +import type { CodexPlanUsage, PlanUsage } from "./plan"; +import type { ProviderId } from "./providers"; +import type { FileDiff } from "./diff"; +import type { Bindings } from "./shortcuts"; +import { DEFAULT_OUTFIT, type Outfit } from "../mochi/wardrobe"; export type AgentSource = "claudeCode" | "n8n" | "agent"; export type PillBadge = "approval" | "finished" | "error"; @@ -13,19 +22,39 @@ export interface AgentTask { state: BotStateName; stepIndex: number; steps: string[]; + /** + * Position of the newest step in the whole session. `steps` is capped, so + * `stepIndex` stops moving once it is full; this keeps counting. + */ + stepSeq?: number; source: AgentSource; isIntegration: boolean; emote?: BotEmoteName | null; miniEye?: EyeShape | null; pillBadge?: PillBadge | null; sessionCwd?: string | null; + /** Claude Code's session, so "Open terminal" can find the window it runs in. */ + sessionId?: string | null; + /** Claude's final message after Stop, one line; cleared when a new turn starts. */ + finalLine?: string | null; } export interface ApprovalInfo { requestId: string; sessionId: string; + /** The pill the request belongs to: VS Code's or Cursor's (Claude Code), or an agent's (Codex…). */ + pillId: string; tool: string; command: string; + /** Set when Claude Code is asking a question rather than for a permission. */ + questions?: AskedQuestion[]; +} + +/** One question of an AskUserQuestion call. */ +export interface AskedQuestion { + question: string; + options: { label: string; description: string }[]; + multiSelect: boolean; } export interface ChatMessage { @@ -50,28 +79,13 @@ export interface SearchResult { note?: string; } -const task = ( - id: string, name: string, color: string, source: AgentSource, -): AgentTask => ({ - id, name, color, state: "idle", stepIndex: 0, steps: [], source, isIntegration: true, -}); - -/** AgentTask.integrationAgents — same ids, names and colours as macOS. */ -export const INTEGRATION_AGENTS: AgentTask[] = [ - task("integration_claude", "VS Code", "#F5F6F8", "claudeCode"), - task("integration_resend", "Resend", "#22C55E", "n8n"), - task("integration_n8n", "n8n", "#F29B38", "n8n"), - task("integration_vercel", "Vercel", "#7C5CFF", "n8n"), - task("integration_github", "GitHub", "#F4505E", "n8n"), - task("integration_notion", "Notion", "#8C8C8C", "n8n"), - task("integration_calcom", "Cal.com", "#C9956A", "n8n"), - task("integration_stripe", "Stripe", "#0570DE", "n8n"), -]; - -export const TOGGLEABLE_INTEGRATION_IDS = [ - "integration_resend", "integration_n8n", "integration_vercel", "integration_github", - "integration_notion", "integration_calcom", "integration_stripe", -]; +/** A fresh, idle task for a catalog pill. */ +function taskFor(def: PillDefinition, name = def.name): AgentTask { + return { + id: def.id, name, color: def.color, state: "idle", stepIndex: 0, steps: [], + source: def.source, isIntegration: true, + }; +} /** What an integration poller last reported. */ export interface IntegrationInfo { @@ -86,12 +100,47 @@ export interface Settings { soundVolume: number; autoCloseInterval: number; absenceInterval: number; + /** Declared pills next to the main one (at most 4), in the order they were added. */ activeIntegrations: string[]; - screen: "primary" | "cursor"; + /** The always-on workspace pill: VS Code, Cursor, Codex or Antigravity. */ + mainPill: string; + /** "primary", "cursor", or `at:,` for one display (logical origin). */ + screen: string; autostart: boolean; hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + /** Show the Claude plan pill (5 h and weekly limits) in the island's header. */ + showPlanInNotch: boolean; + /** Coucou's status line relay is installed in Claude Code's settings. */ + planRelayInstalled: boolean; + /** Show the Codex plan pill in the island's header. */ + showCodexPlanInNotch: boolean; + /** Who the chat talks to (see core/providers.ts); picked in the chat view. */ + chatProvider: ProviderId; + /** The model picked for each provider other than Anthropic, by provider id. */ + chatModels: Record; + /** Model server addresses once connected; empty means not connected. */ + ollamaUrl: string; + lmstudioUrl: string; + customUrl: string; + /** Global shortcuts the user changed, by action id (see core/shortcuts.ts). */ + shortcuts: Bindings; + /** + * Mochi's outfit: "auto" (dresses for the season), "none" or an outfit id. + * Same raw values as the Mac's "mochiOutfit"; read it through parseOutfit. + */ + mochiOutfit: string; + /** + * Interface language: "" follows the system (when Coucou has its language, + * else English), or one of src/i18n's ten codes ("fr", "pt-BR", "zh-Hans"…). + */ + language: string; + /** Mochi on the desktop. Rust owns it: whatever the page sends back is ignored. */ + desktopMochi?: { + onDesktop: boolean; + spot: { x: number; y: number; space: string } | null; + }; } export const DEFAULT_SETTINGS: Settings = { @@ -102,14 +151,31 @@ export const DEFAULT_SETTINGS: Settings = { activeIntegrations: [ "integration_resend", "integration_n8n", "integration_vercel", "integration_github", ], + mainPill: DEFAULT_MAIN_PILL, screen: "primary", autostart: false, hooksInstalled: false, model: "claude-opus-5", + showPlanInNotch: false, + planRelayInstalled: false, + showCodexPlanInNotch: false, + chatProvider: "anthropic", + chatModels: {}, + ollamaUrl: "", + lmstudioUrl: "", + customUrl: "", + shortcuts: {}, + mochiOutfit: DEFAULT_OUTFIT, + language: "", }; type Listener = () => void; +/** Live diffs kept per pill (oldest dropped first) — same cap as macOS. */ +export const MAX_DIFFS_PER_PILL = 50; +/** A pill's diffs are forgotten after an hour without a new one, as on macOS. */ +export const DIFF_TTL_MS = 3_600_000; + class AppState { mode: IslandMode = "hidden"; view: IslandViewName = "overview"; @@ -137,13 +203,40 @@ class AppState { searchResult: SearchResult | null = null; chatHistory: ChatMessage[] = []; pendingApproval: ApprovalInfo | null = null; + /** The pill that was in front when the card came up; it comes back after. */ + focusBeforeApproval: string | null = null; integrations: Record = {}; + /** Claude's 5 h / weekly limits, from the status line (null until the first call). */ + planUsage: PlanUsage | null = null; + /** Codex's limits, from `codex app-server` (null until it has answered). */ + codexPlanUsage: CodexPlanUsage | null = null; + /** A plan card is open in place of the overview's left card. */ + showingPlanDetail = false; + /** Which one: the Codex card rather than Claude's. */ + planDetailIsCodex = false; + /** Per-pill file diffs, in order of reception. Steps carry their ids. */ + sessionDiffs = new Map(); + private sessionDiffTimers = new Map(); + /** Never reset, so an id can never point at a newer diff than the one tapped. */ + private nextDiffId = 0; + /** + * Mochi is out of the island — on the desktop, flying, or being dragged + * there — so the island's own Mochi is hidden (AppState.mochiOnDesktop). + */ + mochiOnDesktop = false; + + /** Outfit shown on Mochi while the pointer rests on a wardrobe button. */ + wardrobePreview: Outfit | null = null; + lastActivity = performance.now(); settings: Settings = { ...DEFAULT_SETTINGS }; + /** Which pills this build offers depends on it (Claude Desktop is Windows only). */ + os: HostOs = HOST_OS; + private listeners = new Set(); subscribe(fn: Listener): () => void { @@ -172,10 +265,41 @@ class AppState { const t = this.tasks.find((x) => x.id === id); if (!t) return; this.focusId = id; + this.showingPlanDetail = false; t.pillBadge = null; this.notify(); } + /** + * A permission card or a question comes up: its pill comes to the front, and + * the pill that was there is remembered (HookServer.focusBeforeApproval). + */ + beginApproval(info: ApprovalInfo) { + this.pendingApproval = info; + this.isPinned = true; + if (this.focusBeforeApproval == null) this.focusBeforeApproval = this.focusId; + this.setFocus(info.pillId); + } + + /** + * The card has its answer, or is withdrawn: the session carries on, and the + * pill you were on comes back — unless you moved to another one meanwhile. + */ + endApproval() { + const req = this.pendingApproval; + if (!req) return; + this.pendingApproval = null; + this.isPinned = false; + this.updateTask(req.pillId, "working"); + this.setPillBadge(req.pillId, null); + const previous = this.focusBeforeApproval; + this.focusBeforeApproval = null; + if (previous && this.focusId === req.pillId && this.tasks.some((t) => t.id === previous)) { + this.focusId = previous; + } + this.notify(); + } + updateTask(id: string, state: BotStateName) { const t = this.tasks.find((x) => x.id === id); if (!t) return; @@ -186,9 +310,11 @@ class AppState { appendStep(id: string, step: string) { const t = this.tasks.find((x) => x.id === id); if (!t) return; + const newest = t.stepSeq ?? t.steps.length - 1; t.steps.push(step); if (t.steps.length > 20) t.steps.shift(); t.stepIndex = t.steps.length - 1; + t.stepSeq = newest + 1; this.notify(); } @@ -199,71 +325,151 @@ class AppState { this.notify(); } - /** loadIntegrationTasks() — VS Code always on, the rest opt-in (max 4). */ + /** Stores a diff for a pill and returns its id (for the ticker step). */ + appendSessionDiff(pillId: string, diff: FileDiff): number { + const id = this.nextDiffId++; + const list = this.sessionDiffs.get(pillId) ?? []; + list.push({ ...diff, id }); + while (list.length > MAX_DIFFS_PER_PILL) list.shift(); + this.sessionDiffs.set(pillId, list); + // One timer per pill, re-armed on every diff — nothing polls. + const prev = this.sessionDiffTimers.get(pillId); + if (prev != null) window.clearTimeout(prev); + this.sessionDiffTimers.set( + pillId, + window.setTimeout(() => this.clearSessionDiffs(pillId), DIFF_TTL_MS), + ); + return id; + } + + findDiff(pillId: string, id: number): FileDiff | null { + return this.sessionDiffs.get(pillId)?.find((d) => d.id === id) ?? null; + } + + clearSessionDiffs(pillId: string) { + const timer = this.sessionDiffTimers.get(pillId); + if (timer != null) window.clearTimeout(timer); + this.sessionDiffTimers.delete(pillId); + this.sessionDiffs.delete(pillId); + } + + /** The always-on workspace pill, once the setting has been checked. */ + get mainPillId(): string { + return sanitizeDeclared(this.settings, this.os).mainPill; + } + + /** + * True for a pill that stays when its session ends: the main pill and the + * declared ones go back to idle instead of going away. + */ + isKept(id: string): boolean { + const d = sanitizeDeclared(this.settings, this.os); + return id === d.mainPill || d.activeIntegrations.includes(id); + } + + /** + * Loads the catalog pills: the main pill always, the declared ones, and none + * of the others — a pill that is mid-session stays until its session ends. + * Safe to call any number of times. AppState.loadIntegrationTasks on macOS. + */ loadIntegrationTasks() { - for (const proto of INTEGRATION_AGENTS) { - const shouldLoad = - proto.id === "integration_claude" || this.settings.activeIntegrations.includes(proto.id); - const idx = this.tasks.findIndex((t) => t.id === proto.id); - if (shouldLoad && idx < 0) this.tasks.push({ ...proto, steps: [] }); - if (!shouldLoad && idx >= 0) this.tasks.splice(idx, 1); + const d = sanitizeDeclared(this.settings, this.os); + this.settings.mainPill = d.mainPill; + this.settings.activeIntegrations = d.activeIntegrations; + for (const def of availablePills(this.os)) { + const shouldLoad = def.id === d.mainPill || d.activeIntegrations.includes(def.id); + const idx = this.tasks.findIndex((t) => t.id === def.id); + if (shouldLoad && idx < 0) this.tasks.push(taskFor(def)); + const busy = idx >= 0 && (this.tasks[idx].state !== "idle" || this.tasks[idx].steps.length > 0); + if (!shouldLoad && idx >= 0 && !busy) this.tasks.splice(idx, 1); } - // Order: integration_claude first, then agent_* pills (visible in slice(0,4)), - // then other integrations in declaration order. - const order = INTEGRATION_AGENTS.map((t) => t.id); - this.tasks.sort((a, b) => { - const isAgentA = a.id.startsWith("agent_"); - const isAgentB = b.id.startsWith("agent_"); - // integration_claude always first - if (a.id === "integration_claude") return -1; - if (b.id === "integration_claude") return 1; - // agent_* before other integrations; preserve insertion order among themselves - if (isAgentA && !isAgentB) return -1; - if (isAgentB && !isAgentA) return 1; - if (isAgentA && isAgentB) return 0; - // both known integrations → declaration order - return order.indexOf(a.id) - order.indexOf(b.id); - }); - if (!this.focusId) this.focusId = "integration_claude"; + this.tasks = orderPills(this.tasks, d.mainPill); + if (!this.focusId || !this.tasks.some((t) => t.id === this.focusId)) this.focusId = d.mainPill; this.notify(); } + /** + * A session is over. The main and declared pills are put back as they were; + * any other pill goes away (AppState.removeTask on macOS). + */ removeTask(id: string) { const idx = this.tasks.findIndex((t) => t.id === id); if (idx < 0) return; + if (this.isKept(id)) { + const t = this.tasks[idx]; + t.state = "idle"; + t.steps = []; + t.stepIndex = 0; + delete t.stepSeq; + t.pillBadge = null; + t.finalLine = null; + const def = pillDefinition(id); + if (def) t.name = def.name; + this.clearSessionDiffs(id); + this.notify(); + return; + } this.tasks.splice(idx, 1); - if (this.focusId === id) this.focusId = this.tasks[0]?.id ?? "integration_claude"; + this.clearSessionDiffs(id); + if (this.focusId === id) this.focusId = this.tasks[0]?.id ?? this.mainPillId; this.notify(); } - /** Creates a dynamic agent_ pill on first event; no-ops if it already exists. - * Inserted right after integration_claude so it appears in the visible slice(0,4). */ + /** + * Creates the pill of a tagged agent on its first event; no-op if it exists. + * Inserted right after the main pill so it is in the visible slice(0,4). A + * catalog agent wears its catalog colour, as on macOS. + */ upsertExternalAgent(id: string, name: string, color: string) { if (this.tasks.some((t) => t.id === id)) return; - const at = this.tasks.findIndex((t) => t.id === "integration_claude") + 1; - this.tasks.splice(at, 0, { - id, name, color, + const def = pillDefinition(id); + this.insertAfterMain({ + id, name, color: def?.color ?? color, state: "idle", stepIndex: 0, steps: [], source: "agent", isIntegration: false, }); - if (!this.focusId) this.focusId = id; + } + + /** + * The pill a Claude Code session belongs to (VS Code or Cursor). It is made + * for the session when it is neither the main pill nor declared, as + * upsertWorkspaceTask does on macOS. + */ + upsertWorkspacePill(id: string, name: string, cwd: string): AgentTask | null { + let t = this.tasks.find((x) => x.id === id); + if (!t) { + const def = pillDefinition(id); + if (!def) return null; + t = taskFor(def, name); + this.insertAfterMain(t); + } + t.name = name; + if (cwd) t.sessionCwd = cwd; + return t; + } + + private insertAfterMain(t: AgentTask) { + const at = this.tasks.findIndex((x) => x.id === this.mainPillId) + 1; + this.tasks.splice(at, 0, t); + if (!this.focusId) this.focusId = t.id; this.notify(); } + /** Declares or undeclares a pill (max 4 next to the main one). */ toggleIntegration(id: string) { - if (id === "integration_claude") return; - const active = this.settings.activeIntegrations; - if (active.includes(id)) { - this.settings.activeIntegrations = active.filter((x) => x !== id); - if (this.focusId === id) this.focusId = "integration_claude"; - } else { - if (active.length >= 4) return; - this.settings.activeIntegrations = [...active, id]; - } + const next = toggleDeclared(sanitizeDeclared(this.settings, this.os), id, this.os); + if (!next) return; + this.settings.activeIntegrations = next; + if (!next.includes(id) && this.focusId === id) this.focusId = this.mainPillId; this.loadIntegrationTasks(); } + /** + * What the island opens on. A card waiting for an answer comes first, so + * reopening a folded island shows it again (Mac #117, #290). + */ defaultView(): IslandViewName { + if (this.pendingApproval) return this.pendingApproval.questions ? "question" : "approval"; return this.tasks.length === 0 ? "empty" : "overview"; } } diff --git a/windows/src/desktop/main.ts b/windows/src/desktop/main.ts new file mode 100644 index 000000000..34273e145 --- /dev/null +++ b/windows/src/desktop/main.ts @@ -0,0 +1,354 @@ +// The desktop Mochi's own window (mochi.html): draws him, and turns clicks and +// drags on him into pokes, flights home, the wardrobe and a new spot. Port of +// DesktopBotView + the mouse half of DesktopMochiController (DesktopMochi.swift). +// +// What he shows comes from the island (DESKTOP_EVENTS.state), where he is from +// Rust. The page draws only while the window is on screen, at 30 fps awake +// like the Mac, and a few frames a second asleep — with no cursor polling at +// all while he sleeps. + +import { Bridge, emitToWindow, onEvent, type DesktopMode } from "../core/bridge"; +import type { BotEmoteName } from "../core/layout"; +import { Sound } from "../core/sound"; +import { BotEngine } from "../mochi/engine"; +import { + DESKTOP_EVENTS, DOUBLE_CLICK_MS, DRAG_THRESHOLD, PANEL_SIZE, agentActive, gaze, isOverBody, + layerDragTopLeft, lookOrigin, pointerDistance, shouldSleep, windowDragTopLeft, + type DesktopSnapshot, type Point, +} from "../mochi/desktop-logic"; + +const ISLAND = "island"; + +/** + * Width Mochi is drawn at. The engine's body radius is 0.3 × width, so this + * gives the 28.8 px body the hit test uses, and leaves room around him for + * hats, hands and hearts inside the 120 px window. + */ +const DRAW_W = PANEL_SIZE * 0.8; +const SIDE = (PANEL_SIZE - DRAW_W) / 2; +/** Frame pacing: TimelineView(minimumInterval: 1/30) awake; asleep, barely. */ +const AWAKE_FRAME_MS = 1000 / 30; +const ASLEEP_FRAME_MS = 250; + +class DesktopMochi { + private engine = new BotEngine(); + private canvas: HTMLCanvasElement; + private mode: DesktopMode = "off"; + + private snap: DesktopSnapshot = { + state: "idle", outfit: "none", soundEnabled: true, soundVolume: 0.12, paused: false, + }; + + private visible = false; + private asleep = false; + /** Whether the island has told us anything yet. */ + private informed = false; + private lastAgentActive = performance.now(); + /** Pointer in window coordinates, and when the page last heard of it. */ + private cursor: Point | null = null; + private lastPointer = -Infinity; + + private timer: number | null = null; + private lastDraw = 0; + + /** Canvas offset inside the window: non-zero only in a layer-shell drag. */ + private offset: Point = { x: 0, y: 0 }; + private press: { client: Point; screen: Point } | null = null; + private dragging = false; + /** Window's top-left corner when the drag began (mode's space). */ + private dragOrigin: Point | null = null; + private dragTopLeft: Point | null = null; + private moveFrame = false; + private pokeTimer: number | null = null; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + const dpr = this.dpr(); + canvas.width = Math.round(PANEL_SIZE * dpr); + canvas.height = Math.round(PANEL_SIZE * dpr); + this.engine.particleOverhang = 0; + this.engine.setState("idle", true); + // Three pokes: dizzy, and the island shows the confused view (as on macOS). + this.engine.onDizzy = () => void emitToWindow(ISLAND, DESKTOP_EVENTS.dizzy); + this.wireInput(); + } + + async start() { + void Sound.preload(); + const info = await Bridge.desktopInfo(); + if (info) this.mode = info.mode; + + await onEvent(DESKTOP_EVENTS.state, (s) => this.onSnapshot(s)); + await onEvent<{ emote: BotEmoteName; duration: number }>(DESKTOP_EVENTS.emote, (e) => { + this.lastAgentActive = performance.now(); + this.wake(); + this.engine.triggerEmote(e.emote, e.duration); + this.schedule(); + }); + await onEvent(DESKTOP_EVENTS.cursor, (p) => this.notePointer(p)); + await onEvent(DESKTOP_EVENTS.visible, (on) => this.setVisible(on)); + await onEvent(DESKTOP_EVENTS.flight, (kind) => { + // He fades in on the way out of the island (alphaValue 0 → 1 on macOS). + if (kind !== "out") return; + this.canvas.style.transition = "none"; + this.canvas.style.opacity = "0"; + void this.canvas.offsetWidth; + this.canvas.style.transition = ""; + this.canvas.style.opacity = "1"; + }); + await emitToWindow(ISLAND, DESKTOP_EVENTS.ready); + } + + // ── State ─────────────────────────────────────────────────────────────────── + + private onSnapshot(s: DesktopSnapshot) { + this.snap = s; + this.informed = true; + Sound.setEnabled(s.soundEnabled); + Sound.setVolume(s.soundVolume); + if (agentActive(s.state)) this.lastAgentActive = performance.now(); + if (!this.asleep) this.engine.setState(s.state); + this.updateSleep(performance.now()); + this.schedule(); + } + + private setVisible(on: boolean) { + this.visible = on; + if (on) { + // The two pages may have started in either order: ask again if needed. + if (!this.informed) void emitToWindow(ISLAND, DESKTOP_EVENTS.ready); + // Landing counts as something going on: he looks around for a while. + this.lastAgentActive = performance.now(); + this.asleep = false; + this.engine.setState(this.snap.state, true); + this.engine.setOutfit(this.snap.outfit, false); + this.schedule(); + return; + } + this.cancelPoke(); + this.press = null; + this.dragging = false; + this.setOffset({ x: 0, y: 0 }); + if (this.timer != null) window.clearTimeout(this.timer); + this.timer = null; + Sound.idle(); + } + + private updateSleep(now: number) { + if (!this.visible) return; + const distance = pointerDistance(this.mode === "poll", this.cursor, now - this.lastPointer); + const sleep = + !this.dragging && + (this.snap.paused || shouldSleep((now - this.lastAgentActive) / 1000, distance)); + if (sleep === this.asleep) return; + this.asleep = sleep; + this.engine.setState(sleep ? "sleeping" : this.snap.state); + // Asleep, Rust stops the cursor poll; his whole little window then listens + // for the pointer, and the first move over it wakes him. + void Bridge.desktopSetAsleep(sleep); + if (sleep) Sound.idle(); + this.schedule(); + } + + private wake() { + if (!this.asleep) return; + this.updateSleep(performance.now()); + } + + /** Pointer in window coordinates: his eyes, and waking up. */ + private notePointer(p: Point) { + this.cursor = p; + this.lastPointer = performance.now(); + if (this.asleep) this.wake(); + this.schedule(); + } + + // ── Frames ────────────────────────────────────────────────────────────────── + + private schedule() { + if (this.timer != null || !this.visible) return; + const wait = this.asleep ? ASLEEP_FRAME_MS : AWAKE_FRAME_MS; + const due = Math.max(0, this.lastDraw + wait - performance.now()); + this.timer = window.setTimeout(() => { + // Awake, frames line up with the display; asleep, a timer is enough. + if (this.asleep) { + this.timer = null; + this.frame(performance.now()); + } else { + requestAnimationFrame((t) => { + this.timer = null; + this.frame(t); + }); + } + }, due); + } + + private frame(now: number) { + if (!this.visible) return; + const dt = Math.min(0.05, Math.max(0, (now - this.lastDraw) / 1000)); + this.lastDraw = now; + this.updateSleep(now); + this.draw(dt, now); + this.schedule(); + } + + private draw(dt: number, now: number) { + const ctx = this.canvas.getContext("2d"); + if (!ctx) return; + const engine = this.engine; + engine.setOutfit(this.snap.outfit, true); + if (!this.asleep) { + // Windows: the global cursor. Linux: only while the pointer is over him. + const fresh = this.mode === "poll" || now - this.lastPointer < 1500; + const g = this.cursor && fresh ? gaze(lookOrigin(0, 0), this.cursor) : { lookX: 0, lookY: 0 }; + engine.lookX = g.lookX; + engine.lookY = g.lookY; + } + // No dancing: the Windows and Linux app has no music signal to dance to. + engine.update(dt); + const dpr = this.dpr(); + ctx.setTransform(dpr, 0, 0, dpr, SIDE * dpr, 0); + ctx.clearRect(-SIDE, 0, PANEL_SIZE, PANEL_SIZE); + engine.draw(ctx, DRAW_W, PANEL_SIZE); + } + + private dpr(): number { + return Math.min(2, window.devicePixelRatio || 1); + } + + // ── Input ─────────────────────────────────────────────────────────────────── + + private local(e: MouseEvent): Point { + return { x: e.clientX - this.offset.x, y: e.clientY - this.offset.y }; + } + + private wireInput() { + document.addEventListener("pointerdown", (e) => { + Sound.resume(); + if (e.button !== 0 || !isOverBody(this.local(e))) return; + this.press = { client: this.local(e), screen: { x: e.screenX, y: e.screenY } }; + this.dragging = false; + try { + document.documentElement.setPointerCapture(e.pointerId); + } catch { + /* capture is a nicety */ + } + }); + + document.addEventListener("pointermove", (e) => { + if (!this.dragging) this.notePointer(this.local(e)); + const press = this.press; + if (!press) return; + if (!this.dragging) { + const l = this.local(e); + if (Math.hypot(l.x - press.client.x, l.y - press.client.y) <= DRAG_THRESHOLD) return; + this.beginDrag(); + } + this.dragTo(e); + }); + + document.addEventListener("pointerup", (e) => { + const press = this.press; + this.press = null; + if (!press) return; + if (this.dragging) { + this.endDrag(e); + return; + } + // Single click → poke, once it is clear it isn't a double click. + this.cancelPoke(); + this.pokeTimer = window.setTimeout(() => { + this.pokeTimer = null; + this.lastAgentActive = performance.now(); + this.wake(); + this.engine.slap(); + this.schedule(); + }, DOUBLE_CLICK_MS); + }); + + document.addEventListener("dblclick", (e) => { + if (!isOverBody(this.local(e))) return; + this.cancelPoke(); + void emitToWindow(ISLAND, DESKTOP_EVENTS.home); + }); + + document.addEventListener("contextmenu", (e) => { + e.preventDefault(); + if (isOverBody(this.local(e))) void emitToWindow(ISLAND, DESKTOP_EVENTS.wardrobe); + }); + + document.addEventListener("pointerleave", () => { + if (this.mode !== "poll" && !this.dragging) this.cursor = null; + }); + + // A layer-shell drag stretches the window over the display; once it is + // back to its own size, so is the canvas. + window.addEventListener("resize", () => { + if (!this.dragging && window.innerWidth <= PANEL_SIZE + 20) this.setOffset({ x: 0, y: 0 }); + }); + } + + private cancelPoke() { + if (this.pokeTimer != null) window.clearTimeout(this.pokeTimer); + this.pokeTimer = null; + } + + private beginDrag() { + this.dragging = true; + this.cancelPoke(); + this.dragOrigin = null; + this.dragTopLeft = null; + void Bridge.desktopDragBegin().then((pos) => { + if (!pos) { + this.dragging = false; + this.press = null; + return; + } + this.dragOrigin = { x: pos[0], y: pos[1] }; + }); + } + + /** Linux: the page drives the drag. Windows: the cursor poll carries him. */ + private dragTo(e: PointerEvent) { + const origin = this.dragOrigin; + const press = this.press; + if (!origin || !press || this.mode === "poll") return; + if (this.mode === "layer") { + const applied = window.innerWidth > PANEL_SIZE + 20; + const topLeft = layerDragTopLeft(origin, { x: e.clientX, y: e.clientY }, press.client, applied); + this.dragTopLeft = topLeft; + this.setOffset(applied ? topLeft : { x: topLeft.x - origin.x, y: topLeft.y - origin.y }); + return; + } + if (this.mode === "window") { + const topLeft = windowDragTopLeft(origin, { x: e.screenX, y: e.screenY }, press.screen, this.dpr()); + this.dragTopLeft = topLeft; + if (this.moveFrame) return; + this.moveFrame = true; + requestAnimationFrame(() => { + this.moveFrame = false; + const p = this.dragTopLeft; + if (p && this.dragging) void Bridge.desktopDragMove(p.x, p.y); + }); + } + } + + private endDrag(e: PointerEvent) { + this.dragTo(e); + this.dragging = false; + const topLeft = this.dragTopLeft; + this.dragOrigin = null; + this.dragTopLeft = null; + if (this.mode === "poll" || !topLeft) return; + void Bridge.desktopDragEnd(Math.round(topLeft.x), Math.round(topLeft.y)); + } + + private setOffset(p: Point) { + this.offset = p; + this.canvas.style.left = `${p.x}px`; + this.canvas.style.top = `${p.y}px`; + } +} + +const canvas = document.getElementById("mochi"); +if (canvas instanceof HTMLCanvasElement) void new DesktopMochi(canvas).start(); diff --git a/windows/src/i18n/extra.json b/windows/src/i18n/extra.json new file mode 100644 index 000000000..b339dfc0b --- /dev/null +++ b/windows/src/i18n/extra.json @@ -0,0 +1,3321 @@ +{ + "_about": "Strings only Windows and Linux show, keyed by their English text like the Mac's catalog, with every language but English. Written by hand; the Mac's strings are in strings.json (generated by scripts/gen-strings.mjs). See src/i18n/i18n.ts.", + "strings": { + " · until {date}": { + "zh-Hans": " · 截至 {date}", + "hi": " · {date} तक", + "es": " · hasta el {date}", + "ar": " · حتى {date}", + "fr": " · jusqu'au {date}", + "bn": " · {date} পর্যন্ত", + "pt-BR": " · até {date}", + "ru": " · до {date}", + "id": " · hingga {date}" + }, + "(stored)": { + "zh-Hans": "(已保存)", + "hi": "(सहेजी गई)", + "es": "(guardada)", + "ar": "(محفوظ)", + "fr": "(enregistrée)", + "bn": "(সংরক্ষিত)", + "pt-BR": "(salva)", + "ru": "(сохранён)", + "id": "(tersimpan)" + }, + "+ subagent": { + "zh-Hans": "+ 子代理", + "hi": "+ सब-एजेंट", + "es": "+ subagente", + "ar": "+ وكيل فرعي", + "fr": "+ sous-agent", + "bn": "+ সাব-এজেন্ট", + "pt-BR": "+ subagente", + "ru": "+ субагент", + "id": "+ subagen" + }, + "A hook group named \"coucou\" that Coucou did not write is already there — Coucou has not touched it.": { + "zh-Hans": "已存在一个并非 Coucou 写入的名为“coucou”的 hook 组 — Coucou 没有修改它。", + "hi": "“coucou” नाम का एक hook समूह पहले से है जिसे Coucou ने नहीं लिखा — Coucou ने इसे नहीं छुआ।", + "es": "Ya existe un grupo de hooks llamado «coucou» que no escribió Coucou — Coucou no lo ha tocado.", + "ar": "توجد بالفعل مجموعة hooks باسم «coucou» لم يكتبها Coucou — لم يمسّها Coucou.", + "fr": "Un groupe de hooks nommé « coucou » que Coucou n'a pas écrit existe déjà — Coucou n'y a pas touché.", + "bn": "“coucou” নামে একটি hook গ্রুপ আগে থেকেই আছে যা Coucou লেখেনি — Coucou এতে হাত দেয়নি।", + "pt-BR": "Já existe um grupo de hooks chamado “coucou” que não foi escrito pelo Coucou — o Coucou não mexeu nele.", + "ru": "Уже есть группа хуков «coucou», которую написал не Coucou, — Coucou её не трогал.", + "id": "Sudah ada grup hook bernama “coucou” yang tidak ditulis Coucou — Coucou tidak menyentuhnya." + }, + "AI for the chat": { + "zh-Hans": "聊天用的 AI", + "hi": "चैट के लिए AI", + "es": "IA para el chat", + "ar": "الذكاء الاصطناعي للدردشة", + "fr": "IA pour le chat", + "bn": "চ্যাটের জন্য AI", + "pt-BR": "IA para o chat", + "ru": "ИИ для чата", + "id": "AI untuk obrolan" + }, + "API error {code}": { + "zh-Hans": "API 错误 {code}", + "hi": "API त्रुटि {code}", + "es": "Error de API {code}", + "ar": "خطأ في الواجهة البرمجية {code}", + "fr": "Erreur d'API {code}", + "bn": "API ত্রুটি {code}", + "pt-BR": "Erro de API {code}", + "ru": "Ошибка API {code}", + "id": "Galat API {code}" + }, + "API key": { + "zh-Hans": "API 密钥", + "hi": "API कुंजी", + "es": "Clave de API", + "ar": "مفتاح API", + "fr": "Clé d'API", + "bn": "API কী", + "pt-BR": "Chave de API", + "ru": "API-ключ", + "id": "Kunci API" + }, + "API key (optional)": { + "zh-Hans": "API 密钥(可选)", + "hi": "API कुंजी (वैकल्पिक)", + "es": "Clave de API (opcional)", + "ar": "مفتاح API (اختياري)", + "fr": "Clé d'API (facultative)", + "bn": "API কী (ঐচ্ছিক)", + "pt-BR": "Chave de API (opcional)", + "ru": "API-ключ (необязательно)", + "id": "Kunci API (opsional)" + }, + "API key missing. Open settings.": { + "zh-Hans": "缺少 API 密钥。请打开设置。", + "hi": "API कुंजी नहीं है। सेटिंग्स खोलें।", + "es": "Falta la clave de API. Abre la configuración.", + "ar": "مفتاح API مفقود. افتح الإعدادات.", + "fr": "Clé d'API manquante. Ouvre les réglages.", + "bn": "API কী নেই। সেটিংস খুলুন।", + "pt-BR": "Falta a chave de API. Abra as configurações.", + "ru": "Нет API-ключа. Откройте настройки.", + "id": "Kunci API tidak ada. Buka pengaturan." + }, + "Answer in terminal": { + "zh-Hans": "在终端中回答", + "hi": "टर्मिनल में जवाब दें", + "es": "Responder en la terminal", + "ar": "الرد في الطرفية", + "fr": "Répondre dans le terminal", + "bn": "টার্মিনালে উত্তর দিন", + "pt-BR": "Responder no terminal", + "ru": "Ответить в терминале", + "id": "Jawab di terminal" + }, + "Answer it in your terminal.": { + "zh-Hans": "请在终端中回答。", + "hi": "इसका जवाब अपने टर्मिनल में दें।", + "es": "Respóndela en tu terminal.", + "ar": "أجب عنه في الطرفية.", + "fr": "Réponds-y dans ton terminal.", + "bn": "আপনার টার্মিনালে উত্তর দিন।", + "pt-BR": "Responda no seu terminal.", + "ru": "Ответьте в терминале.", + "id": "Jawab di terminal Anda." + }, + "Ask": { + "zh-Hans": "提问", + "hi": "पूछें", + "es": "Preguntar", + "ar": "اسأل", + "fr": "Demander", + "bn": "জিজ্ঞেস করুন", + "pt-BR": "Perguntar", + "ru": "Спросить", + "id": "Tanya" + }, + "Asking Codex…": { + "zh-Hans": "正在询问 Codex…", + "hi": "Codex से पूछ रहे हैं…", + "es": "Consultando a Codex…", + "ar": "جارٍ سؤال Codex…", + "fr": "Interrogation de Codex…", + "bn": "Codex-কে জিজ্ঞেস করা হচ্ছে…", + "pt-BR": "Consultando o Codex…", + "ru": "Запрос к Codex…", + "id": "Menanyakan Codex…" + }, + "Attach the front window to the chat": { + "zh-Hans": "将前台窗口附加到聊天", + "hi": "सामने वाली विंडो को चैट से जोड़ें", + "es": "Adjuntar la ventana activa al chat", + "ar": "إرفاق النافذة الأمامية بالدردشة", + "fr": "Joindre la fenêtre au premier plan au chat", + "bn": "সামনের উইন্ডোটি চ্যাটে যুক্ত করুন", + "pt-BR": "Anexar a janela em primeiro plano ao chat", + "ru": "Прикрепить активное окно к чату", + "id": "Lampirkan jendela depan ke obrolan" + }, + "Auto (seasons)": { + "zh-Hans": "自动(随季节)", + "hi": "ऑटो (मौसम के अनुसार)", + "es": "Auto (estaciones)", + "ar": "تلقائي (حسب المواسم)", + "fr": "Auto (saisons)", + "bn": "অটো (ঋতু অনুযায়ী)", + "pt-BR": "Auto (estações)", + "ru": "Авто (по сезону)", + "id": "Otomatis (musim)" + }, + "Auto · follows the seasons (now: {outfit})": { + "zh-Hans": "自动 · 随季节变化(当前:{outfit})", + "hi": "ऑटो · मौसम के साथ बदलता है (अभी: {outfit})", + "es": "Auto · sigue las estaciones (ahora: {outfit})", + "ar": "تلقائي · يتبع المواسم (الآن: {outfit})", + "fr": "Auto · suit les saisons (en ce moment : {outfit})", + "bn": "অটো · ঋতু অনুযায়ী বদলায় (এখন: {outfit})", + "pt-BR": "Auto · acompanha as estações (agora: {outfit})", + "ru": "Авто · по сезону (сейчас: {outfit})", + "id": "Otomatis · mengikuti musim (sekarang: {outfit})" + }, + "Auto · {outfit}": { + "zh-Hans": "自动 · {outfit}", + "hi": "ऑटो · {outfit}", + "es": "Auto · {outfit}", + "ar": "تلقائي · {outfit}", + "fr": "Auto · {outfit}", + "bn": "অটো · {outfit}", + "pt-BR": "Auto · {outfit}", + "ru": "Авто · {outfit}", + "id": "Otomatis · {outfit}" + }, + "Auto-close": { + "zh-Hans": "自动关闭", + "hi": "अपने आप बंद", + "es": "Cierre automático", + "ar": "الإغلاق التلقائي", + "fr": "Fermeture auto", + "bn": "স্বয়ংক্রিয় বন্ধ", + "pt-BR": "Fechar sozinho", + "ru": "Автозакрытие", + "id": "Tutup otomatis" + }, + "Back": { + "zh-Hans": "返回", + "hi": "वापस", + "es": "Atrás", + "ar": "رجوع", + "fr": "Retour", + "bn": "ফিরে যান", + "pt-BR": "Voltar", + "ru": "Назад", + "id": "Kembali" + }, + "Back up and remove": { + "zh-Hans": "备份并移除", + "hi": "बैकअप लें और हटाएँ", + "es": "Respaldar y quitar", + "ar": "النسخ الاحتياطي ثم الإزالة", + "fr": "Sauvegarder et retirer", + "bn": "ব্যাকআপ নিয়ে সরান", + "pt-BR": "Fazer backup e remover", + "ru": "Сохранить копию и удалить", + "id": "Cadangkan dan hapus" + }, + "Back up and write": { + "zh-Hans": "备份并写入", + "hi": "बैकअप लें और लिखें", + "es": "Respaldar y escribir", + "ar": "النسخ الاحتياطي ثم الكتابة", + "fr": "Sauvegarder et écrire", + "bn": "ব্যাকআপ নিয়ে লিখুন", + "pt-BR": "Fazer backup e gravar", + "ru": "Сохранить копию и записать", + "id": "Cadangkan dan tulis" + }, + "Backup of {path} failed, nothing was written: {error}": { + "zh-Hans": "{path} 备份失败,未写入任何内容:{error}", + "hi": "{path} का बैकअप विफल रहा, कुछ नहीं लिखा गया: {error}", + "es": "Falló la copia de seguridad de {path}, no se escribió nada: {error}", + "ar": "فشل النسخ الاحتياطي لـ {path}، لم يُكتب أي شيء: {error}", + "fr": "La sauvegarde de {path} a échoué, rien n'a été écrit : {error}", + "bn": "{path}-এর ব্যাকআপ ব্যর্থ, কিছু লেখা হয়নি: {error}", + "pt-BR": "O backup de {path} falhou, nada foi gravado: {error}", + "ru": "Не удалось сохранить копию {path}, ничего не записано: {error}", + "id": "Pencadangan {path} gagal, tidak ada yang ditulis: {error}" + }, + "Backup → {path}": { + "zh-Hans": "备份 → {path}", + "hi": "बैकअप → {path}", + "es": "Copia de seguridad → {path}", + "ar": "النسخة الاحتياطية ← {path}", + "fr": "Sauvegarde → {path}", + "bn": "ব্যাকআপ → {path}", + "pt-BR": "Backup → {path}", + "ru": "Резервная копия → {path}", + "id": "Cadangan → {path}" + }, + "Bad API response: {error}": { + "zh-Hans": "API 响应无效:{error}", + "hi": "API का जवाब गलत है: {error}", + "es": "Respuesta de la API no válida: {error}", + "ar": "استجابة غير صالحة من الواجهة البرمجية: {error}", + "fr": "Réponse de l'API invalide : {error}", + "bn": "API-র উত্তর ঠিক নয়: {error}", + "pt-BR": "Resposta da API inválida: {error}", + "ru": "Некорректный ответ API: {error}", + "id": "Respons API tidak valid: {error}" + }, + "Beanie": { + "zh-Hans": "毛线帽", + "hi": "ऊनी टोपी", + "es": "Gorro de lana", + "ar": "قبعة صوفية", + "fr": "Bonnet", + "bn": "উলের টুপি", + "pt-BR": "Gorro", + "ru": "Шапка", + "id": "Kupluk" + }, + "Bow": { + "zh-Hans": "蝴蝶结", + "hi": "बो", + "es": "Lazo", + "ar": "فيونكة", + "fr": "Nœud", + "bn": "বো", + "pt-BR": "Laço", + "ru": "Бантик", + "id": "Pita" + }, + "Bunny ears": { + "zh-Hans": "兔耳朵", + "hi": "खरगोश के कान", + "es": "Orejas de conejo", + "ar": "أذنا أرنب", + "fr": "Oreilles de lapin", + "bn": "খরগোশের কান", + "pt-BR": "Orelhas de coelho", + "ru": "Заячьи ушки", + "id": "Telinga kelinci" + }, + "Can't read {path}: {error}": { + "zh-Hans": "无法读取 {path}:{error}", + "hi": "{path} पढ़ा नहीं जा सकता: {error}", + "es": "No se puede leer {path}: {error}", + "ar": "تعذّرت قراءة {path}: {error}", + "fr": "Impossible de lire {path} : {error}", + "bn": "{path} পড়া যাচ্ছে না: {error}", + "pt-BR": "Não é possível ler {path}: {error}", + "ru": "Не удаётся прочитать {path}: {error}", + "id": "Tidak dapat membaca {path}: {error}" + }, + "Canceled": { + "zh-Hans": "已取消", + "hi": "रद्द", + "es": "Cancelado", + "ar": "أُلغي", + "fr": "Annulé", + "bn": "বাতিল", + "pt-BR": "Cancelado", + "ru": "Отменено", + "id": "Dibatalkan" + }, + "Cannot copy: {error}": { + "zh-Hans": "无法复制:{error}", + "hi": "कॉपी नहीं हो सका: {error}", + "es": "No se puede copiar: {error}", + "ar": "تعذّر النسخ: {error}", + "fr": "Copie impossible : {error}", + "bn": "কপি করা যায়নি: {error}", + "pt-BR": "Não é possível copiar: {error}", + "ru": "Не удаётся скопировать: {error}", + "id": "Tidak dapat menyalin: {error}" + }, + "Cannot reach {url}. Is the server running?": { + "zh-Hans": "无法连接到 {url}。服务器在运行吗?", + "hi": "{url} तक नहीं पहुँचा जा सका। क्या सर्वर चल रहा है?", + "es": "No se puede contactar con {url}. ¿Está funcionando el servidor?", + "ar": "تعذّر الوصول إلى {url}. هل الخادم قيد التشغيل؟", + "fr": "Impossible de joindre {url}. Le serveur est-il lancé ?", + "bn": "{url}-এ পৌঁছানো যাচ্ছে না। সার্ভার কি চলছে?", + "pt-BR": "Não foi possível alcançar {url}. O servidor está rodando?", + "ru": "Не удаётся подключиться к {url}. Сервер запущен?", + "id": "Tidak dapat menjangkau {url}. Apakah servernya berjalan?" + }, + "Cannot read {path}: {error}": { + "zh-Hans": "无法读取 {path}:{error}", + "hi": "{path} पढ़ा नहीं जा सकता: {error}", + "es": "No se puede leer {path}: {error}", + "ar": "تعذّرت قراءة {path}: {error}", + "fr": "Impossible de lire {path} : {error}", + "bn": "{path} পড়া যাচ্ছে না: {error}", + "pt-BR": "Não é possível ler {path}: {error}", + "ru": "Не удаётся прочитать {path}: {error}", + "id": "Tidak dapat membaca {path}: {error}" + }, + "Chat providers": { + "zh-Hans": "聊天服务商", + "hi": "चैट प्रदाता", + "es": "Proveedores de chat", + "ar": "مزوّدو الدردشة", + "fr": "Fournisseurs de chat", + "bn": "চ্যাট প্রদানকারী", + "pt-BR": "Provedores de chat", + "ru": "Провайдеры чата", + "id": "Penyedia obrolan" + }, + "Chat with Google AI, OpenAI or OpenRouter instead of Claude: add a key here, then click the model name above the chat box to switch provider and model. Keys stay in the system keychain. These providers get no web search and no tools: they can answer, never act on this computer.": { + "zh-Hans": "用 Google AI、OpenAI 或 OpenRouter 代替 Claude 聊天:在这里添加密钥,然后点击聊天框上方的模型名称来切换服务商和模型。密钥保存在系统钥匙串中。这些服务商没有网页搜索和工具:它们只能回答,绝不会在这台电脑上执行操作。", + "hi": "Claude की जगह Google AI, OpenAI या OpenRouter से चैट करें: यहाँ एक कुंजी जोड़ें, फिर प्रदाता और मॉडल बदलने के लिए चैट बॉक्स के ऊपर मॉडल के नाम पर क्लिक करें। कुंजियाँ सिस्टम कीचेन में रहती हैं। इन प्रदाताओं को न वेब सर्च मिलता है न टूल: वे जवाब दे सकते हैं, इस कंप्यूटर पर कभी कुछ नहीं करते।", + "es": "Chatea con Google AI, OpenAI u OpenRouter en lugar de Claude: añade una clave aquí y luego haz clic en el nombre del modelo encima del cuadro de chat para cambiar de proveedor y modelo. Las claves se quedan en el llavero del sistema. Estos proveedores no tienen búsqueda web ni herramientas: pueden responder, nunca actuar en este equipo.", + "ar": "دردش مع Google AI أو OpenAI أو OpenRouter بدلًا من Claude: أضف مفتاحًا هنا، ثم انقر على اسم النموذج فوق مربع الدردشة لتبديل المزوّد والنموذج. تبقى المفاتيح في سلسلة مفاتيح النظام. لا يحصل هؤلاء المزوّدون على بحث الويب ولا على الأدوات: يمكنهم الإجابة، ولا يتصرفون أبدًا على هذا الحاسوب.", + "fr": "Discute avec Google AI, OpenAI ou OpenRouter à la place de Claude : ajoute une clé ici, puis clique sur le nom du modèle au-dessus de la zone de chat pour changer de fournisseur et de modèle. Les clés restent dans le trousseau du système. Ces fournisseurs n'ont ni recherche web ni outils : ils peuvent répondre, jamais agir sur cet ordinateur.", + "bn": "Claude-এর বদলে Google AI, OpenAI বা OpenRouter-এর সঙ্গে চ্যাট করুন: এখানে একটি কী যোগ করুন, তারপর প্রদানকারী ও মডেল বদলাতে চ্যাট বক্সের উপরে মডেলের নামে ক্লিক করুন। কী সিস্টেম কীচেইনে থাকে। এই প্রদানকারীরা ওয়েব সার্চ বা টুল পায় না: তারা উত্তর দিতে পারে, এই কম্পিউটারে কখনো কিছু করে না।", + "pt-BR": "Converse com Google AI, OpenAI ou OpenRouter em vez do Claude: adicione uma chave aqui e clique no nome do modelo acima da caixa de chat para trocar de provedor e de modelo. As chaves ficam no chaveiro do sistema. Esses provedores não têm busca na web nem ferramentas: podem responder, nunca agir neste computador.", + "ru": "Общайтесь с Google AI, OpenAI или OpenRouter вместо Claude: добавьте ключ здесь, затем нажмите на название модели над полем чата, чтобы сменить провайдера и модель. Ключи хранятся в системной связке ключей. У этих провайдеров нет веб-поиска и инструментов: они могут отвечать, но никогда не действуют на этом компьютере.", + "id": "Mengobrol dengan Google AI, OpenAI, atau OpenRouter sebagai ganti Claude: tambahkan kunci di sini, lalu klik nama model di atas kotak obrolan untuk mengganti penyedia dan model. Kunci tetap di keychain sistem. Penyedia ini tidak punya pencarian web maupun alat: mereka bisa menjawab, tidak pernah bertindak di komputer ini." + }, + "Chat with a model you run yourself: Ollama or LM Studio (leave the address empty for the usual one on this computer), or any server that speaks the OpenAI API, such as vLLM or llama.cpp. Once connected, pick it above the chat box.": { + "zh-Hans": "与你自己运行的模型聊天:Ollama 或 LM Studio(地址留空即使用本机的默认地址),或任何兼容 OpenAI API 的服务器,例如 vLLM 或 llama.cpp。连接后,在聊天框上方选择它。", + "hi": "अपने चलाए मॉडल से चैट करें: Ollama या LM Studio (इस कंप्यूटर के सामान्य पते के लिए पता खाली छोड़ें), या OpenAI API बोलने वाला कोई भी सर्वर, जैसे vLLM या llama.cpp। कनेक्ट होने के बाद, उसे चैट बॉक्स के ऊपर चुनें।", + "es": "Chatea con un modelo que ejecutas tú mismo: Ollama o LM Studio (deja la dirección vacía para usar la habitual en este equipo), o cualquier servidor compatible con la API de OpenAI, como vLLM o llama.cpp. Una vez conectado, elígelo encima del cuadro de chat.", + "ar": "دردش مع نموذج تشغّله بنفسك: Ollama أو LM Studio (اترك العنوان فارغًا لاستخدام العنوان المعتاد على هذا الحاسوب)، أو أي خادم يتحدث واجهة OpenAI API مثل vLLM أو llama.cpp. بعد الاتصال، اختره فوق مربع الدردشة.", + "fr": "Discute avec un modèle que tu fais tourner toi-même : Ollama ou LM Studio (laisse l'adresse vide pour celle habituelle sur cet ordinateur), ou tout serveur qui parle l'API d'OpenAI, comme vLLM ou llama.cpp. Une fois connecté, choisis-le au-dessus de la zone de chat.", + "bn": "নিজে চালানো মডেলের সঙ্গে চ্যাট করুন: Ollama বা LM Studio (এই কম্পিউটারের সাধারণ ঠিকানার জন্য ঠিকানা ফাঁকা রাখুন), অথবা OpenAI API বোঝে এমন যেকোনো সার্ভার, যেমন vLLM বা llama.cpp। সংযুক্ত হলে চ্যাট বক্সের উপরে সেটি বেছে নিন।", + "pt-BR": "Converse com um modelo que você mesmo executa: Ollama ou LM Studio (deixe o endereço vazio para usar o padrão neste computador), ou qualquer servidor compatível com a API da OpenAI, como vLLM ou llama.cpp. Depois de conectado, escolha-o acima da caixa de chat.", + "ru": "Общайтесь с моделью, которую запускаете сами: Ollama или LM Studio (оставьте адрес пустым, чтобы использовать обычный адрес на этом компьютере) или любой сервер с API OpenAI, например vLLM или llama.cpp. После подключения выберите его над полем чата.", + "id": "Mengobrol dengan model yang Anda jalankan sendiri: Ollama atau LM Studio (kosongkan alamat untuk alamat biasa di komputer ini), atau server apa pun yang mendukung API OpenAI, seperti vLLM atau llama.cpp. Setelah terhubung, pilih di atas kotak obrolan." + }, + "Choose a model": { + "zh-Hans": "选择模型", + "hi": "मॉडल चुनें", + "es": "Elige un modelo", + "ar": "اختر نموذجًا", + "fr": "Choisis un modèle", + "bn": "একটি মডেল বেছে নিন", + "pt-BR": "Escolha um modelo", + "ru": "Выберите модель", + "id": "Pilih model" + }, + "Classic token with the repo scope, or fine-grained with read access to Pull requests, Commit statuses and Actions.": { + "zh-Hans": "带 repo 权限范围的经典令牌,或对 Pull requests、Commit statuses 和 Actions 具有读取权限的细粒度令牌。", + "hi": "repo स्कोप वाला क्लासिक टोकन, या Pull requests, Commit statuses और Actions पर पढ़ने की अनुमति वाला fine-grained टोकन।", + "es": "Token clásico con el alcance repo, o detallado (fine-grained) con acceso de lectura a Pull requests, Commit statuses y Actions.", + "ar": "رمز كلاسيكي بنطاق repo، أو رمز دقيق الصلاحيات مع إذن قراءة لـ Pull requests وCommit statuses وActions.", + "fr": "Jeton classique avec la portée repo, ou à granularité fine avec un accès en lecture aux Pull requests, Commit statuses et Actions.", + "bn": "repo স্কোপসহ ক্লাসিক টোকেন, অথবা Pull requests, Commit statuses ও Actions-এ পড়ার অনুমতিসহ fine-grained টোকেন।", + "pt-BR": "Token clássico com o escopo repo, ou refinado (fine-grained) com acesso de leitura a Pull requests, Commit statuses e Actions.", + "ru": "Классический токен с областью repo или детализированный (fine-grained) с доступом на чтение к Pull requests, Commit statuses и Actions.", + "id": "Token klasik dengan cakupan repo, atau fine-grained dengan akses baca ke Pull requests, Commit statuses, dan Actions." + }, + "Claude declined this one.": { + "zh-Hans": "Claude 拒绝了这个请求。", + "hi": "Claude ने इसे मना कर दिया।", + "es": "Claude ha rechazado esta petición.", + "ar": "رفض Claude هذا الطلب.", + "fr": "Claude a décliné cette demande.", + "bn": "Claude এটি প্রত্যাখ্যান করেছে।", + "pt-BR": "O Claude recusou este pedido.", + "ru": "Claude отклонил этот запрос.", + "id": "Claude menolak permintaan ini." + }, + "Claude needs an answer.": { + "zh-Hans": "Claude 需要一个回答。", + "hi": "Claude को जवाब चाहिए।", + "es": "Claude necesita una respuesta.", + "ar": "يحتاج Claude إلى إجابة.", + "fr": "Claude attend une réponse.", + "bn": "Claude-এর একটি উত্তর দরকার।", + "pt-BR": "O Claude precisa de uma resposta.", + "ru": "Claude ждёт ответа.", + "id": "Claude membutuhkan jawaban." + }, + "Claude plan usage": { + "zh-Hans": "Claude 套餐用量", + "hi": "Claude प्लान का उपयोग", + "es": "Uso del plan de Claude", + "ar": "استخدام خطة Claude", + "fr": "Utilisation du forfait Claude", + "bn": "Claude প্ল্যানের ব্যবহার", + "pt-BR": "Uso do plano Claude", + "ru": "Использование тарифа Claude", + "id": "Pemakaian paket Claude" + }, + "Close the island": { + "zh-Hans": "关闭岛", + "hi": "आइलैंड बंद करें", + "es": "Cerrar la isla", + "ar": "إغلاق الشريط", + "fr": "Fermer l'île", + "bn": "আইল্যান্ড বন্ধ করুন", + "pt-BR": "Fechar a ilha", + "ru": "Закрыть остров", + "id": "Tutup pulau" + }, + "Code": { + "zh-Hans": "代码", + "hi": "कोड", + "es": "Código", + "ar": "شيفرة", + "fr": "Code", + "bn": "কোড", + "pt-BR": "Código", + "ru": "Код", + "id": "Kode" + }, + "Codex plan": { + "zh-Hans": "Codex 套餐", + "hi": "Codex प्लान", + "es": "Plan de Codex", + "ar": "خطة Codex", + "fr": "Forfait Codex", + "bn": "Codex প্ল্যান", + "pt-BR": "Plano Codex", + "ru": "Тариф Codex", + "id": "Paket Codex" + }, + "Codex plan usage": { + "zh-Hans": "Codex 套餐用量", + "hi": "Codex प्लान का उपयोग", + "es": "Uso del plan de Codex", + "ar": "استخدام خطة Codex", + "fr": "Utilisation du forfait Codex", + "bn": "Codex প্ল্যানের ব্যবহার", + "pt-BR": "Uso do plano Codex", + "ru": "Использование тарифа Codex", + "id": "Pemakaian paket Codex" + }, + "Codex runs new hooks only once you trust them: start Codex and review them once with /hooks.": { + "zh-Hans": "Codex 只会运行你信任的新 hooks:启动 Codex,并用 /hooks 检查一次。", + "hi": "Codex नए hooks तभी चलाता है जब आप उन पर भरोसा करें: Codex शुरू करें और /hooks से एक बार उनकी समीक्षा करें।", + "es": "Codex solo ejecuta hooks nuevos cuando confías en ellos: abre Codex y revísalos una vez con /hooks.", + "ar": "لا يشغّل Codex الـ hooks الجديدة إلا بعد أن تثق بها: شغّل Codex وراجعها مرة واحدة بالأمر /hooks.", + "fr": "Codex n'exécute les nouveaux hooks qu'une fois que tu leur fais confiance : lance Codex et passe-les en revue une fois avec /hooks.", + "bn": "Codex নতুন hooks তখনই চালায় যখন আপনি সেগুলোকে বিশ্বাস করেন: Codex চালু করে /hooks দিয়ে একবার পর্যালোচনা করুন।", + "pt-BR": "O Codex só executa hooks novos depois que você confia neles: abra o Codex e revise-os uma vez com /hooks.", + "ru": "Codex запускает новые хуки только после того, как вы им доверитесь: откройте Codex и один раз проверьте их через /hooks.", + "id": "Codex hanya menjalankan hooks baru setelah Anda memercayainya: buka Codex dan tinjau sekali dengan /hooks." + }, + "Connect {name} in Settings → Local models first.": { + "zh-Hans": "请先在“设置 → 本地模型”中连接 {name}。", + "hi": "पहले सेटिंग्स → लोकल मॉडल में {name} कनेक्ट करें।", + "es": "Conecta primero {name} en Configuración → Modelos locales.", + "ar": "اربط {name} أولًا من الإعدادات ← النماذج المحلية.", + "fr": "Connecte d'abord {name} dans Réglages → Modèles locaux.", + "bn": "আগে সেটিংস → লোকাল মডেল-এ {name} সংযুক্ত করুন।", + "pt-BR": "Conecte primeiro o {name} em Configurações → Modelos locais.", + "ru": "Сначала подключите {name} в «Настройки → Локальные модели».", + "id": "Hubungkan {name} dulu di Pengaturan → Model lokal." + }, + "Copied": { + "zh-Hans": "已复制", + "hi": "कॉपी हो गया", + "es": "Copiado", + "ar": "تم النسخ", + "fr": "Copié", + "bn": "কপি হয়েছে", + "pt-BR": "Copiado", + "ru": "Скопировано", + "id": "Disalin" + }, + "Copied — paste it anywhere.": { + "zh-Hans": "已复制 — 可以粘贴到任何地方。", + "hi": "कॉपी हो गया — कहीं भी पेस्ट करें।", + "es": "Copiada — pégala donde quieras.", + "ar": "تم النسخ — الصقها في أي مكان.", + "fr": "Copiée — colle-la où tu veux.", + "bn": "কপি হয়েছে — যেকোনো জায়গায় পেস্ট করুন।", + "pt-BR": "Copiada — cole onde quiser.", + "ru": "Скопировано — вставьте куда угодно.", + "id": "Disalin — tempel di mana saja." + }, + "Copying images isn't available here — use Save image.": { + "zh-Hans": "此处无法复制图片 — 请使用“保存图片”。", + "hi": "यहाँ छवियाँ कॉपी नहीं हो सकतीं — “छवि सहेजें” का उपयोग करें।", + "es": "Aquí no se pueden copiar imágenes — usa Guardar imagen.", + "ar": "نسخ الصور غير متاح هنا — استخدم «حفظ الصورة».", + "fr": "Copier des images n'est pas possible ici — utilise Enregistrer l'image.", + "bn": "এখানে ছবি কপি করা যায় না — “ছবি সংরক্ষণ করুন” ব্যবহার করুন।", + "pt-BR": "Não é possível copiar imagens aqui — use Salvar imagem.", + "ru": "Копировать изображения здесь нельзя — используйте «Сохранить изображение».", + "id": "Menyalin gambar tidak tersedia di sini — gunakan Simpan gambar." + }, + "Coucou could not list the agents.": { + "zh-Hans": "Coucou 无法列出代理。", + "hi": "Coucou एजेंटों की सूची नहीं बना सका।", + "es": "Coucou no pudo listar los agentes.", + "ar": "تعذّر على Coucou سرد الوكلاء.", + "fr": "Coucou n'a pas pu lister les agents.", + "bn": "Coucou এজেন্টদের তালিকা করতে পারেনি।", + "pt-BR": "O Coucou não conseguiu listar os agentes.", + "ru": "Coucou не удалось получить список агентов.", + "id": "Coucou tidak dapat menampilkan daftar agen." + }, + "Coucou counts your agent sessions as they happen — check back next Monday.": { + "zh-Hans": "Coucou 会实时统计你的代理会话 — 下周一再来看看吧。", + "hi": "Coucou आपके एजेंट सत्रों को होते-होते गिनता है — अगले सोमवार फिर देखें।", + "es": "Coucou cuenta tus sesiones de agentes a medida que ocurren — vuelve el próximo lunes.", + "ar": "يحسب Coucou جلسات وكلائك أثناء حدوثها — عُد يوم الاثنين القادم.", + "fr": "Coucou compte tes sessions d'agents au fil de l'eau — reviens lundi prochain.", + "bn": "Coucou আপনার এজেন্ট সেশন ঘটার সঙ্গে সঙ্গে গোনে — পরের সোমবার আবার দেখুন।", + "pt-BR": "O Coucou conta suas sessões de agentes conforme acontecem — volte na próxima segunda-feira.", + "ru": "Coucou считает ваши сессии агентов по ходу дела — загляните в следующий понедельник.", + "id": "Coucou menghitung sesi agen Anda saat berlangsung — cek lagi Senin depan." + }, + "Coucou is hooked into your Claude Code sessions. Tool calls, questions and permission requests show up in the island, and you can answer them there.": { + "zh-Hans": "Coucou 已接入你的 Claude Code 会话。工具调用、问题和权限请求都会显示在岛中,你可以直接在那里回应。", + "hi": "Coucou आपके Claude Code सत्रों से जुड़ा है। टूल कॉल, प्रश्न और अनुमति अनुरोध आइलैंड में दिखते हैं, और आप वहीं उनका जवाब दे सकते हैं।", + "es": "Coucou está conectado a tus sesiones de Claude Code. Las llamadas a herramientas, las preguntas y las solicitudes de permiso aparecen en la isla y puedes responderlas ahí.", + "ar": "Coucou متصل بجلسات Claude Code لديك. تظهر استدعاءات الأدوات والأسئلة وطلبات الإذن في الشريط، ويمكنك الرد عليها من هناك.", + "fr": "Coucou est branché sur tes sessions Claude Code. Les appels d'outils, les questions et les demandes de permission s'affichent dans l'île, et tu peux y répondre directement.", + "bn": "Coucou আপনার Claude Code সেশনের সঙ্গে যুক্ত। টুল কল, প্রশ্ন আর অনুমতির অনুরোধ আইল্যান্ডে দেখা যায়, এবং আপনি সেখানেই উত্তর দিতে পারেন।", + "pt-BR": "O Coucou está conectado às suas sessões do Claude Code. Chamadas de ferramentas, perguntas e pedidos de permissão aparecem na ilha, e você pode respondê-los ali.", + "ru": "Coucou подключён к вашим сессиям Claude Code. Вызовы инструментов, вопросы и запросы разрешений появляются в острове, и отвечать можно прямо там.", + "id": "Coucou terhubung ke sesi Claude Code Anda. Panggilan alat, pertanyaan, dan permintaan izin muncul di pulau, dan Anda bisa menjawabnya di sana." + }, + "Could not remove {path}: {error}": { + "zh-Hans": "无法删除 {path}:{error}", + "hi": "{path} हटाया नहीं जा सका: {error}", + "es": "No se pudo eliminar {path}: {error}", + "ar": "تعذّر حذف {path}: {error}", + "fr": "Impossible de supprimer {path} : {error}", + "bn": "{path} সরানো যায়নি: {error}", + "pt-BR": "Não foi possível remover {path}: {error}", + "ru": "Не удалось удалить {path}: {error}", + "id": "Tidak dapat menghapus {path}: {error}" + }, + "Could not remove: {error}": { + "zh-Hans": "无法移除:{error}", + "hi": "हटाया नहीं जा सका: {error}", + "es": "No se pudo quitar: {error}", + "ar": "تعذّرت الإزالة: {error}", + "fr": "Suppression impossible : {error}", + "bn": "সরানো যায়নি: {error}", + "pt-BR": "Não foi possível remover: {error}", + "ru": "Не удалось удалить: {error}", + "id": "Tidak dapat menghapus: {error}" + }, + "Could not save the image: {error}": { + "zh-Hans": "无法保存图片:{error}", + "hi": "छवि सहेजी नहीं जा सकी: {error}", + "es": "No se pudo guardar la imagen: {error}", + "ar": "تعذّر حفظ الصورة: {error}", + "fr": "Impossible d'enregistrer l'image : {error}", + "bn": "ছবিটি সংরক্ষণ করা যায়নি: {error}", + "pt-BR": "Não foi possível salvar a imagem: {error}", + "ru": "Не удалось сохранить изображение: {error}", + "id": "Tidak dapat menyimpan gambar: {error}" + }, + "Could not save your current status line next to the relay; nothing was changed.": { + "zh-Hans": "无法将你当前的状态栏保存到中继旁边;未做任何更改。", + "hi": "आपकी मौजूदा स्टेटस लाइन रिले के पास सहेजी नहीं जा सकी; कुछ नहीं बदला गया।", + "es": "No se pudo guardar tu línea de estado actual junto al relay; no se cambió nada.", + "ar": "تعذّر حفظ سطر الحالة الحالي بجانب المُرحِّل؛ لم يتغيّر أي شيء.", + "fr": "Impossible d'enregistrer ta ligne d'état actuelle à côté du relais ; rien n'a été modifié.", + "bn": "আপনার বর্তমান স্ট্যাটাস লাইন রিলের পাশে সংরক্ষণ করা যায়নি; কিছুই বদলানো হয়নি।", + "pt-BR": "Não foi possível salvar sua linha de status atual ao lado do relay; nada foi alterado.", + "ru": "Не удалось сохранить вашу текущую строку состояния рядом с ретранслятором; ничего не изменено.", + "id": "Tidak dapat menyimpan baris status Anda saat ini di samping relay; tidak ada yang diubah." + }, + "Could not save: {error}": { + "zh-Hans": "无法保存:{error}", + "hi": "सहेजा नहीं जा सका: {error}", + "es": "No se pudo guardar: {error}", + "ar": "تعذّر الحفظ: {error}", + "fr": "Enregistrement impossible : {error}", + "bn": "সংরক্ষণ করা যায়নি: {error}", + "pt-BR": "Não foi possível salvar: {error}", + "ru": "Не удалось сохранить: {error}", + "id": "Tidak dapat menyimpan: {error}" + }, + "Could not write: {error}": { + "zh-Hans": "无法写入:{error}", + "hi": "लिखा नहीं जा सका: {error}", + "es": "No se pudo escribir: {error}", + "ar": "تعذّرت الكتابة: {error}", + "fr": "Écriture impossible : {error}", + "bn": "লেখা যায়নি: {error}", + "pt-BR": "Não foi possível gravar: {error}", + "ru": "Не удалось записать: {error}", + "id": "Tidak dapat menulis: {error}" + }, + "Counts and project names only — never commands, files or prompts. Kept on this computer for 12 weeks.": { + "zh-Hans": "仅保存计数和项目名称 — 绝不保存命令、文件或提示词。在这台电脑上保留 12 周。", + "hi": "केवल गिनती और प्रोजेक्ट के नाम — कभी कमांड, फ़ाइलें या प्रॉम्प्ट नहीं। इस कंप्यूटर पर 12 सप्ताह तक रखा जाता है।", + "es": "Solo recuentos y nombres de proyecto — nunca comandos, archivos ni prompts. Se guarda en este equipo durante 12 semanas.", + "ar": "أعداد وأسماء مشاريع فقط — لا أوامر ولا ملفات ولا مطالبات أبدًا. يُحتفظ بها على هذا الحاسوب لمدة 12 أسبوعًا.", + "fr": "Uniquement des compteurs et des noms de projet — jamais de commandes, de fichiers ni de prompts. Conservé sur cet ordinateur pendant 12 semaines.", + "bn": "শুধু গণনা আর প্রজেক্টের নাম — কখনো কমান্ড, ফাইল বা প্রম্পট নয়। এই কম্পিউটারে ১২ সপ্তাহ রাখা হয়।", + "pt-BR": "Somente contagens e nomes de projeto — nunca comandos, arquivos ou prompts. Mantido neste computador por 12 semanas.", + "ru": "Только счётчики и названия проектов — никогда команды, файлы или промпты. Хранится на этом компьютере 12 недель.", + "id": "Hanya hitungan dan nama proyek — tidak pernah perintah, file, atau prompt. Disimpan di komputer ini selama 12 minggu." + }, + "Crown": { + "zh-Hans": "王冠", + "hi": "मुकुट", + "es": "Corona", + "ar": "تاج", + "fr": "Couronne", + "bn": "মুকুট", + "pt-BR": "Coroa", + "ru": "Корона", + "id": "Mahkota" + }, + "Custom server": { + "zh-Hans": "自定义服务器", + "hi": "कस्टम सर्वर", + "es": "Servidor personalizado", + "ar": "خادم مخصص", + "fr": "Serveur personnalisé", + "bn": "কাস্টম সার্ভার", + "pt-BR": "Servidor personalizado", + "ru": "Свой сервер", + "id": "Server kustom" + }, + "Default branch CI": { + "zh-Hans": "默认分支 CI", + "hi": "डिफ़ॉल्ट ब्रांच CI", + "es": "CI de la rama principal", + "ar": "CI للفرع الافتراضي", + "fr": "CI de la branche par défaut", + "bn": "ডিফল্ট ব্রাঞ্চ CI", + "pt-BR": "CI do branch padrão", + "ru": "CI основной ветки", + "id": "CI cabang utama" + }, + "Deployment": { + "zh-Hans": "部署", + "hi": "डिप्लॉयमेंट", + "es": "Despliegue", + "ar": "نشر", + "fr": "Déploiement", + "bn": "ডিপ্লয়মেন্ট", + "pt-BR": "Implantação", + "ru": "Развёртывание", + "id": "Deployment" + }, + "Details": { + "zh-Hans": "详情", + "hi": "विवरण", + "es": "Detalles", + "ar": "التفاصيل", + "fr": "Détails", + "bn": "বিস্তারিত", + "pt-BR": "Detalhes", + "ru": "Подробности", + "id": "Detail" + }, + "Display under the cursor": { + "zh-Hans": "光标所在的显示器", + "hi": "कर्सर वाला डिस्प्ले", + "es": "Pantalla bajo el cursor", + "ar": "الشاشة التي عليها المؤشر", + "fr": "Écran sous le curseur", + "bn": "কার্সর যে ডিসপ্লেতে আছে", + "pt-BR": "Tela sob o cursor", + "ru": "Дисплей под курсором", + "id": "Layar di bawah kursor" + }, + "Docs": { + "zh-Hans": "文档", + "hi": "दस्तावेज़", + "es": "Documentos", + "ar": "مستندات", + "fr": "Docs", + "bn": "ডকুমেন্ট", + "pt-BR": "Docs", + "ru": "Документы", + "id": "Dokumen" + }, + "Done": { + "zh-Hans": "完成", + "hi": "हो गया", + "es": "Listo", + "ar": "تم", + "fr": "Valider", + "bn": "হয়েছে", + "pt-BR": "Concluir", + "ru": "Готово", + "id": "Selesai" + }, + "Done.": { + "zh-Hans": "完成。", + "hi": "हो गया।", + "es": "Listo.", + "ar": "تم.", + "fr": "C'est fait.", + "bn": "হয়ে গেছে।", + "pt-BR": "Pronto.", + "ru": "Готово.", + "id": "Selesai." + }, + "Done. Open a new Claude Code session to pick the hooks up.": { + "zh-Hans": "完成。打开新的 Claude Code 会话即可启用 hooks。", + "hi": "हो गया। hooks लागू करने के लिए Claude Code का नया सत्र खोलें।", + "es": "Listo. Abre una nueva sesión de Claude Code para que cargue los hooks.", + "ar": "تم. افتح جلسة Claude Code جديدة لتفعيل الـ hooks.", + "fr": "C'est fait. Ouvre une nouvelle session Claude Code pour qu'elle prenne les hooks.", + "bn": "হয়ে গেছে। hooks চালু করতে Claude Code-এর নতুন সেশন খুলুন।", + "pt-BR": "Pronto. Abra uma nova sessão do Claude Code para carregar os hooks.", + "ru": "Готово. Откройте новую сессию Claude Code, чтобы хуки заработали.", + "id": "Selesai. Buka sesi Claude Code baru agar hooks dimuat." + }, + "Done. Previous settings saved as {backup}. Open a new Claude Code session to pick the hooks up.": { + "zh-Hans": "完成。旧设置已保存为 {backup}。打开新的 Claude Code 会话即可启用 hooks。", + "hi": "हो गया। पिछली सेटिंग्स {backup} के रूप में सहेजी गईं। hooks लागू करने के लिए Claude Code का नया सत्र खोलें।", + "es": "Listo. La configuración anterior se guardó como {backup}. Abre una nueva sesión de Claude Code para que cargue los hooks.", + "ar": "تم. حُفظت الإعدادات السابقة باسم {backup}. افتح جلسة Claude Code جديدة لتفعيل الـ hooks.", + "fr": "C'est fait. Les anciens réglages sont enregistrés sous {backup}. Ouvre une nouvelle session Claude Code pour qu'elle prenne les hooks.", + "bn": "হয়ে গেছে। আগের সেটিংস {backup} নামে সংরক্ষিত। hooks চালু করতে Claude Code-এর নতুন সেশন খুলুন।", + "pt-BR": "Pronto. As configurações anteriores foram salvas como {backup}. Abra uma nova sessão do Claude Code para carregar os hooks.", + "ru": "Готово. Предыдущие настройки сохранены как {backup}. Откройте новую сессию Claude Code, чтобы хуки заработали.", + "id": "Selesai. Pengaturan sebelumnya disimpan sebagai {backup}. Buka sesi Claude Code baru agar hooks dimuat." + }, + "Done. Previous settings saved as {backup}. The numbers appear after the next reply of a Claude Code session.": { + "zh-Hans": "完成。旧设置已保存为 {backup}。数字会在 Claude Code 会话的下一次回复后出现。", + "hi": "हो गया। पिछली सेटिंग्स {backup} के रूप में सहेजी गईं। संख्याएँ Claude Code सत्र के अगले जवाब के बाद दिखेंगी।", + "es": "Listo. La configuración anterior se guardó como {backup}. Las cifras aparecen tras la próxima respuesta de una sesión de Claude Code.", + "ar": "تم. حُفظت الإعدادات السابقة باسم {backup}. تظهر الأرقام بعد الرد التالي في جلسة Claude Code.", + "fr": "C'est fait. Les anciens réglages sont enregistrés sous {backup}. Les chiffres apparaissent après la prochaine réponse d'une session Claude Code.", + "bn": "হয়ে গেছে। আগের সেটিংস {backup} নামে সংরক্ষিত। Claude Code সেশনের পরের উত্তরের পর সংখ্যাগুলো দেখা যাবে।", + "pt-BR": "Pronto. As configurações anteriores foram salvas como {backup}. Os números aparecem após a próxima resposta de uma sessão do Claude Code.", + "ru": "Готово. Предыдущие настройки сохранены как {backup}. Цифры появятся после следующего ответа в сессии Claude Code.", + "id": "Selesai. Pengaturan sebelumnya disimpan sebagai {backup}. Angkanya muncul setelah balasan berikutnya dari sesi Claude Code." + }, + "Done. Previous version saved as {paths}.": { + "zh-Hans": "完成。旧版本已保存为 {paths}。", + "hi": "हो गया। पिछला संस्करण {paths} के रूप में सहेजा गया।", + "es": "Listo. La versión anterior se guardó como {paths}.", + "ar": "تم. حُفظت النسخة السابقة باسم {paths}.", + "fr": "C'est fait. L'ancienne version est enregistrée sous {paths}.", + "bn": "হয়ে গেছে। আগের সংস্করণ {paths} নামে সংরক্ষিত।", + "pt-BR": "Pronto. A versão anterior foi salva como {paths}.", + "ru": "Готово. Предыдущая версия сохранена как {paths}.", + "id": "Selesai. Versi sebelumnya disimpan sebagai {paths}." + }, + "Done. The numbers appear after the next reply of a Claude Code session.": { + "zh-Hans": "完成。数字会在 Claude Code 会话的下一次回复后出现。", + "hi": "हो गया। संख्याएँ Claude Code सत्र के अगले जवाब के बाद दिखेंगी।", + "es": "Listo. Las cifras aparecen tras la próxima respuesta de una sesión de Claude Code.", + "ar": "تم. تظهر الأرقام بعد الرد التالي في جلسة Claude Code.", + "fr": "C'est fait. Les chiffres apparaissent après la prochaine réponse d'une session Claude Code.", + "bn": "হয়ে গেছে। Claude Code সেশনের পরের উত্তরের পর সংখ্যাগুলো দেখা যাবে।", + "pt-BR": "Pronto. Os números aparecem após a próxima resposta de uma sessão do Claude Code.", + "ru": "Готово. Цифры появятся после следующего ответа в сессии Claude Code.", + "id": "Selesai. Angkanya muncul setelah balasan berikutnya dari sesi Claude Code." + }, + "Drop": { + "zh-Hans": "拖放", + "hi": "छोड़ें", + "es": "Soltar", + "ar": "إفلات", + "fr": "Déposer", + "bn": "ড্রপ", + "pt-BR": "Soltar", + "ru": "Перетащить", + "id": "Letakkan" + }, + "Enter the server address first.": { + "zh-Hans": "请先输入服务器地址。", + "hi": "पहले सर्वर का पता दर्ज करें।", + "es": "Escribe primero la dirección del servidor.", + "ar": "أدخل عنوان الخادم أولًا.", + "fr": "Saisis d'abord l'adresse du serveur.", + "bn": "আগে সার্ভারের ঠিকানা লিখুন।", + "pt-BR": "Digite primeiro o endereço do servidor.", + "ru": "Сначала введите адрес сервера.", + "id": "Masukkan alamat server terlebih dahulu." + }, + "Error": { + "zh-Hans": "错误", + "hi": "त्रुटि", + "es": "Error", + "ar": "خطأ", + "fr": "Erreur", + "bn": "ত্রুটি", + "pt-BR": "Erro", + "ru": "Ошибка", + "id": "Galat" + }, + "File": { + "zh-Hans": "文件", + "hi": "फ़ाइल", + "es": "Archivo", + "ar": "ملف", + "fr": "Fichier", + "bn": "ফাইল", + "pt-BR": "Arquivo", + "ru": "Файл", + "id": "File" + }, + "Folders can't be dropped yet.": { + "zh-Hans": "暂时还不能拖放文件夹。", + "hi": "फ़ोल्डर अभी नहीं छोड़े जा सकते।", + "es": "Todavía no se pueden soltar carpetas.", + "ar": "لا يمكن إفلات المجلدات بعد.", + "fr": "On ne peut pas encore déposer de dossiers.", + "bn": "এখনো ফোল্ডার ড্রপ করা যায় না।", + "pt-BR": "Ainda não é possível soltar pastas.", + "ru": "Папки пока перетаскивать нельзя.", + "id": "Folder belum bisa diletakkan." + }, + "Friday": { + "zh-Hans": "星期五", + "hi": "शुक्रवार", + "es": "Viernes", + "ar": "الجمعة", + "fr": "Vendredi", + "bn": "শুক্রবার", + "pt-BR": "Sexta-feira", + "ru": "Пятница", + "id": "Jumat" + }, + "From anywhere": { + "zh-Hans": "全局", + "hi": "कहीं से भी", + "es": "Desde cualquier lugar", + "ar": "من أي مكان", + "fr": "De partout", + "bn": "যেকোনো জায়গা থেকে", + "pt-BR": "De qualquer lugar", + "ru": "Отовсюду", + "id": "Dari mana saja" + }, + "Go to pill 1 to 9": { + "zh-Hans": "前往第 1 至 9 个标签", + "hi": "टैब 1 से 9 पर जाएँ", + "es": "Ir a la pestaña 1 a 9", + "ar": "الانتقال إلى التبويب من 1 إلى 9", + "fr": "Aller à l'onglet 1 à 9", + "bn": "ট্যাব ১ থেকে ৯-এ যান", + "pt-BR": "Ir para a aba 1 a 9", + "ru": "Перейти к вкладке 1–9", + "id": "Buka tab 1 sampai 9" + }, + "Go to the waiting permission or question": { + "zh-Hans": "前往待处理的权限请求或问题", + "hi": "प्रतीक्षारत अनुमति या प्रश्न पर जाएँ", + "es": "Ir al permiso o la pregunta pendiente", + "ar": "الانتقال إلى الإذن أو السؤال المنتظر", + "fr": "Aller à la permission ou la question en attente", + "bn": "অপেক্ষমাণ অনুমতি বা প্রশ্নে যান", + "pt-BR": "Ir para a permissão ou pergunta pendente", + "ru": "Перейти к ожидающему разрешению или вопросу", + "id": "Buka izin atau pertanyaan yang menunggu" + }, + "Hide project names": { + "zh-Hans": "隐藏项目名称", + "hi": "प्रोजेक्ट के नाम छिपाएँ", + "es": "Ocultar nombres de proyecto", + "ar": "إخفاء أسماء المشاريع", + "fr": "Masquer les noms de projet", + "bn": "প্রজেক্টের নাম লুকান", + "pt-BR": "Ocultar nomes de projeto", + "ru": "Скрыть названия проектов", + "id": "Sembunyikan nama proyek" + }, + "History cleared.": { + "zh-Hans": "历史记录已清除。", + "hi": "इतिहास साफ़ कर दिया गया।", + "es": "Historial borrado.", + "ar": "مُسح السجل.", + "fr": "Historique effacé.", + "bn": "ইতিহাস মুছে ফেলা হয়েছে।", + "pt-BR": "Histórico apagado.", + "ru": "История очищена.", + "id": "Riwayat dihapus." + }, + "Hold Ctrl, Alt or the Windows key with it.": { + "zh-Hans": "同时按住 Ctrl、Alt 或 Windows 键。", + "hi": "इसके साथ Ctrl, Alt या Windows कुंजी दबाए रखें।", + "es": "Mantén pulsada Ctrl, Alt o la tecla Windows a la vez.", + "ar": "اضغط معه باستمرار على Ctrl أو Alt أو مفتاح Windows.", + "fr": "Maintiens Ctrl, Alt ou la touche Windows en même temps.", + "bn": "সঙ্গে Ctrl, Alt বা Windows কী চেপে ধরুন।", + "pt-BR": "Segure Ctrl, Alt ou a tecla Windows junto.", + "ru": "Удерживайте вместе с ней Ctrl, Alt или клавишу Windows.", + "id": "Tahan Ctrl, Alt, atau tombol Windows bersamaan." + }, + "Images": { + "zh-Hans": "图片", + "hi": "छवियाँ", + "es": "Imágenes", + "ar": "صور", + "fr": "Images", + "bn": "ছবি", + "pt-BR": "Imagens", + "ru": "Изображения", + "id": "Gambar" + }, + "In the open island": { + "zh-Hans": "在打开的岛中", + "hi": "खुले आइलैंड में", + "es": "En la isla abierta", + "ar": "في الشريط المفتوح", + "fr": "Dans l'île ouverte", + "bn": "খোলা আইল্যান্ডে", + "pt-BR": "Na ilha aberta", + "ru": "В открытом острове", + "id": "Di pulau yang terbuka" + }, + "In use": { + "zh-Hans": "使用中", + "hi": "उपयोग में", + "es": "En uso", + "ar": "قيد الاستخدام", + "fr": "Utilisé", + "bn": "ব্যবহৃত হচ্ছে", + "pt-BR": "Em uso", + "ru": "Используется", + "id": "Sedang dipakai" + }, + "In use by another app": { + "zh-Hans": "已被其他应用占用", + "hi": "किसी दूसरे ऐप द्वारा उपयोग में", + "es": "En uso por otra app", + "ar": "مستخدم من تطبيق آخر", + "fr": "Utilisé par une autre app", + "bn": "অন্য অ্যাপ ব্যবহার করছে", + "pt-BR": "Em uso por outro app", + "ru": "Занято другим приложением", + "id": "Dipakai aplikasi lain" + }, + "Install hooks…": { + "zh-Hans": "安装 hooks…", + "hi": "hooks इंस्टॉल करें…", + "es": "Instalar hooks…", + "ar": "تثبيت الـ hooks…", + "fr": "Installer les hooks…", + "bn": "hooks ইনস্টল করুন…", + "pt-BR": "Instalar hooks…", + "ru": "Установить хуки…", + "id": "Pasang hooks…" + }, + "Install relay…": { + "zh-Hans": "安装中继…", + "hi": "रिले इंस्टॉल करें…", + "es": "Instalar relay…", + "ar": "تثبيت المُرحِّل…", + "fr": "Installer le relais…", + "bn": "রিলে ইনস্টল করুন…", + "pt-BR": "Instalar relay…", + "ru": "Установить ретранслятор…", + "id": "Pasang relay…" + }, + "Install the hooks to see your Claude Code sessions in the island and approve permissions without leaving what you are doing.": { + "zh-Hans": "安装 hooks,即可在岛中查看 Claude Code 会话,并在不打断手头工作的情况下批准权限。", + "hi": "आइलैंड में अपने Claude Code सत्र देखने और अपना काम छोड़े बिना अनुमतियाँ देने के लिए hooks इंस्टॉल करें।", + "es": "Instala los hooks para ver tus sesiones de Claude Code en la isla y aprobar permisos sin dejar lo que estás haciendo.", + "ar": "ثبّت الـ hooks لترى جلسات Claude Code في الشريط وتوافق على الأذونات دون أن تترك ما تفعله.", + "fr": "Installe les hooks pour voir tes sessions Claude Code dans l'île et valider les permissions sans quitter ce que tu fais.", + "bn": "আইল্যান্ডে আপনার Claude Code সেশন দেখতে এবং কাজ না থামিয়ে অনুমতি দিতে hooks ইনস্টল করুন।", + "pt-BR": "Instale os hooks para ver suas sessões do Claude Code na ilha e aprovar permissões sem largar o que está fazendo.", + "ru": "Установите хуки, чтобы видеть сессии Claude Code в острове и давать разрешения, не отрываясь от работы.", + "id": "Pasang hooks untuk melihat sesi Claude Code di pulau dan menyetujui izin tanpa meninggalkan pekerjaan Anda." + }, + "Install…": { + "zh-Hans": "安装…", + "hi": "इंस्टॉल करें…", + "es": "Instalar…", + "ar": "تثبيت…", + "fr": "Installer…", + "bn": "ইনস্টল করুন…", + "pt-BR": "Instalar…", + "ru": "Установить…", + "id": "Pasang…" + }, + "Instance URL": { + "zh-Hans": "实例地址", + "hi": "इंस्टेंस URL", + "es": "URL de la instancia", + "ar": "عنوان المثيل", + "fr": "URL de l'instance", + "bn": "ইনস্ট্যান্স URL", + "pt-BR": "URL da instância", + "ru": "URL экземпляра", + "id": "URL instans" + }, + "Integration": { + "zh-Hans": "集成", + "hi": "एकीकरण", + "es": "Integración", + "ar": "تكامل", + "fr": "Intégration", + "bn": "ইন্টিগ্রেশন", + "pt-BR": "Integração", + "ru": "Интеграция", + "id": "Integrasi" + }, + "Integration lacks access": { + "zh-Hans": "集成没有访问权限", + "hi": "इंटीग्रेशन के पास पहुँच नहीं है", + "es": "La integración no tiene acceso", + "ar": "التكامل لا يملك صلاحية الوصول", + "fr": "L'intégration n'a pas l'accès", + "bn": "ইন্টিগ্রেশনের অ্যাক্সেস নেই", + "pt-BR": "A integração não tem acesso", + "ru": "У интеграции нет доступа", + "id": "Integrasi tidak punya akses" + }, + "Integration token": { + "zh-Hans": "集成令牌", + "hi": "इंटीग्रेशन टोकन", + "es": "Token de integración", + "ar": "رمز التكامل", + "fr": "Jeton d'intégration", + "bn": "ইন্টিগ্রেশন টোকেন", + "pt-BR": "Token de integração", + "ru": "Токен интеграции", + "id": "Token integrasi" + }, + "Invalid API key (401)": { + "zh-Hans": "API 密钥无效(401)", + "hi": "अमान्य API कुंजी (401)", + "es": "Clave de API no válida (401)", + "ar": "مفتاح API غير صالح (401)", + "fr": "Clé d'API invalide (401)", + "bn": "অবৈধ API কী (401)", + "pt-BR": "Chave de API inválida (401)", + "ru": "Неверный API-ключ (401)", + "id": "Kunci API tidak valid (401)" + }, + "Island lives on": { + "zh-Hans": "岛所在位置", + "hi": "आइलैंड कहाँ रहे", + "es": "La isla vive en", + "ar": "مكان الشريط", + "fr": "L'île vit sur", + "bn": "আইল্যান্ড থাকবে", + "pt-BR": "A ilha fica na", + "ru": "Остров на", + "id": "Pulau berada di" + }, + "Keep the island open": { + "zh-Hans": "保持岛打开", + "hi": "आइलैंड खुला रखें", + "es": "Mantener la isla abierta", + "ar": "إبقاء الشريط مفتوحًا", + "fr": "Garder l'île ouverte", + "bn": "আইল্যান্ড খোলা রাখুন", + "pt-BR": "Manter a ilha aberta", + "ru": "Оставить остров открытым", + "id": "Biarkan pulau tetap terbuka" + }, + "Key configured": { + "zh-Hans": "密钥已配置", + "hi": "कुंजी सेट है", + "es": "Clave configurada", + "ar": "المفتاح مُعدّ", + "fr": "Clé configurée", + "bn": "কী সেট করা আছে", + "pt-BR": "Chave configurada", + "ru": "Ключ настроен", + "id": "Kunci sudah diatur" + }, + "Key from {site}": { + "zh-Hans": "密钥来自 {site}", + "hi": "कुंजी {site} से", + "es": "Clave de {site}", + "ar": "المفتاح من {site}", + "fr": "Clé sur {site}", + "bn": "কী পাবেন {site}-এ", + "pt-BR": "Chave em {site}", + "ru": "Ключ на {site}", + "id": "Kunci dari {site}" + }, + "Key lacks access": { + "zh-Hans": "密钥没有访问权限", + "hi": "कुंजी के पास पहुँच नहीं है", + "es": "La clave no tiene acceso", + "ar": "المفتاح لا يملك صلاحية الوصول", + "fr": "La clé n'a pas l'accès", + "bn": "কী-এর অ্যাক্সেস নেই", + "pt-BR": "A chave não tem acesso", + "ru": "У ключа нет доступа", + "id": "Kunci tidak punya akses" + }, + "Key removed.": { + "zh-Hans": "密钥已移除。", + "hi": "कुंजी हटा दी गई।", + "es": "Clave quitada.", + "ar": "أُزيل المفتاح.", + "fr": "Clé retirée.", + "bn": "কী সরানো হয়েছে।", + "pt-BR": "Chave removida.", + "ru": "Ключ удалён.", + "id": "Kunci dihapus." + }, + "Key saved in the {store}.": { + "zh-Hans": "密钥已保存在 {store} 中。", + "hi": "कुंजी {store} में सहेजी गई है।", + "es": "Clave guardada en {store}.", + "ar": "حُفظ المفتاح في {store}.", + "fr": "Clé enregistrée dans {store}.", + "bn": "কী {store}-এ সংরক্ষিত।", + "pt-BR": "Chave salva em {store}.", + "ru": "Ключ сохранён в {store}.", + "id": "Kunci tersimpan di {store}." + }, + "Later — keep it waiting": { + "zh-Hans": "稍后 — 让它等着", + "hi": "बाद में — इसे इंतज़ार करने दें", + "es": "Más tarde — que espere", + "ar": "لاحقًا — دعه ينتظر", + "fr": "Plus tard — le laisser en attente", + "bn": "পরে — অপেক্ষায় রাখুন", + "pt-BR": "Depois — deixar esperando", + "ru": "Позже — пусть подождёт", + "id": "Nanti — biarkan menunggu" + }, + "Launch at startup": { + "zh-Hans": "开机时启动", + "hi": "स्टार्टअप पर शुरू करें", + "es": "Abrir al iniciar sesión", + "ar": "التشغيل عند بدء التشغيل", + "fr": "Lancer au démarrage", + "bn": "স্টার্টআপে চালু করুন", + "pt-BR": "Abrir ao iniciar", + "ru": "Запуск при старте системы", + "id": "Jalankan saat startup" + }, + "Leave the user name and password out of the address.": { + "zh-Hans": "请不要在地址中包含用户名和密码。", + "hi": "पते में यूज़रनेम और पासवर्ड न डालें।", + "es": "No incluyas el usuario ni la contraseña en la dirección.", + "ar": "لا تضع اسم المستخدم وكلمة المرور في العنوان.", + "fr": "Ne mets ni nom d'utilisateur ni mot de passe dans l'adresse.", + "bn": "ঠিকানায় ইউজারনেম আর পাসওয়ার্ড দেবেন না।", + "pt-BR": "Não coloque usuário e senha no endereço.", + "ru": "Не указывайте имя пользователя и пароль в адресе.", + "id": "Jangan sertakan nama pengguna dan kata sandi di alamat." + }, + "Main display": { + "zh-Hans": "主显示器", + "hi": "मुख्य डिस्प्ले", + "es": "Pantalla principal", + "ar": "الشاشة الرئيسية", + "fr": "Écran principal", + "bn": "প্রধান ডিসপ্লে", + "pt-BR": "Tela principal", + "ru": "Основной дисплей", + "id": "Layar utama" + }, + "Main tool": { + "zh-Hans": "主要工具", + "hi": "मुख्य टूल", + "es": "Herramienta principal", + "ar": "الأداة الرئيسية", + "fr": "Outil principal", + "bn": "প্রধান টুল", + "pt-BR": "Ferramenta principal", + "ru": "Основной инструмент", + "id": "Alat utama" + }, + "Meeting": { + "zh-Hans": "会议", + "hi": "मीटिंग", + "es": "Reunión", + "ar": "اجتماع", + "fr": "Réunion", + "bn": "মিটিং", + "pt-BR": "Reunião", + "ru": "Встреча", + "id": "Rapat" + }, + "Monday": { + "zh-Hans": "星期一", + "hi": "सोमवार", + "es": "Lunes", + "ar": "الاثنين", + "fr": "Lundi", + "bn": "সোমবার", + "pt-BR": "Segunda-feira", + "ru": "Понедельник", + "id": "Senin" + }, + "Mute": { + "zh-Hans": "静音", + "hi": "म्यूट", + "es": "Silenciar", + "ar": "كتم الصوت", + "fr": "Couper le son", + "bn": "মিউট", + "pt-BR": "Silenciar", + "ru": "Без звука", + "id": "Bisukan" + }, + "Mute or unmute Mochi": { + "zh-Hans": "静音或取消静音 Mochi", + "hi": "Mochi को म्यूट या अनम्यूट करें", + "es": "Silenciar o activar a Mochi", + "ar": "كتم صوت Mochi أو إلغاء كتمه", + "fr": "Couper ou remettre le son de Mochi", + "bn": "Mochi-কে মিউট বা আনমিউট করুন", + "pt-BR": "Silenciar ou ativar o som do Mochi", + "ru": "Выключить или включить звук Mochi", + "id": "Bisukan atau bunyikan Mochi" + }, + "Network error: {error}": { + "zh-Hans": "网络错误:{error}", + "hi": "नेटवर्क त्रुटि: {error}", + "es": "Error de red: {error}", + "ar": "خطأ في الشبكة: {error}", + "fr": "Erreur réseau : {error}", + "bn": "নেটওয়ার্ক ত্রুটি: {error}", + "pt-BR": "Erro de rede: {error}", + "ru": "Ошибка сети: {error}", + "id": "Galat jaringan: {error}" + }, + "Next or previous pill": { + "zh-Hans": "下一个或上一个标签", + "hi": "अगला या पिछला टैब", + "es": "Pestaña siguiente o anterior", + "ar": "التبويب التالي أو السابق", + "fr": "Onglet suivant ou précédent", + "bn": "পরের বা আগের ট্যাব", + "pt-BR": "Próxima aba ou anterior", + "ru": "Следующая или предыдущая вкладка", + "id": "Tab berikutnya atau sebelumnya" + }, + "Next pill": { + "zh-Hans": "下一个标签", + "hi": "अगला टैब", + "es": "Siguiente pestaña", + "ar": "التبويب التالي", + "fr": "Onglet suivant", + "bn": "পরের ট্যাব", + "pt-BR": "Próxima aba", + "ru": "Следующая вкладка", + "id": "Tab berikutnya" + }, + "No API key — add it in Settings.": { + "zh-Hans": "没有 API 密钥 — 请在设置中添加。", + "hi": "कोई API कुंजी नहीं — इसे सेटिंग्स में जोड़ें।", + "es": "No hay clave de API — añádela en Configuración.", + "ar": "لا يوجد مفتاح API — أضفه من الإعدادات.", + "fr": "Pas de clé d'API — ajoute-la dans les Réglages.", + "bn": "কোনো API কী নেই — সেটিংসে যোগ করুন।", + "pt-BR": "Sem chave de API — adicione nas Configurações.", + "ru": "Нет API-ключа — добавьте его в настройках.", + "id": "Tidak ada kunci API — tambahkan di Pengaturan." + }, + "No Pictures or Downloads folder to save into.": { + "zh-Hans": "没有可保存的“图片”或“下载”文件夹。", + "hi": "सहेजने के लिए कोई Pictures या Downloads फ़ोल्डर नहीं है।", + "es": "No hay una carpeta Imágenes o Descargas donde guardar.", + "ar": "لا يوجد مجلد «الصور» أو «التنزيلات» للحفظ فيه.", + "fr": "Aucun dossier Images ou Téléchargements où enregistrer.", + "bn": "সংরক্ষণের জন্য কোনো Pictures বা Downloads ফোল্ডার নেই।", + "pt-BR": "Não há uma pasta Imagens ou Downloads para salvar.", + "ru": "Нет папки «Изображения» или «Загрузки» для сохранения.", + "id": "Tidak ada folder Gambar atau Unduhan untuk menyimpan." + }, + "No change.": { + "zh-Hans": "没有改动。", + "hi": "कोई बदलाव नहीं।", + "es": "Sin cambios.", + "ar": "لا تغيير.", + "fr": "Aucun changement.", + "bn": "কোনো পরিবর্তন নেই।", + "pt-BR": "Nenhuma alteração.", + "ru": "Без изменений.", + "id": "Tidak ada perubahan." + }, + "No connection: {error}": { + "zh-Hans": "无连接:{error}", + "hi": "कोई कनेक्शन नहीं: {error}", + "es": "Sin conexión: {error}", + "ar": "لا يوجد اتصال: {error}", + "fr": "Pas de connexion : {error}", + "bn": "সংযোগ নেই: {error}", + "pt-BR": "Sem conexão: {error}", + "ru": "Нет соединения: {error}", + "id": "Tidak ada koneksi: {error}" + }, + "No detail available.": { + "zh-Hans": "没有可用的详情。", + "hi": "कोई विवरण उपलब्ध नहीं।", + "es": "No hay detalles disponibles.", + "ar": "لا توجد تفاصيل.", + "fr": "Aucun détail disponible.", + "bn": "কোনো বিস্তারিত নেই।", + "pt-BR": "Nenhum detalhe disponível.", + "ru": "Подробностей нет.", + "id": "Tidak ada detail." + }, + "No display server was found, so global shortcuts are off.": { + "zh-Hans": "未找到显示服务器,因此全局快捷键已关闭。", + "hi": "कोई डिस्प्ले सर्वर नहीं मिला, इसलिए ग्लोबल शॉर्टकट बंद हैं।", + "es": "No se encontró ningún servidor gráfico, así que los atajos globales están desactivados.", + "ar": "لم يُعثر على خادم عرض، لذا الاختصارات العامة معطّلة.", + "fr": "Aucun serveur d'affichage trouvé, les raccourcis globaux sont donc désactivés.", + "bn": "কোনো ডিসপ্লে সার্ভার পাওয়া যায়নি, তাই গ্লোবাল শর্টকাট বন্ধ।", + "pt-BR": "Nenhum servidor gráfico foi encontrado, então os atalhos globais estão desativados.", + "ru": "Дисплейный сервер не найден, поэтому глобальные сочетания отключены.", + "id": "Server tampilan tidak ditemukan, jadi pintasan global dimatikan." + }, + "No existing file — nothing to back up.": { + "zh-Hans": "没有现有文件 — 无需备份。", + "hi": "कोई मौजूदा फ़ाइल नहीं — बैकअप के लिए कुछ नहीं।", + "es": "No hay ningún archivo — nada que respaldar.", + "ar": "لا يوجد ملف حالي — لا شيء لنسخه احتياطيًا.", + "fr": "Aucun fichier existant — rien à sauvegarder.", + "bn": "কোনো ফাইল নেই — ব্যাকআপ করার কিছু নেই।", + "pt-BR": "Nenhum arquivo existente — nada para fazer backup.", + "ru": "Файла пока нет — нечего сохранять.", + "id": "Belum ada file — tidak ada yang perlu dicadangkan." + }, + "No key yet — the chat needs one.": { + "zh-Hans": "还没有密钥 — 聊天需要一个。", + "hi": "अभी कोई कुंजी नहीं — चैट को एक चाहिए।", + "es": "Aún no hay clave — el chat necesita una.", + "ar": "لا يوجد مفتاح بعد — تحتاج الدردشة إلى مفتاح.", + "fr": "Pas encore de clé — le chat en a besoin.", + "bn": "এখনো কোনো কী নেই — চ্যাটের একটি লাগবে।", + "pt-BR": "Ainda não há chave — o chat precisa de uma.", + "ru": "Ключа пока нет — он нужен для чата.", + "id": "Belum ada kunci — obrolan membutuhkannya." + }, + "No models yet. Download one in {name} first.": { + "zh-Hans": "还没有模型。请先在 {name} 中下载一个。", + "hi": "अभी कोई मॉडल नहीं। पहले {name} में एक डाउनलोड करें।", + "es": "Aún no hay modelos. Descarga uno en {name} primero.", + "ar": "لا توجد نماذج بعد. نزّل نموذجًا في {name} أولًا.", + "fr": "Pas encore de modèle. Télécharges-en un dans {name} d'abord.", + "bn": "এখনো কোনো মডেল নেই। আগে {name}-এ একটি ডাউনলোড করুন।", + "pt-BR": "Ainda não há modelos. Baixe um no {name} primeiro.", + "ru": "Моделей пока нет. Сначала загрузите модель в {name}.", + "id": "Belum ada model. Unduh satu di {name} terlebih dahulu." + }, + "No response text.": { + "zh-Hans": "没有回复内容。", + "hi": "जवाब में कोई टेक्स्ट नहीं।", + "es": "La respuesta no tiene texto.", + "ar": "لا يوجد نص في الرد.", + "fr": "La réponse ne contient pas de texte.", + "bn": "উত্তরে কোনো লেখা নেই।", + "pt-BR": "A resposta não tem texto.", + "ru": "В ответе нет текста.", + "id": "Tidak ada teks balasan." + }, + "No settings.json yet — nothing to back up.": { + "zh-Hans": "还没有 settings.json — 无需备份。", + "hi": "अभी कोई settings.json नहीं — बैकअप के लिए कुछ नहीं।", + "es": "Aún no hay settings.json — nada que respaldar.", + "ar": "لا يوجد settings.json بعد — لا شيء لنسخه احتياطيًا.", + "fr": "Pas encore de settings.json — rien à sauvegarder.", + "bn": "এখনো কোনো settings.json নেই — ব্যাকআপ করার কিছু নেই।", + "pt-BR": "Ainda não há settings.json — nada para fazer backup.", + "ru": "settings.json пока нет — нечего сохранять.", + "id": "Belum ada settings.json — tidak ada yang perlu dicadangkan." + }, + "No telemetry. Network requests only go to the services you configure yourself.": { + "zh-Hans": "没有遥测。网络请求只会发往你自己配置的服务。", + "hi": "कोई टेलीमेट्री नहीं। नेटवर्क अनुरोध केवल उन्हीं सेवाओं को जाते हैं जिन्हें आप खुद सेट करते हैं।", + "es": "Sin telemetría. Las solicitudes de red solo van a los servicios que configuras tú.", + "ar": "لا قياس عن بُعد. تذهب طلبات الشبكة فقط إلى الخدمات التي تضبطها بنفسك.", + "fr": "Aucune télémétrie. Les requêtes réseau ne vont qu'aux services que tu configures toi-même.", + "bn": "কোনো টেলিমেট্রি নেই। নেটওয়ার্ক অনুরোধ শুধু আপনার নিজের সেট করা পরিষেবাগুলোতেই যায়।", + "pt-BR": "Sem telemetria. As requisições de rede só vão para os serviços que você mesmo configura.", + "ru": "Никакой телеметрии. Сетевые запросы идут только к сервисам, которые вы настроили сами.", + "id": "Tanpa telemetri. Permintaan jaringan hanya ke layanan yang Anda atur sendiri." + }, + "None": { + "zh-Hans": "无", + "hi": "कोई नहीं", + "es": "Ninguno", + "ar": "لا شيء", + "fr": "Aucun", + "bn": "কিছু না", + "pt-BR": "Nenhum", + "ru": "Нет", + "id": "Tidak ada" + }, + "Not a valid address: {address}": { + "zh-Hans": "不是有效的地址:{address}", + "hi": "मान्य पता नहीं: {address}", + "es": "Dirección no válida: {address}", + "ar": "عنوان غير صالح: {address}", + "fr": "Adresse non valide : {address}", + "bn": "বৈধ ঠিকানা নয়: {address}", + "pt-BR": "Endereço inválido: {address}", + "ru": "Некорректный адрес: {address}", + "id": "Alamat tidak valid: {address}" + }, + "Not a valid http:// or https:// address.": { + "zh-Hans": "不是有效的 http:// 或 https:// 地址。", + "hi": "मान्य http:// या https:// पता नहीं है।", + "es": "No es una dirección http:// o https:// válida.", + "ar": "ليس عنوان http:// أو https:// صالحًا.", + "fr": "Ce n'est pas une adresse http:// ou https:// valide.", + "bn": "বৈধ http:// বা https:// ঠিকানা নয়।", + "pt-BR": "Não é um endereço http:// ou https:// válido.", + "ru": "Это не корректный адрес http:// или https://.", + "id": "Bukan alamat http:// atau https:// yang valid." + }, + "Not a valid shortcut": { + "zh-Hans": "无效的快捷键", + "hi": "मान्य शॉर्टकट नहीं", + "es": "No es un atajo válido", + "ar": "ليس اختصارًا صالحًا", + "fr": "Raccourci non valide", + "bn": "বৈধ শর্টকাট নয়", + "pt-BR": "Atalho inválido", + "ru": "Недопустимое сочетание", + "id": "Pintasan tidak valid" + }, + "Not available": { + "zh-Hans": "不可用", + "hi": "उपलब्ध नहीं", + "es": "No disponible", + "ar": "غير متاح", + "fr": "Indisponible", + "bn": "উপলব্ধ নয়", + "pt-BR": "Indisponível", + "ru": "Недоступно", + "id": "Tidak tersedia" + }, + "Nothing leaves your PC: the server runs on this computer.": { + "zh-Hans": "数据不会离开你的电脑:服务器运行在这台电脑上。", + "hi": "आपके PC से कुछ बाहर नहीं जाता: सर्वर इसी कंप्यूटर पर चलता है।", + "es": "Nada sale de tu PC: el servidor funciona en este equipo.", + "ar": "لا يغادر أي شيء جهازك: الخادم يعمل على هذا الحاسوب.", + "fr": "Rien ne quitte ton PC : le serveur tourne sur cet ordinateur.", + "bn": "আপনার PC থেকে কিছুই বাইরে যায় না: সার্ভার এই কম্পিউটারেই চলে।", + "pt-BR": "Nada sai do seu PC: o servidor roda neste computador.", + "ru": "Ничего не покидает ваш ПК: сервер работает на этом компьютере.", + "id": "Tidak ada yang keluar dari PC Anda: server berjalan di komputer ini." + }, + "Only files dropped on the island can be added.": { + "zh-Hans": "只能添加拖放到岛上的文件。", + "hi": "केवल आइलैंड पर छोड़ी गई फ़ाइलें ही जोड़ी जा सकती हैं।", + "es": "Solo se pueden añadir archivos soltados en la isla.", + "ar": "يمكن إضافة الملفات المُفلتة على الشريط فقط.", + "fr": "Seuls les fichiers déposés sur l'île peuvent être ajoutés.", + "bn": "শুধু আইল্যান্ডে ড্রপ করা ফাইলই যোগ করা যায়।", + "pt-BR": "Só arquivos soltos na ilha podem ser adicionados.", + "ru": "Добавлять можно только файлы, перетащенные на остров.", + "id": "Hanya file yang diletakkan di pulau yang bisa ditambahkan." + }, + "Open Claude": { + "zh-Hans": "打开 Claude", + "hi": "Claude खोलें", + "es": "Abrir Claude", + "ar": "فتح Claude", + "fr": "Ouvrir Claude", + "bn": "Claude খুলুন", + "pt-BR": "Abrir o Claude", + "ru": "Открыть Claude", + "id": "Buka Claude" + }, + "Open Settings": { + "zh-Hans": "打开设置", + "hi": "सेटिंग्स खोलें", + "es": "Abrir Configuración", + "ar": "فتح الإعدادات", + "fr": "Ouvrir les réglages", + "bn": "সেটিংস খুলুন", + "pt-BR": "Abrir Configurações", + "ru": "Открыть настройки", + "id": "Buka Pengaturan" + }, + "Open in VS Code": { + "zh-Hans": "在 VS Code 中打开", + "hi": "VS Code में खोलें", + "es": "Abrir en VS Code", + "ar": "فتح في VS Code", + "fr": "Ouvrir dans VS Code", + "bn": "VS Code-এ খুলুন", + "pt-BR": "Abrir no VS Code", + "ru": "Открыть в VS Code", + "id": "Buka di VS Code" + }, + "Open or close the island": { + "zh-Hans": "打开或关闭岛", + "hi": "आइलैंड खोलें या बंद करें", + "es": "Abrir o cerrar la isla", + "ar": "فتح الشريط أو إغلاقه", + "fr": "Ouvrir ou fermer l'île", + "bn": "আইল্যান্ড খুলুন বা বন্ধ করুন", + "pt-BR": "Abrir ou fechar a ilha", + "ru": "Открыть или закрыть остров", + "id": "Buka atau tutup pulau" + }, + "Open the chat": { + "zh-Hans": "打开聊天", + "hi": "चैट खोलें", + "es": "Abrir el chat", + "ar": "فتح الدردشة", + "fr": "Ouvrir le chat", + "bn": "চ্যাট খুলুন", + "pt-BR": "Abrir o chat", + "ru": "Открыть чат", + "id": "Buka obrolan" + }, + "Open the terminal": { + "zh-Hans": "打开终端", + "hi": "टर्मिनल खोलें", + "es": "Abrir la terminal", + "ar": "فتح الطرفية", + "fr": "Ouvrir le terminal", + "bn": "টার্মিনাল খুলুন", + "pt-BR": "Abrir o terminal", + "ru": "Открыть терминал", + "id": "Buka terminal" + }, + "Open the wardrobe": { + "zh-Hans": "打开衣橱", + "hi": "अलमारी खोलें", + "es": "Abrir el guardarropa", + "ar": "فتح خزانة الملابس", + "fr": "Ouvrir la garde-robe", + "bn": "পোশাক ঘর খুলুন", + "pt-BR": "Abrir o guarda-roupa", + "ru": "Открыть гардероб", + "id": "Buka lemari" + }, + "OpenAI-compatible": { + "zh-Hans": "兼容 OpenAI", + "hi": "OpenAI-संगत", + "es": "Compatible con OpenAI", + "ar": "متوافق مع OpenAI", + "fr": "Compatible OpenAI", + "bn": "OpenAI-সামঞ্জস্যপূর্ণ", + "pt-BR": "Compatível com OpenAI", + "ru": "Совместимый с OpenAI", + "id": "Kompatibel OpenAI" + }, + "OpenAI-compatible server": { + "zh-Hans": "兼容 OpenAI 的服务器", + "hi": "OpenAI-संगत सर्वर", + "es": "Servidor compatible con OpenAI", + "ar": "خادم متوافق مع OpenAI", + "fr": "Serveur compatible OpenAI", + "bn": "OpenAI-সামঞ্জস্যপূর্ণ সার্ভার", + "pt-BR": "Servidor compatível com OpenAI", + "ru": "Сервер, совместимый с OpenAI", + "id": "Server kompatibel OpenAI" + }, + "PDF": { + "zh-Hans": "PDF", + "hi": "PDF", + "es": "PDF", + "ar": "PDF", + "fr": "PDF", + "bn": "PDF", + "pt-BR": "PDF", + "ru": "PDF", + "id": "PDF" + }, + "Party hat": { + "zh-Hans": "派对帽", + "hi": "पार्टी टोपी", + "es": "Gorro de fiesta", + "ar": "قبعة الحفلة", + "fr": "Chapeau de fête", + "bn": "পার্টি টুপি", + "pt-BR": "Chapéu de festa", + "ru": "Праздничный колпак", + "id": "Topi pesta" + }, + "Pause": { + "zh-Hans": "暂停", + "hi": "रोकें", + "es": "Pausa", + "ar": "إيقاف مؤقت", + "fr": "Pause", + "bn": "বিরতি", + "pt-BR": "Pausar", + "ru": "Пауза", + "id": "Jeda" + }, + "Payment": { + "zh-Hans": "付款", + "hi": "भुगतान", + "es": "Pago", + "ar": "دفعة", + "fr": "Paiement", + "bn": "পেমেন্ট", + "pt-BR": "Pagamento", + "ru": "Платёж", + "id": "Pembayaran" + }, + "Permissions": { + "zh-Hans": "权限", + "hi": "अनुमतियाँ", + "es": "Permisos", + "ar": "الأذونات", + "fr": "Permissions", + "bn": "অনুমতি", + "pt-BR": "Permissões", + "ru": "Разрешения", + "id": "Izin" + }, + "Pick a model above the chat box first.": { + "zh-Hans": "请先在聊天框上方选择一个模型。", + "hi": "पहले चैट बॉक्स के ऊपर एक मॉडल चुनें।", + "es": "Elige primero un modelo encima del cuadro de chat.", + "ar": "اختر نموذجًا فوق مربع الدردشة أولًا.", + "fr": "Choisis d'abord un modèle au-dessus de la zone de chat.", + "bn": "আগে চ্যাট বক্সের উপরে একটি মডেল বেছে নিন।", + "pt-BR": "Escolha primeiro um modelo acima da caixa de chat.", + "ru": "Сначала выберите модель над полем чата.", + "id": "Pilih model di atas kotak obrolan terlebih dahulu." + }, + "Pick a {name} model above the chat box.": { + "zh-Hans": "请在聊天框上方选择一个 {name} 模型。", + "hi": "चैट बॉक्स के ऊपर {name} का एक मॉडल चुनें।", + "es": "Elige un modelo de {name} encima del cuadro de chat.", + "ar": "اختر نموذجًا من {name} فوق مربع الدردشة.", + "fr": "Choisis un modèle {name} au-dessus de la zone de chat.", + "bn": "চ্যাট বক্সের উপরে {name}-এর একটি মডেল বেছে নিন।", + "pt-BR": "Escolha um modelo do {name} acima da caixa de chat.", + "ru": "Выберите модель {name} над полем чата.", + "id": "Pilih model {name} di atas kotak obrolan." + }, + "Pick up to {max} pills to show next to Mochi — {used}/{max} in use. Keys are stored in the {store}, never on disk.": { + "zh-Hans": "最多选择 {max} 个标签显示在 Mochi 旁边 — 已用 {used}/{max}。密钥保存在 {store} 中,绝不写入磁盘。", + "hi": "Mochi के बगल में दिखाने के लिए अधिकतम {max} टैब चुनें — {used}/{max} उपयोग में। कुंजियाँ {store} में रखी जाती हैं, डिस्क पर कभी नहीं।", + "es": "Elige hasta {max} pestañas para mostrar junto a Mochi — {used}/{max} en uso. Las claves se guardan en {store}, nunca en el disco.", + "ar": "اختر حتى {max} تبويبات لعرضها بجانب Mochi — {used}/{max} مستخدمة. تُحفظ المفاتيح في {store}، ولا تُكتب على القرص أبدًا.", + "fr": "Choisis jusqu'à {max} onglets à afficher à côté de Mochi — {used}/{max} utilisés. Les clés sont stockées dans {store}, jamais sur le disque.", + "bn": "Mochi-র পাশে দেখাতে সর্বোচ্চ {max}টি ট্যাব বেছে নিন — {used}/{max} ব্যবহৃত। কী {store}-এ রাখা হয়, কখনো ডিস্কে নয়।", + "pt-BR": "Escolha até {max} abas para mostrar ao lado do Mochi — {used}/{max} em uso. As chaves ficam em {store}, nunca no disco.", + "ru": "Выберите до {max} вкладок рядом с Mochi — занято {used}/{max}. Ключи хранятся в {store}, никогда на диске.", + "id": "Pilih hingga {max} tab untuk ditampilkan di samping Mochi — {used}/{max} terpakai. Kunci disimpan di {store}, tidak pernah di disk." + }, + "Press keys…": { + "zh-Hans": "请按键…", + "hi": "कुंजियाँ दबाएँ…", + "es": "Pulsa las teclas…", + "ar": "اضغط المفاتيح…", + "fr": "Appuie sur les touches…", + "bn": "কী চাপুন…", + "pt-BR": "Pressione as teclas…", + "ru": "Нажмите клавиши…", + "id": "Tekan tombol…" + }, + "Previous pill": { + "zh-Hans": "上一个标签", + "hi": "पिछला टैब", + "es": "Pestaña anterior", + "ar": "التبويب السابق", + "fr": "Onglet précédent", + "bn": "আগের ট্যাব", + "pt-BR": "Aba anterior", + "ru": "Предыдущая вкладка", + "id": "Tab sebelumnya" + }, + "Pumpkin": { + "zh-Hans": "南瓜", + "hi": "कद्दू", + "es": "Calabaza", + "ar": "يقطينة", + "fr": "Citrouille", + "bn": "কুমড়া", + "pt-BR": "Abóbora", + "ru": "Тыква", + "id": "Labu" + }, + "Questions": { + "zh-Hans": "问题", + "hi": "प्रश्न", + "es": "Preguntas", + "ar": "الأسئلة", + "fr": "Questions", + "bn": "প্রশ্ন", + "pt-BR": "Perguntas", + "ru": "Вопросы", + "id": "Pertanyaan" + }, + "Ready": { + "zh-Hans": "就绪", + "hi": "तैयार", + "es": "Listo", + "ar": "جاهز", + "fr": "Prêt", + "bn": "প্রস্তুত", + "pt-BR": "Pronto", + "ru": "Готово", + "id": "Siap" + }, + "Ready · no setup needed": { + "zh-Hans": "就绪 · 无需设置", + "hi": "तैयार · कोई सेटअप ज़रूरी नहीं", + "es": "Listo · sin configuración", + "ar": "جاهز · لا يحتاج إلى إعداد", + "fr": "Prêt · rien à configurer", + "bn": "প্রস্তুত · সেটআপ লাগবে না", + "pt-BR": "Pronto · sem configuração", + "ru": "Готово · настройка не нужна", + "id": "Siap · tanpa pengaturan" + }, + "Reinstall hooks…": { + "zh-Hans": "重新安装 hooks…", + "hi": "hooks फिर से इंस्टॉल करें…", + "es": "Reinstalar hooks…", + "ar": "إعادة تثبيت الـ hooks…", + "fr": "Réinstaller les hooks…", + "bn": "hooks আবার ইনস্টল করুন…", + "pt-BR": "Reinstalar hooks…", + "ru": "Переустановить хуки…", + "id": "Pasang ulang hooks…" + }, + "Reinstall…": { + "zh-Hans": "重新安装…", + "hi": "फिर से इंस्टॉल करें…", + "es": "Reinstalar…", + "ar": "إعادة التثبيت…", + "fr": "Réinstaller…", + "bn": "আবার ইনস্টল করুন…", + "pt-BR": "Reinstalar…", + "ru": "Переустановить…", + "id": "Pasang ulang…" + }, + "Relay": { + "zh-Hans": "中继", + "hi": "रिले", + "es": "Relay", + "ar": "المُرحِّل", + "fr": "Relais", + "bn": "রিলে", + "pt-BR": "Relay", + "ru": "Ретранслятор", + "id": "Relay" + }, + "Remove": { + "zh-Hans": "移除", + "hi": "हटाएँ", + "es": "Quitar", + "ar": "إزالة", + "fr": "Retirer", + "bn": "সরান", + "pt-BR": "Remover", + "ru": "Удалить", + "id": "Hapus" + }, + "Repositories": { + "zh-Hans": "仓库", + "hi": "रिपॉज़िटरी", + "es": "Repositorios", + "ar": "المستودعات", + "fr": "Dépôts", + "bn": "রিপোজিটরি", + "pt-BR": "Repositórios", + "ru": "Репозитории", + "id": "Repositori" + }, + "Reset to defaults": { + "zh-Hans": "恢复默认", + "hi": "डिफ़ॉल्ट पर रीसेट करें", + "es": "Restablecer valores predeterminados", + "ar": "استعادة الإعدادات الافتراضية", + "fr": "Rétablir les valeurs par défaut", + "bn": "ডিফল্টে ফেরান", + "pt-BR": "Restaurar padrões", + "ru": "Сбросить по умолчанию", + "id": "Kembalikan ke bawaan" + }, + "Resets": { + "zh-Hans": "重置", + "hi": "रीसेट", + "es": "Reinicios", + "ar": "إعادات التعيين", + "fr": "Réinitialisations", + "bn": "রিসেট", + "pt-BR": "Redefinições", + "ru": "Сбросы", + "id": "Reset" + }, + "Restart Amp to load the plugin.": { + "zh-Hans": "重启 Amp 以加载插件。", + "hi": "प्लगइन लोड करने के लिए Amp फिर से शुरू करें।", + "es": "Reinicia Amp para cargar el plugin.", + "ar": "أعد تشغيل Amp لتحميل الإضافة.", + "fr": "Redémarre Amp pour charger le plugin.", + "bn": "প্লাগইন লোড করতে Amp আবার চালু করুন।", + "pt-BR": "Reinicie o Amp para carregar o plugin.", + "ru": "Перезапустите Amp, чтобы загрузить плагин.", + "id": "Mulai ulang Amp untuk memuat plugin." + }, + "Restart Cursor to pick the hooks up.": { + "zh-Hans": "重启 Cursor 即可启用 hooks。", + "hi": "hooks लागू करने के लिए Cursor फिर से शुरू करें।", + "es": "Reinicia Cursor para que cargue los hooks.", + "ar": "أعد تشغيل Cursor لتفعيل الـ hooks.", + "fr": "Redémarre Cursor pour qu'il prenne les hooks.", + "bn": "hooks চালু করতে Cursor আবার চালু করুন।", + "pt-BR": "Reinicie o Cursor para carregar os hooks.", + "ru": "Перезапустите Cursor, чтобы хуки заработали.", + "id": "Mulai ulang Cursor agar hooks dimuat." + }, + "Restart OpenCode to load the plugin.": { + "zh-Hans": "重启 OpenCode 以加载插件。", + "hi": "प्लगइन लोड करने के लिए OpenCode फिर से शुरू करें।", + "es": "Reinicia OpenCode para cargar el plugin.", + "ar": "أعد تشغيل OpenCode لتحميل الإضافة.", + "fr": "Redémarre OpenCode pour charger le plugin.", + "bn": "প্লাগইন লোড করতে OpenCode আবার চালু করুন।", + "pt-BR": "Reinicie o OpenCode para carregar o plugin.", + "ru": "Перезапустите OpenCode, чтобы загрузить плагин.", + "id": "Mulai ulang OpenCode untuk memuat plugin." + }, + "Result": { + "zh-Hans": "结果", + "hi": "परिणाम", + "es": "Resultado", + "ar": "النتيجة", + "fr": "Résultat", + "bn": "ফলাফল", + "pt-BR": "Resultado", + "ru": "Результат", + "id": "Hasil" + }, + "Round glasses": { + "zh-Hans": "圆框眼镜", + "hi": "गोल चश्मा", + "es": "Gafas redondas", + "ar": "نظارة دائرية", + "fr": "Lunettes rondes", + "bn": "গোল চশমা", + "pt-BR": "Óculos redondos", + "ru": "Круглые очки", + "id": "Kacamata bulat" + }, + "Santa hat": { + "zh-Hans": "圣诞帽", + "hi": "सांता टोपी", + "es": "Gorro de Papá Noel", + "ar": "قبعة بابا نويل", + "fr": "Bonnet de Père Noël", + "bn": "সান্তা টুপি", + "pt-BR": "Gorro de Papai Noel", + "ru": "Шапка Санты", + "id": "Topi Sinterklas" + }, + "Saturday": { + "zh-Hans": "星期六", + "hi": "शनिवार", + "es": "Sábado", + "ar": "السبت", + "fr": "Samedi", + "bn": "শনিবার", + "pt-BR": "Sábado", + "ru": "Суббота", + "id": "Sabtu" + }, + "Save image": { + "zh-Hans": "保存图片", + "hi": "छवि सहेजें", + "es": "Guardar imagen", + "ar": "حفظ الصورة", + "fr": "Enregistrer l'image", + "bn": "ছবি সংরক্ষণ করুন", + "pt-BR": "Salvar imagem", + "ru": "Сохранить изображение", + "id": "Simpan gambar" + }, + "Save key": { + "zh-Hans": "保存密钥", + "hi": "कुंजी सहेजें", + "es": "Guardar clave", + "ar": "حفظ المفتاح", + "fr": "Enregistrer la clé", + "bn": "কী সংরক্ষণ করুন", + "pt-BR": "Salvar chave", + "ru": "Сохранить ключ", + "id": "Simpan kunci" + }, + "Saved as {name}": { + "zh-Hans": "已保存为 {name}", + "hi": "{name} के रूप में सहेजा गया", + "es": "Guardada como {name}", + "ar": "حُفظت باسم {name}", + "fr": "Enregistrée sous {name}", + "bn": "{name} নামে সংরক্ষিত", + "pt-BR": "Salva como {name}", + "ru": "Сохранено как {name}", + "id": "Disimpan sebagai {name}" + }, + "Saved. It never touches disk.": { + "zh-Hans": "已保存。它从不写入磁盘。", + "hi": "सहेजी गई। यह कभी डिस्क पर नहीं जाती।", + "es": "Guardada. Nunca toca el disco.", + "ar": "تم الحفظ. لا يُكتب على القرص أبدًا.", + "fr": "Enregistrée. Elle ne touche jamais le disque.", + "bn": "সংরক্ষিত। এটি কখনো ডিস্কে লেখা হয় না।", + "pt-BR": "Salva. Ela nunca vai para o disco.", + "ru": "Сохранено. Ключ никогда не попадает на диск.", + "id": "Tersimpan. Kunci tidak pernah ditulis ke disk." + }, + "Saving…": { + "zh-Hans": "正在保存…", + "hi": "सहेजा जा रहा है…", + "es": "Guardando…", + "ar": "جارٍ الحفظ…", + "fr": "Enregistrement…", + "bn": "সংরক্ষণ হচ্ছে…", + "pt-BR": "Salvando…", + "ru": "Сохранение…", + "id": "Menyimpan…" + }, + "Scarf": { + "zh-Hans": "围巾", + "hi": "मफ़लर", + "es": "Bufanda", + "ar": "وشاح", + "fr": "Écharpe", + "bn": "মাফলার", + "pt-BR": "Cachecol", + "ru": "Шарф", + "id": "Syal" + }, + "Secret key": { + "zh-Hans": "私密密钥", + "hi": "सीक्रेट कुंजी", + "es": "Clave secreta", + "ar": "المفتاح السري", + "fr": "Clé secrète", + "bn": "সিক্রেট কী", + "pt-BR": "Chave secreta", + "ru": "Секретный ключ", + "id": "Kunci rahasia" + }, + "Send Mochi to the desktop": { + "zh-Hans": "把 Mochi 送到桌面", + "hi": "Mochi को डेस्कटॉप पर भेजें", + "es": "Enviar a Mochi al escritorio", + "ar": "إرسال Mochi إلى سطح المكتب", + "fr": "Envoyer Mochi sur le bureau", + "bn": "Mochi-কে ডেস্কটপে পাঠান", + "pt-BR": "Enviar o Mochi para a área de trabalho", + "ru": "Отправить Mochi на рабочий стол", + "id": "Kirim Mochi ke desktop" + }, + "Send the message": { + "zh-Hans": "发送消息", + "hi": "संदेश भेजें", + "es": "Enviar el mensaje", + "ar": "إرسال الرسالة", + "fr": "Envoyer le message", + "bn": "বার্তা পাঠান", + "pt-BR": "Enviar a mensagem", + "ru": "Отправить сообщение", + "id": "Kirim pesan" + }, + "Sending by email isn't in this version.": { + "zh-Hans": "此版本暂不支持通过邮件发送。", + "hi": "ईमेल से भेजना इस संस्करण में नहीं है।", + "es": "El envío por correo no está en esta versión.", + "ar": "الإرسال بالبريد الإلكتروني غير متاح في هذا الإصدار.", + "fr": "L'envoi par e-mail n'est pas dans cette version.", + "bn": "ইমেলে পাঠানো এই সংস্করণে নেই।", + "pt-BR": "O envio por e-mail não está nesta versão.", + "ru": "Отправки по почте в этой версии нет.", + "id": "Mengirim lewat email belum ada di versi ini." + }, + "Services": { + "zh-Hans": "服务", + "hi": "सेवाएँ", + "es": "Servicios", + "ar": "الخدمات", + "fr": "Services", + "bn": "পরিষেবা", + "pt-BR": "Serviços", + "ru": "Сервисы", + "id": "Layanan" + }, + "Session stopped on an error.": { + "zh-Hans": "会话因错误而停止。", + "hi": "सत्र एक त्रुटि पर रुक गया।", + "es": "La sesión se detuvo por un error.", + "ar": "توقفت الجلسة بسبب خطأ.", + "fr": "La session s'est arrêtée sur une erreur.", + "bn": "একটি ত্রুটিতে সেশন থেমে গেছে।", + "pt-BR": "A sessão parou por um erro.", + "ru": "Сессия остановилась из-за ошибки.", + "id": "Sesi berhenti karena galat." + }, + "Sessions — approvals stay in the agent": { + "zh-Hans": "会话 — 审批仍在代理中进行", + "hi": "सत्र — अनुमतियाँ एजेंट में ही रहती हैं", + "es": "Sesiones — las aprobaciones se quedan en el agente", + "ar": "الجلسات — تبقى الموافقات داخل الوكيل", + "fr": "Sessions — les validations restent dans l'agent", + "bn": "সেশন — অনুমোদন এজেন্টেই থাকে", + "pt-BR": "Sessões — as aprovações ficam no agente", + "ru": "Сессии — подтверждения остаются в агенте", + "id": "Sesi — persetujuan tetap di agen" + }, + "Sessions, and Allow / Deny from the island": { + "zh-Hans": "会话,以及在岛中允许 / 拒绝", + "hi": "सत्र, और आइलैंड से अनुमति दें / अस्वीकार करें", + "es": "Sesiones, y Permitir / Denegar desde la isla", + "ar": "الجلسات، والسماح / الرفض من الشريط", + "fr": "Sessions, et Autoriser / Refuser depuis l'île", + "bn": "সেশন, আর আইল্যান্ড থেকে অনুমতি / প্রত্যাখ্যান", + "pt-BR": "Sessões, e Permitir / Negar pela ilha", + "ru": "Сессии и «Разрешить / Отклонить» из острова", + "id": "Sesi, dan Izinkan / Tolak dari pulau" + }, + "Settings": { + "zh-Hans": "设置", + "hi": "सेटिंग्स", + "es": "Configuración", + "ar": "الإعدادات", + "fr": "Réglages", + "bn": "সেটিংস", + "pt-BR": "Configurações", + "ru": "Настройки", + "id": "Pengaturan" + }, + "Share your week": { + "zh-Hans": "分享你的一周", + "hi": "अपना हफ़्ता शेयर करें", + "es": "Comparte tu semana", + "ar": "شارك أسبوعك", + "fr": "Partage ta semaine", + "bn": "আপনার সপ্তাহ শেয়ার করুন", + "pt-BR": "Compartilhe sua semana", + "ru": "Поделитесь своей неделей", + "id": "Bagikan minggu Anda" + }, + "Show Codex plan in the notch": { + "zh-Hans": "在刘海中显示 Codex 套餐", + "hi": "नॉच में Codex प्लान दिखाएं", + "es": "Mostrar el plan de Codex en el notch", + "ar": "عرض خطة Codex في الشريط العلوي", + "fr": "Afficher le forfait Codex dans l'encoche", + "bn": "নচে Codex প্ল্যান দেখান", + "pt-BR": "Mostrar o plano Codex no notch", + "ru": "Показывать тариф Codex в нотче", + "id": "Tampilkan paket Codex di notch" + }, + "Show in folder": { + "zh-Hans": "在文件夹中显示", + "hi": "फ़ोल्डर में दिखाएँ", + "es": "Mostrar en la carpeta", + "ar": "إظهار في المجلد", + "fr": "Afficher dans le dossier", + "bn": "ফোল্ডারে দেখান", + "pt-BR": "Mostrar na pasta", + "ru": "Показать в папке", + "id": "Tampilkan di folder" + }, + "Show other coding agents in the island. Coucou adds its entries to each agent's own config: you see the exact change and where the backup goes before anything is written, and uninstalling removes only what Coucou added.": { + "zh-Hans": "在岛中显示其他编程代理。Coucou 会把自己的条目添加到各代理的配置中:写入之前,你会看到确切的改动和备份位置;卸载只会移除 Coucou 添加的内容。", + "hi": "दूसरे कोडिंग एजेंटों को आइलैंड में दिखाएँ। Coucou हर एजेंट की अपनी कॉन्फ़िग में अपनी प्रविष्टियाँ जोड़ता है: कुछ भी लिखे जाने से पहले आप सटीक बदलाव और बैकअप की जगह देखते हैं, और अनइंस्टॉल करने पर केवल वही हटता है जो Coucou ने जोड़ा था।", + "es": "Muestra otros agentes de programación en la isla. Coucou añade sus entradas a la configuración de cada agente: ves el cambio exacto y dónde va la copia de seguridad antes de que se escriba nada, y al desinstalar solo se quita lo que añadió Coucou.", + "ar": "اعرض وكلاء البرمجة الآخرين في الشريط. يضيف Coucou إدخالاته إلى إعدادات كل وكيل: ترى التغيير بالضبط ومكان النسخة الاحتياطية قبل كتابة أي شيء، وإلغاء التثبيت يزيل فقط ما أضافه Coucou.", + "fr": "Affiche les autres agents de code dans l'île. Coucou ajoute ses entrées à la configuration de chaque agent : tu vois la modification exacte et l'emplacement de la sauvegarde avant que quoi que ce soit soit écrit, et la désinstallation ne retire que ce que Coucou a ajouté.", + "bn": "অন্যান্য কোডিং এজেন্টকে আইল্যান্ডে দেখান। Coucou প্রতিটি এজেন্টের নিজস্ব কনফিগে তার এন্ট্রি যোগ করে: কিছু লেখার আগে আপনি সঠিক পরিবর্তন এবং ব্যাকআপ কোথায় যাবে তা দেখতে পান, আর আনইনস্টল করলে শুধু Coucou যা যোগ করেছিল তা-ই সরানো হয়।", + "pt-BR": "Mostre outros agentes de programação na ilha. O Coucou adiciona as próprias entradas à configuração de cada agente: você vê a alteração exata e para onde vai o backup antes de qualquer gravação, e desinstalar remove só o que o Coucou adicionou.", + "ru": "Показывайте другие агенты для кода в острове. Coucou добавляет свои записи в конфигурацию каждого агента: до любой записи вы видите точное изменение и куда пойдёт резервная копия, а удаление убирает только то, что добавил Coucou.", + "id": "Tampilkan agen coding lain di pulau. Coucou menambahkan entrinya ke konfigurasi masing-masing agen: Anda melihat perubahan persisnya dan lokasi cadangan sebelum apa pun ditulis, dan mencopot pemasangan hanya menghapus yang ditambahkan Coucou." + }, + "Shows your Claude plan usage (5-hour and weekly limits) in the island's header. Coucou adds a status line relay in ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.": { + "zh-Hans": "在岛的顶栏显示你的 Claude 套餐用量(5 小时和每周限额)。Coucou 会在 ~/.claude/settings.json 中添加一个状态栏中继。如果你已有状态栏,它会照常工作。仅限 Pro 和 Max 套餐。", + "hi": "आइलैंड के हेडर में आपके Claude प्लान का उपयोग (5 घंटे और साप्ताहिक सीमा) दिखाता है। Coucou ~/.claude/settings.json में एक स्टेटस लाइन रिले जोड़ता है। अगर आपके पास पहले से स्टेटस लाइन है, तो वह पहले की तरह चलती रहेगी। केवल Pro और Max प्लान।", + "es": "Muestra el uso de tu plan de Claude (límites de 5 horas y semanal) en la cabecera de la isla. Coucou añade un relay de línea de estado en ~/.claude/settings.json. Si ya tienes una línea de estado, sigue funcionando como antes. Solo planes Pro y Max.", + "ar": "يعرض استخدام خطة Claude (حدّا الخمس ساعات والأسبوع) في رأس الشريط. يضيف Coucou مُرحِّلًا لسطر الحالة في ~/.claude/settings.json. إن كان لديك سطر حالة فسيستمر في العمل كما كان. لخطتَي Pro وMax فقط.", + "fr": "Affiche l'utilisation de ton forfait Claude (limites sur 5 heures et à la semaine) dans l'en-tête de l'île. Coucou ajoute un relais de ligne d'état dans ~/.claude/settings.json. Si tu as déjà une ligne d'état, elle continue de fonctionner comme avant. Forfaits Pro et Max uniquement.", + "bn": "আইল্যান্ডের হেডারে আপনার Claude প্ল্যানের ব্যবহার (৫ ঘণ্টা ও সাপ্তাহিক সীমা) দেখায়। Coucou ~/.claude/settings.json-এ একটি স্ট্যাটাস লাইন রিলে যোগ করে। আপনার আগে থেকে স্ট্যাটাস লাইন থাকলে সেটি আগের মতোই চলবে। শুধু Pro ও Max প্ল্যান।", + "pt-BR": "Mostra o uso do seu plano Claude (limites de 5 horas e semanal) no cabeçalho da ilha. O Coucou adiciona um relay de linha de status em ~/.claude/settings.json. Se você já tem uma linha de status, ela continua funcionando como antes. Apenas planos Pro e Max.", + "ru": "Показывает использование тарифа Claude (лимиты на 5 часов и на неделю) в заголовке острова. Coucou добавляет ретранслятор строки состояния в ~/.claude/settings.json. Если у вас уже есть строка состояния, она работает как прежде. Только тарифы Pro и Max.", + "id": "Menampilkan pemakaian paket Claude Anda (batas 5 jam dan mingguan) di header pulau. Coucou menambahkan relay baris status di ~/.claude/settings.json. Jika Anda sudah punya baris status, baris itu tetap berjalan seperti biasa. Khusus paket Pro dan Max." + }, + "Shows your Codex plan usage (weekly limit and free resets left) in the island's header. Coucou asks the Codex CLI (codex app-server) when the pill shows; nothing is installed. Codex must be signed in with ChatGPT.": { + "zh-Hans": "在岛的顶栏显示你的 Codex 套餐用量(每周限额和剩余的免费重置次数)。标签显示时,Coucou 会询问 Codex CLI(codex app-server),不会安装任何东西。Codex 必须已用 ChatGPT 登录。", + "hi": "आइलैंड के हेडर में आपके Codex प्लान का उपयोग (साप्ताहिक सीमा और बचे मुफ़्त रीसेट) दिखाता है। टैब दिखने पर Coucou Codex CLI (codex app-server) से पूछता है; कुछ भी इंस्टॉल नहीं होता। Codex में ChatGPT से साइन इन होना ज़रूरी है।", + "es": "Muestra el uso de tu plan de Codex (límite semanal y reinicios gratuitos restantes) en la cabecera de la isla. Coucou consulta a la CLI de Codex (codex app-server) cuando aparece la pestaña; no se instala nada. Codex debe tener la sesión iniciada con ChatGPT.", + "ar": "يعرض استخدام خطة Codex (الحد الأسبوعي وإعادات التعيين المجانية المتبقية) في رأس الشريط. يسأل Coucou واجهة Codex CLI (codex app-server) عند ظهور التبويب، ولا يُثبَّت أي شيء. يجب تسجيل الدخول إلى Codex بحساب ChatGPT.", + "fr": "Affiche l'utilisation de ton forfait Codex (limite hebdomadaire et réinitialisations gratuites restantes) dans l'en-tête de l'île. Coucou interroge la CLI Codex (codex app-server) quand l'onglet s'affiche ; rien n'est installé. Codex doit être connecté avec ChatGPT.", + "bn": "আইল্যান্ডের হেডারে আপনার Codex প্ল্যানের ব্যবহার (সাপ্তাহিক সীমা ও বাকি ফ্রি রিসেট) দেখায়। ট্যাব দেখা গেলে Coucou Codex CLI (codex app-server)-কে জিজ্ঞেস করে; কিছুই ইনস্টল হয় না। Codex-এ ChatGPT দিয়ে সাইন ইন থাকতে হবে।", + "pt-BR": "Mostra o uso do seu plano Codex (limite semanal e redefinições grátis restantes) no cabeçalho da ilha. O Coucou consulta a CLI do Codex (codex app-server) quando a aba aparece; nada é instalado. O Codex precisa estar conectado com o ChatGPT.", + "ru": "Показывает использование тарифа Codex (недельный лимит и оставшиеся бесплатные сбросы) в заголовке острова. Coucou спрашивает Codex CLI (codex app-server), когда видна вкладка; ничего не устанавливается. В Codex нужно войти через ChatGPT.", + "id": "Menampilkan pemakaian paket Codex Anda (batas mingguan dan sisa reset gratis) di header pulau. Coucou menanyakan Codex CLI (codex app-server) saat tab tampil; tidak ada yang dipasang. Codex harus masuk dengan ChatGPT." + }, + "Start a new Antigravity conversation to pick the hooks up.": { + "zh-Hans": "开启新的 Antigravity 对话即可启用 hooks。", + "hi": "hooks लागू करने के लिए Antigravity में नई बातचीत शुरू करें।", + "es": "Abre una nueva conversación de Antigravity para que cargue los hooks.", + "ar": "ابدأ محادثة Antigravity جديدة لتفعيل الـ hooks.", + "fr": "Lance une nouvelle conversation Antigravity pour qu'elle prenne les hooks.", + "bn": "hooks চালু করতে Antigravity-তে নতুন কথোপকথন শুরু করুন।", + "pt-BR": "Inicie uma nova conversa no Antigravity para carregar os hooks.", + "ru": "Начните новый разговор в Antigravity, чтобы хуки заработали.", + "id": "Mulai percakapan Antigravity baru agar hooks dimuat." + }, + "Start a new Copilot CLI session to pick the hooks up.": { + "zh-Hans": "开启新的 Copilot CLI 会话即可启用 hooks。", + "hi": "hooks लागू करने के लिए Copilot CLI का नया सत्र शुरू करें।", + "es": "Abre una nueva sesión de Copilot CLI para que cargue los hooks.", + "ar": "ابدأ جلسة Copilot CLI جديدة لتفعيل الـ hooks.", + "fr": "Lance une nouvelle session Copilot CLI pour qu'elle prenne les hooks.", + "bn": "hooks চালু করতে Copilot CLI-এর নতুন সেশন শুরু করুন।", + "pt-BR": "Inicie uma nova sessão do Copilot CLI para carregar os hooks.", + "ru": "Начните новую сессию Copilot CLI, чтобы хуки заработали.", + "id": "Mulai sesi Copilot CLI baru agar hooks dimuat." + }, + "Start a new Gemini CLI session to pick the hooks up.": { + "zh-Hans": "开启新的 Gemini CLI 会话即可启用 hooks。", + "hi": "hooks लागू करने के लिए Gemini CLI का नया सत्र शुरू करें।", + "es": "Abre una nueva sesión de Gemini CLI para que cargue los hooks.", + "ar": "ابدأ جلسة Gemini CLI جديدة لتفعيل الـ hooks.", + "fr": "Lance une nouvelle session Gemini CLI pour qu'elle prenne les hooks.", + "bn": "hooks চালু করতে Gemini CLI-এর নতুন সেশন শুরু করুন।", + "pt-BR": "Inicie uma nova sessão do Gemini CLI para carregar os hooks.", + "ru": "Начните новую сессию Gemini CLI, чтобы хуки заработали.", + "id": "Mulai sesi Gemini CLI baru agar hooks dimuat." + }, + "Start a new Muse Code session to pick the hooks up.": { + "zh-Hans": "开启新的 Muse Code 会话即可启用 hooks。", + "hi": "hooks लागू करने के लिए Muse Code का नया सत्र शुरू करें।", + "es": "Abre una nueva sesión de Muse Code para que cargue los hooks.", + "ar": "ابدأ جلسة Muse Code جديدة لتفعيل الـ hooks.", + "fr": "Lance une nouvelle session Muse Code pour qu'elle prenne les hooks.", + "bn": "hooks চালু করতে Muse Code-এর নতুন সেশন শুরু করুন।", + "pt-BR": "Inicie uma nova sessão do Muse Code para carregar os hooks.", + "ru": "Начните новую сессию Muse Code, чтобы хуки заработали.", + "id": "Mulai sesi Muse Code baru agar hooks dimuat." + }, + "Start a new chat": { + "zh-Hans": "开始新聊天", + "hi": "नई चैट शुरू करें", + "es": "Empezar un chat nuevo", + "ar": "بدء دردشة جديدة", + "fr": "Commencer un nouveau chat", + "bn": "নতুন চ্যাট শুরু করুন", + "pt-BR": "Começar um novo chat", + "ru": "Начать новый чат", + "id": "Mulai obrolan baru" + }, + "Sunday": { + "zh-Hans": "星期日", + "hi": "रविवार", + "es": "Domingo", + "ar": "الأحد", + "fr": "Dimanche", + "bn": "রবিবার", + "pt-BR": "Domingo", + "ru": "Воскресенье", + "id": "Minggu" + }, + "Sunglasses": { + "zh-Hans": "墨镜", + "hi": "धूप का चश्मा", + "es": "Gafas de sol", + "ar": "نظارة شمسية", + "fr": "Lunettes de soleil", + "bn": "সানগ্লাস", + "pt-BR": "Óculos de sol", + "ru": "Солнечные очки", + "id": "Kacamata hitam" + }, + "Switch provider or model": { + "zh-Hans": "切换服务商或模型", + "hi": "प्रदाता या मॉडल बदलें", + "es": "Cambiar de proveedor o modelo", + "ar": "تبديل المزوّد أو النموذج", + "fr": "Changer de fournisseur ou de modèle", + "bn": "প্রদানকারী বা মডেল বদলান", + "pt-BR": "Trocar de provedor ou modelo", + "ru": "Сменить провайдера или модель", + "id": "Ganti penyedia atau model" + }, + "That key can't be used in a shortcut.": { + "zh-Hans": "这个键不能用于快捷键。", + "hi": "उस कुंजी का शॉर्टकट में उपयोग नहीं हो सकता।", + "es": "Esa tecla no se puede usar en un atajo.", + "ar": "لا يمكن استخدام هذا المفتاح في اختصار.", + "fr": "Cette touche ne peut pas servir dans un raccourci.", + "bn": "সেই কী শর্টকাটে ব্যবহার করা যায় না।", + "pt-BR": "Essa tecla não pode ser usada num atalho.", + "ru": "Эту клавишу нельзя использовать в сочетании.", + "id": "Tombol itu tidak dapat dipakai dalam pintasan." + }, + "The address must start with http:// or https://.": { + "zh-Hans": "地址必须以 http:// 或 https:// 开头。", + "hi": "पता http:// या https:// से शुरू होना चाहिए।", + "es": "La dirección debe empezar por http:// o https://.", + "ar": "يجب أن يبدأ العنوان بـ http:// أو https://.", + "fr": "L'adresse doit commencer par http:// ou https://.", + "bn": "ঠিকানাটি http:// বা https:// দিয়ে শুরু হতে হবে।", + "pt-BR": "O endereço precisa começar com http:// ou https://.", + "ru": "Адрес должен начинаться с http:// или https://.", + "id": "Alamat harus diawali http:// atau https://." + }, + "The file is removed.": { + "zh-Hans": "该文件将被删除。", + "hi": "फ़ाइल हटा दी जाती है।", + "es": "El archivo se elimina.", + "ar": "سيُحذف الملف.", + "fr": "Le fichier est supprimé.", + "bn": "ফাইলটি সরানো হবে।", + "pt-BR": "O arquivo é removido.", + "ru": "Файл удаляется.", + "id": "File dihapus." + }, + "The image could not be read.": { + "zh-Hans": "无法读取图片。", + "hi": "छवि पढ़ी नहीं जा सकी।", + "es": "No se pudo leer la imagen.", + "ar": "تعذّرت قراءة الصورة.", + "fr": "L'image n'a pas pu être lue.", + "bn": "ছবিটি পড়া যায়নি।", + "pt-BR": "Não foi possível ler a imagem.", + "ru": "Не удалось прочитать изображение.", + "id": "Gambar tidak dapat dibaca." + }, + "The image is too large.": { + "zh-Hans": "图片过大。", + "hi": "छवि बहुत बड़ी है।", + "es": "La imagen es demasiado grande.", + "ar": "الصورة كبيرة جدًا.", + "fr": "L'image est trop volumineuse.", + "bn": "ছবিটি অনেক বড়।", + "pt-BR": "A imagem é grande demais.", + "ru": "Изображение слишком большое.", + "id": "Gambar terlalu besar." + }, + "The preview is out of date. Nothing was written — review the new diff.": { + "zh-Hans": "预览已过期。未写入任何内容 — 请查看新的差异。", + "hi": "प्रीव्यू पुराना हो गया है। कुछ नहीं लिखा गया — नया डिफ़ देखें।", + "es": "La vista previa está desactualizada. No se escribió nada — revisa el nuevo diff.", + "ar": "المعاينة قديمة. لم يُكتب أي شيء — راجع الفروقات الجديدة.", + "fr": "L'aperçu n'est plus à jour. Rien n'a été écrit — relis le nouveau diff.", + "bn": "প্রিভিউ পুরোনো হয়ে গেছে। কিছু লেখা হয়নি — নতুন ডিফ দেখুন।", + "pt-BR": "A prévia está desatualizada. Nada foi gravado — revise o novo diff.", + "ru": "Предпросмотр устарел. Ничего не записано — проверьте новый diff.", + "id": "Pratinjau sudah usang. Tidak ada yang ditulis — tinjau diff yang baru." + }, + "The relay isn't installed yet.": { + "zh-Hans": "中继尚未安装。", + "hi": "रिले अभी इंस्टॉल नहीं है।", + "es": "El relay aún no está instalado.", + "ar": "المُرحِّل غير مثبَّت بعد.", + "fr": "Le relais n'est pas encore installé.", + "bn": "রিলে এখনো ইনস্টল হয়নি।", + "pt-BR": "O relay ainda não está instalado.", + "ru": "Ретранслятор ещё не установлен.", + "id": "Relay belum terpasang." + }, + "The relay isn't installed yet. Restart Coucou.": { + "zh-Hans": "中继尚未安装。请重启 Coucou。", + "hi": "रिले अभी इंस्टॉल नहीं है। Coucou को फिर से शुरू करें।", + "es": "El relay aún no está instalado. Reinicia Coucou.", + "ar": "المُرحِّل غير مثبَّت بعد. أعد تشغيل Coucou.", + "fr": "Le relais n'est pas encore installé. Redémarre Coucou.", + "bn": "রিলে এখনো ইনস্টল হয়নি। Coucou আবার চালু করুন।", + "pt-BR": "O relay ainda não está instalado. Reinicie o Coucou.", + "ru": "Ретранслятор ещё не установлен. Перезапустите Coucou.", + "id": "Relay belum terpasang. Mulai ulang Coucou." + }, + "The server refused the key. Check it, then connect again.": { + "zh-Hans": "服务器拒绝了该密钥。请检查后重新连接。", + "hi": "सर्वर ने कुंजी अस्वीकार कर दी। उसे जाँचें, फिर दोबारा कनेक्ट करें।", + "es": "El servidor rechazó la clave. Revísala y vuelve a conectar.", + "ar": "رفض الخادم المفتاح. تحقّق منه ثم اتصل مجددًا.", + "fr": "Le serveur a refusé la clé. Vérifie-la, puis reconnecte-toi.", + "bn": "সার্ভার কী প্রত্যাখ্যান করেছে। যাচাই করে আবার সংযুক্ত করুন।", + "pt-BR": "O servidor recusou a chave. Verifique-a e conecte de novo.", + "ru": "Сервер отклонил ключ. Проверьте его и подключитесь снова.", + "id": "Server menolak kunci. Periksa, lalu hubungkan lagi." + }, + "The server's answer is too large.": { + "zh-Hans": "服务器的回复过大。", + "hi": "सर्वर का जवाब बहुत बड़ा है।", + "es": "La respuesta del servidor es demasiado grande.", + "ar": "رد الخادم كبير جدًا.", + "fr": "La réponse du serveur est trop volumineuse.", + "bn": "সার্ভারের উত্তর অনেক বড়।", + "pt-BR": "A resposta do servidor é grande demais.", + "ru": "Ответ сервера слишком большой.", + "id": "Jawaban server terlalu besar." + }, + "This address is another machine, over plain http: what you ask travels unencrypted.": { + "zh-Hans": "这个地址是另一台机器,且使用明文 http:你的提问会以未加密的形式传输。", + "hi": "यह पता किसी दूसरी मशीन का है, सादे http पर: आप जो पूछते हैं वह बिना एन्क्रिप्शन के जाता है।", + "es": "Esta dirección es otra máquina, por http sin cifrar: lo que preguntas viaja sin cifrado.", + "ar": "هذا العنوان لجهاز آخر عبر http عادي: ما تسأله ينتقل دون تشفير.", + "fr": "Cette adresse est une autre machine, en http simple : ce que tu demandes circule en clair.", + "bn": "এই ঠিকানাটি অন্য মেশিনের, সাধারণ http-তে: আপনি যা জিজ্ঞেস করেন তা এনক্রিপশন ছাড়া যায়।", + "pt-BR": "Este endereço é outra máquina, por http simples: o que você pergunta trafega sem criptografia.", + "ru": "Этот адрес — другой компьютер, по обычному http: ваши запросы передаются без шифрования.", + "id": "Alamat ini adalah mesin lain, melalui http biasa: pertanyaan Anda dikirim tanpa enkripsi." + }, + "This address is another machine: what you ask is sent to it.": { + "zh-Hans": "这个地址是另一台机器:你的提问会发送给它。", + "hi": "यह पता किसी दूसरी मशीन का है: आप जो पूछते हैं वह उसे भेजा जाता है।", + "es": "Esta dirección es otra máquina: lo que preguntas se le envía.", + "ar": "هذا العنوان لجهاز آخر: ما تسأله يُرسل إليه.", + "fr": "Cette adresse est une autre machine : ce que tu demandes lui est envoyé.", + "bn": "এই ঠিকানাটি অন্য একটি মেশিনের: আপনি যা জিজ্ঞেস করেন তা সেখানে পাঠানো হয়।", + "pt-BR": "Este endereço é outra máquina: o que você pergunta é enviado para ela.", + "ru": "Этот адрес — другой компьютер: ваши запросы отправляются туда.", + "id": "Alamat ini adalah mesin lain: pertanyaan Anda dikirim ke sana." + }, + "This agent takes a plugin, not hook entries.": { + "zh-Hans": "此代理使用插件,而不是 hook 条目。", + "hi": "यह एजेंट प्लगइन लेता है, hook प्रविष्टियाँ नहीं।", + "es": "Este agente usa un plugin, no entradas de hooks.", + "ar": "يستخدم هذا الوكيل إضافة، لا إدخالات hooks.", + "fr": "Cet agent prend un plugin, pas des entrées de hooks.", + "bn": "এই এজেন্ট প্লাগইন নেয়, hook এন্ট্রি নয়।", + "pt-BR": "Este agente usa um plugin, não entradas de hooks.", + "ru": "Этот агент использует плагин, а не записи хуков.", + "id": "Agen ini memakai plugin, bukan entri hook." + }, + "This is exactly what changes for {name}. Nothing else is touched.": { + "zh-Hans": "这正是 {name} 将发生的改动。其他内容不会被触碰。", + "hi": "{name} के लिए ठीक यही बदलेगा। और कुछ नहीं छुआ जाता।", + "es": "Esto es exactamente lo que cambia para {name}. No se toca nada más.", + "ar": "هذا بالضبط ما سيتغيّر في {name}. لن يُمسّ أي شيء آخر.", + "fr": "Voici exactement ce qui change pour {name}. Rien d'autre n'est touché.", + "bn": "{name}-এর জন্য ঠিক এটাই বদলাবে। আর কিছুতে হাত দেওয়া হয় না।", + "pt-BR": "Isto é exatamente o que muda para {name}. Nada mais é alterado.", + "ru": "Вот что именно изменится для {name}. Больше ничего не затрагивается.", + "id": "Inilah persisnya yang berubah untuk {name}. Tidak ada yang lain disentuh." + }, + "This is exactly what will change in your settings.json. Your own hooks are left untouched.": { + "zh-Hans": "这正是你的 settings.json 将发生的改动。你自己的 hooks 不会被触碰。", + "hi": "आपकी settings.json में ठीक यही बदलेगा। आपके अपने hooks नहीं छुए जाते।", + "es": "Esto es exactamente lo que cambiará en tu settings.json. Tus propios hooks no se tocan.", + "ar": "هذا بالضبط ما سيتغيّر في ملف settings.json. لن تُمسّ hooks الخاصة بك.", + "fr": "Voici exactement ce qui va changer dans ton settings.json. Tes propres hooks ne sont pas touchés.", + "bn": "আপনার settings.json-এ ঠিক এটাই বদলাবে। আপনার নিজের hooks-এ হাত দেওয়া হয় না।", + "pt-BR": "Isto é exatamente o que vai mudar no seu settings.json. Seus próprios hooks não são alterados.", + "ru": "Вот что именно изменится в вашем settings.json. Ваши собственные хуки не затрагиваются.", + "id": "Inilah persisnya yang akan berubah di settings.json Anda. Hooks milik Anda tidak disentuh." + }, + "This is exactly what will change: only the status line. If you already have one it keeps working, Coucou's relay runs it for you.": { + "zh-Hans": "这正是将发生的改动:只涉及状态栏。如果你已有状态栏,它会照常工作,由 Coucou 的中继替你运行。", + "hi": "ठीक यही बदलेगा: केवल स्टेटस लाइन। अगर आपके पास पहले से एक है तो वह चलती रहेगी, Coucou का रिले उसे आपके लिए चलाता है।", + "es": "Esto es exactamente lo que cambiará: solo la línea de estado. Si ya tienes una, sigue funcionando: el relay de Coucou la ejecuta por ti.", + "ar": "هذا بالضبط ما سيتغيّر: سطر الحالة فقط. إن كان لديك سطر حالة فسيستمر في العمل، إذ يشغّله مُرحِّل Coucou نيابةً عنك.", + "fr": "Voici exactement ce qui va changer : seulement la ligne d'état. Si tu en as déjà une, elle continue de fonctionner, le relais de Coucou la lance pour toi.", + "bn": "ঠিক এটাই বদলাবে: শুধু স্ট্যাটাস লাইন। আপনার আগে থেকে একটি থাকলে সেটি চলতেই থাকবে, Coucou-র রিলে আপনার হয়ে সেটি চালায়।", + "pt-BR": "Isto é exatamente o que vai mudar: apenas a linha de status. Se você já tem uma, ela continua funcionando: o relay do Coucou a executa para você.", + "ru": "Вот что именно изменится: только строка состояния. Если она у вас уже есть, она продолжит работать — её запускает ретранслятор Coucou.", + "id": "Inilah persisnya yang akan berubah: hanya baris status. Jika Anda sudah punya, baris itu tetap berjalan, relay Coucou menjalankannya untuk Anda." + }, + "This puts your previous status line back, or removes the entry if there was none.": { + "zh-Hans": "这会恢复你之前的状态栏;如果之前没有,则移除该条目。", + "hi": "यह आपकी पिछली स्टेटस लाइन वापस लगाता है, या अगर कोई नहीं थी तो प्रविष्टि हटा देता है।", + "es": "Esto devuelve tu línea de estado anterior, o quita la entrada si no había ninguna.", + "ar": "يعيد هذا سطر الحالة السابق، أو يزيل الإدخال إن لم يكن هناك سطر.", + "fr": "Ta ligne d'état précédente est remise en place, ou l'entrée est retirée s'il n'y en avait pas.", + "bn": "এটি আপনার আগের স্ট্যাটাস লাইন ফিরিয়ে আনে, অথবা আগে না থাকলে এন্ট্রিটি সরিয়ে দেয়।", + "pt-BR": "Isto restaura sua linha de status anterior, ou remove a entrada se não havia nenhuma.", + "ru": "Возвращается ваша прежняя строка состояния, а если её не было — запись удаляется.", + "id": "Ini mengembalikan baris status Anda sebelumnya, atau menghapus entri jika sebelumnya tidak ada." + }, + "This removes Coucou's entries only. Everything else stays.": { + "zh-Hans": "这只会移除 Coucou 的条目,其余内容保持不变。", + "hi": "यह केवल Coucou की प्रविष्टियाँ हटाता है। बाकी सब वैसा ही रहता है।", + "es": "Esto solo quita las entradas de Coucou. Todo lo demás se queda.", + "ar": "يزيل هذا إدخالات Coucou فقط. يبقى كل شيء آخر كما هو.", + "fr": "Seules les entrées de Coucou sont retirées. Tout le reste est conservé.", + "bn": "এটি শুধু Coucou-র এন্ট্রি সরায়। বাকি সব থেকে যায়।", + "pt-BR": "Isto remove apenas as entradas do Coucou. O resto continua.", + "ru": "Удаляются только записи Coucou. Всё остальное остаётся.", + "id": "Ini hanya menghapus entri Coucou. Yang lain tetap ada." + }, + "This removes Coucou's entries only. Your own hooks are left untouched.": { + "zh-Hans": "这只会移除 Coucou 的条目。你自己的 hooks 不会被触碰。", + "hi": "यह केवल Coucou की प्रविष्टियाँ हटाता है। आपके अपने hooks नहीं छुए जाते।", + "es": "Esto solo quita las entradas de Coucou. Tus propios hooks no se tocan.", + "ar": "يزيل هذا إدخالات Coucou فقط. لن تُمسّ hooks الخاصة بك.", + "fr": "Seules les entrées de Coucou sont retirées. Tes propres hooks ne sont pas touchés.", + "bn": "এটি শুধু Coucou-র এন্ট্রি সরায়। আপনার নিজের hooks-এ হাত দেওয়া হয় না।", + "pt-BR": "Isto remove apenas as entradas do Coucou. Seus próprios hooks não são alterados.", + "ru": "Удаляются только записи Coucou. Ваши собственные хуки не затрагиваются.", + "id": "Ini hanya menghapus entri Coucou. Hooks milik Anda tidak disentuh." + }, + "Thursday": { + "zh-Hans": "星期四", + "hi": "गुरुवार", + "es": "Jueves", + "ar": "الخميس", + "fr": "Jeudi", + "bn": "বৃহস্পতিবার", + "pt-BR": "Quinta-feira", + "ru": "Четверг", + "id": "Kamis" + }, + "Token": { + "zh-Hans": "令牌", + "hi": "टोकन", + "es": "Token", + "ar": "الرمز المميز", + "fr": "Jeton", + "bn": "টোকেন", + "pt-BR": "Token", + "ru": "Токен", + "id": "Token" + }, + "Token lacks access": { + "zh-Hans": "令牌没有访问权限", + "hi": "टोकन के पास पहुँच नहीं है", + "es": "El token no tiene acceso", + "ar": "الرمز لا يملك صلاحية الوصول", + "fr": "Le jeton n'a pas l'accès", + "bn": "টোকেনের অ্যাক্সেস নেই", + "pt-BR": "O token não tem acesso", + "ru": "У токена нет доступа", + "id": "Token tidak punya akses" + }, + "Token lacks the needed scope": { + "zh-Hans": "令牌缺少所需的权限范围", + "hi": "टोकन में ज़रूरी स्कोप नहीं है", + "es": "Al token le falta el alcance necesario", + "ar": "يفتقر الرمز إلى النطاق المطلوب", + "fr": "Il manque au jeton la portée nécessaire", + "bn": "টোকেনে প্রয়োজনীয় স্কোপ নেই", + "pt-BR": "Falta ao token o escopo necessário", + "ru": "У токена нет нужной области доступа", + "id": "Token tidak memiliki cakupan yang diperlukan" + }, + "Total stars": { + "zh-Hans": "星标总数", + "hi": "कुल स्टार", + "es": "Estrellas totales", + "ar": "إجمالي النجوم", + "fr": "Étoiles au total", + "bn": "মোট স্টার", + "pt-BR": "Total de estrelas", + "ru": "Всего звёзд", + "id": "Total bintang" + }, + "Tuesday": { + "zh-Hans": "星期二", + "hi": "मंगलवार", + "es": "Martes", + "ar": "الثلاثاء", + "fr": "Mardi", + "bn": "মঙ্গলবার", + "pt-BR": "Terça-feira", + "ru": "Вторник", + "id": "Selasa" + }, + "Turn it on once with `hermes plugins enable coucou`, then start a new Hermes session.": { + "zh-Hans": "先用 `hermes plugins enable coucou` 启用一次,然后开启新的 Hermes 会话。", + "hi": "इसे एक बार `hermes plugins enable coucou` से चालू करें, फिर Hermes का नया सत्र शुरू करें।", + "es": "Actívalo una vez con `hermes plugins enable coucou` y luego abre una nueva sesión de Hermes.", + "ar": "فعّله مرة واحدة بالأمر `hermes plugins enable coucou`، ثم ابدأ جلسة Hermes جديدة.", + "fr": "Active-le une fois avec `hermes plugins enable coucou`, puis lance une nouvelle session Hermes.", + "bn": "একবার `hermes plugins enable coucou` দিয়ে চালু করুন, তারপর Hermes-এর নতুন সেশন শুরু করুন।", + "pt-BR": "Ative uma vez com `hermes plugins enable coucou` e depois inicie uma nova sessão do Hermes.", + "ru": "Включите его один раз командой `hermes plugins enable coucou`, затем начните новую сессию Hermes.", + "id": "Aktifkan sekali dengan `hermes plugins enable coucou`, lalu mulai sesi Hermes baru." + }, + "Types “{char}”": { + "zh-Hans": "输入“{char}”", + "hi": "“{char}” टाइप करता है", + "es": "Escribe «{char}»", + "ar": "يكتب «{char}»", + "fr": "Tape « {char} »", + "bn": "“{char}” টাইপ করে", + "pt-BR": "Digita “{char}”", + "ru": "Вводит «{char}»", + "id": "Mengetik “{char}”" + }, + "Unexpected API response.": { + "zh-Hans": "API 返回了意外的响应。", + "hi": "API से अप्रत्याशित जवाब।", + "es": "Respuesta inesperada de la API.", + "ar": "استجابة غير متوقعة من الواجهة البرمجية.", + "fr": "Réponse inattendue de l'API.", + "bn": "API থেকে অপ্রত্যাশিত উত্তর।", + "pt-BR": "Resposta inesperada da API.", + "ru": "Неожиданный ответ API.", + "id": "Respons API tak terduga." + }, + "Uninstall hooks…": { + "zh-Hans": "卸载 hooks…", + "hi": "hooks अनइंस्टॉल करें…", + "es": "Desinstalar hooks…", + "ar": "إلغاء تثبيت الـ hooks…", + "fr": "Désinstaller les hooks…", + "bn": "hooks আনইনস্টল করুন…", + "pt-BR": "Desinstalar hooks…", + "ru": "Удалить хуки…", + "id": "Copot hooks…" + }, + "Uninstall relay…": { + "zh-Hans": "卸载中继…", + "hi": "रिले अनइंस्टॉल करें…", + "es": "Desinstalar relay…", + "ar": "إلغاء تثبيت المُرحِّل…", + "fr": "Désinstaller le relais…", + "bn": "রিলে আনইনস্টল করুন…", + "pt-BR": "Desinstalar relay…", + "ru": "Удалить ретранслятор…", + "id": "Copot relay…" + }, + "Uninstall…": { + "zh-Hans": "卸载…", + "hi": "अनइंस्टॉल करें…", + "es": "Desinstalar…", + "ar": "إلغاء التثبيت…", + "fr": "Désinstaller…", + "bn": "আনইনস্টল করুন…", + "pt-BR": "Desinstalar…", + "ru": "Удалить…", + "id": "Copot…" + }, + "Unknown agent \"{id}\".": { + "zh-Hans": "未知代理“{id}”。", + "hi": "अज्ञात एजेंट “{id}”।", + "es": "Agente desconocido «{id}».", + "ar": "وكيل غير معروف «{id}».", + "fr": "Agent inconnu « {id} ».", + "bn": "অজানা এজেন্ট “{id}”।", + "pt-BR": "Agente desconhecido “{id}”.", + "ru": "Неизвестный агент «{id}».", + "id": "Agen tidak dikenal “{id}”." + }, + "Untitled": { + "zh-Hans": "无标题", + "hi": "बिना शीर्षक", + "es": "Sin título", + "ar": "بلا عنوان", + "fr": "Sans titre", + "bn": "শিরোনামহীন", + "pt-BR": "Sem título", + "ru": "Без названия", + "id": "Tanpa judul" + }, + "Use a secret key (sk_live_… not pk_live_…)": { + "zh-Hans": "请使用私密密钥(sk_live_…,而不是 pk_live_…)", + "hi": "सीक्रेट कुंजी का उपयोग करें (sk_live_…, pk_live_… नहीं)", + "es": "Usa una clave secreta (sk_live_…, no pk_live_…)", + "ar": "استخدم مفتاحًا سريًا (sk_live_… وليس pk_live_…)", + "fr": "Utilise une clé secrète (sk_live_…, pas pk_live_…)", + "bn": "সিক্রেট কী ব্যবহার করুন (sk_live_…, pk_live_… নয়)", + "pt-BR": "Use uma chave secreta (sk_live_…, não pk_live_…)", + "ru": "Используйте секретный ключ (sk_live_…, а не pk_live_…)", + "id": "Gunakan kunci rahasia (sk_live_…, bukan pk_live_…)" + }, + "Use in chat": { + "zh-Hans": "在聊天中使用", + "hi": "चैट में उपयोग करें", + "es": "Usar en el chat", + "ar": "استخدامه في الدردشة", + "fr": "Utiliser dans le chat", + "bn": "চ্যাটে ব্যবহার করুন", + "pt-BR": "Usar no chat", + "ru": "Использовать в чате", + "id": "Pakai di obrolan" + }, + "Used twice": { + "zh-Hans": "重复使用", + "hi": "दो बार उपयोग", + "es": "Usado dos veces", + "ar": "مستخدم مرتين", + "fr": "Utilisé deux fois", + "bn": "দুবার ব্যবহৃত", + "pt-BR": "Usado duas vezes", + "ru": "Используется дважды", + "id": "Dipakai dua kali" + }, + "Wardrobe…": { + "zh-Hans": "衣橱…", + "hi": "अलमारी…", + "es": "Guardarropa…", + "ar": "خزانة الملابس…", + "fr": "Garde-robe…", + "bn": "পোশাক ঘর…", + "pt-BR": "Guarda-roupa…", + "ru": "Гардероб…", + "id": "Lemari…" + }, + "Warning: the key would be sent unencrypted (http://) to another machine. Use https://, or a server on this computer.": { + "zh-Hans": "警告:密钥将以未加密的方式(http://)发送到另一台机器。请使用 https://,或本机上的服务器。", + "hi": "चेतावनी: कुंजी बिना एन्क्रिप्शन (http://) के किसी दूसरी मशीन को भेजी जाएगी। https:// या इसी कंप्यूटर का सर्वर उपयोग करें।", + "es": "Atención: la clave se enviaría sin cifrar (http://) a otra máquina. Usa https:// o un servidor en este equipo.", + "ar": "تحذير: سيُرسل المفتاح دون تشفير (http://) إلى جهاز آخر. استخدم https:// أو خادمًا على هذا الحاسوب.", + "fr": "Attention : la clé serait envoyée en clair (http://) à une autre machine. Utilise https://, ou un serveur sur cet ordinateur.", + "bn": "সতর্কতা: কী এনক্রিপশন ছাড়া (http://) অন্য মেশিনে পাঠানো হবে। https:// অথবা এই কম্পিউটারের সার্ভার ব্যবহার করুন।", + "pt-BR": "Atenção: a chave seria enviada sem criptografia (http://) para outra máquina. Use https:// ou um servidor neste computador.", + "ru": "Внимание: ключ будет отправлен без шифрования (http://) на другой компьютер. Используйте https:// или сервер на этом компьютере.", + "id": "Peringatan: kunci akan dikirim tanpa enkripsi (http://) ke mesin lain. Gunakan https://, atau server di komputer ini." + }, + "Wednesday": { + "zh-Hans": "星期三", + "hi": "बुधवार", + "es": "Miércoles", + "ar": "الأربعاء", + "fr": "Mercredi", + "bn": "বুধবার", + "pt-BR": "Quarta-feira", + "ru": "Среда", + "id": "Rabu" + }, + "Where you code": { + "zh-Hans": "编程环境", + "hi": "जहाँ आप कोड करते हैं", + "es": "Dónde programas", + "ar": "مكان البرمجة", + "fr": "Où tu codes", + "bn": "যেখানে আপনি কোড করেন", + "pt-BR": "Onde você programa", + "ru": "Где вы пишете код", + "id": "Tempat Anda membuat kode" + }, + "Witch hat": { + "zh-Hans": "女巫帽", + "hi": "चुड़ैल की टोपी", + "es": "Sombrero de bruja", + "ar": "قبعة ساحرة", + "fr": "Chapeau de sorcière", + "bn": "ডাইনির টুপি", + "pt-BR": "Chapéu de bruxa", + "ru": "Шляпа ведьмы", + "id": "Topi penyihir" + }, + "Work from any app. Click a shortcut to change it, then press the new keys — Esc cancels, Backspace removes it.": { + "zh-Hans": "在任何应用中都可使用。点击某个快捷键即可修改,然后按下新的按键 — Esc 取消,Backspace 删除。", + "hi": "किसी भी ऐप से काम करते हैं। शॉर्टकट बदलने के लिए उस पर क्लिक करें, फिर नई कुंजियाँ दबाएँ — Esc रद्द करता है, Backspace उसे हटाता है।", + "es": "Funcionan desde cualquier app. Haz clic en un atajo para cambiarlo y pulsa las nuevas teclas — Esc cancela, Backspace lo quita.", + "ar": "تعمل من أي تطبيق. انقر على اختصار لتغييره، ثم اضغط المفاتيح الجديدة — Esc يلغي، وBackspace يزيله.", + "fr": "Ils marchent depuis n'importe quelle app. Clique sur un raccourci pour le changer, puis appuie sur les nouvelles touches — Esc annule, Backspace le retire.", + "bn": "যেকোনো অ্যাপ থেকে কাজ করে। বদলাতে একটি শর্টকাটে ক্লিক করুন, তারপর নতুন কী চাপুন — Esc বাতিল করে, Backspace সেটি সরায়।", + "pt-BR": "Funcionam em qualquer app. Clique num atalho para alterá-lo e pressione as novas teclas — Esc cancela, Backspace remove.", + "ru": "Работают из любого приложения. Нажмите на сочетание, чтобы изменить его, затем нажмите новые клавиши — Esc отменяет, Backspace удаляет.", + "id": "Berfungsi dari aplikasi apa pun. Klik pintasan untuk mengubahnya, lalu tekan tombol baru — Esc membatalkan, Backspace menghapusnya." + }, + "Workflow": { + "zh-Hans": "工作流", + "hi": "वर्कफ़्लो", + "es": "Flujo de trabajo", + "ar": "سير العمل", + "fr": "Workflow", + "bn": "ওয়ার্কফ্লো", + "pt-BR": "Fluxo de trabalho", + "ru": "Сценарий", + "id": "Alur kerja" + }, + "Write to {path} failed: {error}": { + "zh-Hans": "写入 {path} 失败:{error}", + "hi": "{path} में लिखना विफल रहा: {error}", + "es": "Falló la escritura en {path}: {error}", + "ar": "فشلت الكتابة في {path}: {error}", + "fr": "L'écriture dans {path} a échoué : {error}", + "bn": "{path}-এ লেখা ব্যর্থ: {error}", + "pt-BR": "A gravação em {path} falhou: {error}", + "ru": "Не удалось записать {path}: {error}", + "id": "Gagal menulis ke {path}: {error}" + }, + "Your Wayland desktop doesn't let apps listen for keys outside their own windows. Add the shortcuts in your system's keyboard settings instead, with these commands:": { + "zh-Hans": "你的 Wayland 桌面不允许应用监听自身窗口之外的按键。请改为在系统的键盘设置中添加快捷键,使用以下命令:", + "hi": "आपका Wayland डेस्कटॉप ऐप्स को उनकी अपनी विंडो के बाहर कुंजियाँ सुनने नहीं देता। इसके बजाय इन कमांड के साथ अपने सिस्टम की कीबोर्ड सेटिंग्स में शॉर्टकट जोड़ें:", + "es": "Tu escritorio Wayland no deja que las apps escuchen teclas fuera de sus propias ventanas. Añade los atajos en la configuración de teclado del sistema, con estos comandos:", + "ar": "لا يسمح سطح مكتب Wayland لديك للتطبيقات بالتقاط المفاتيح خارج نوافذها. أضف الاختصارات من إعدادات لوحة المفاتيح في نظامك بدلًا من ذلك، بهذه الأوامر:", + "fr": "Ton bureau Wayland ne laisse pas les apps écouter les touches en dehors de leurs propres fenêtres. Ajoute plutôt les raccourcis dans les réglages clavier de ton système, avec ces commandes :", + "bn": "আপনার Wayland ডেস্কটপ অ্যাপকে নিজের উইন্ডোর বাইরে কী শুনতে দেয় না। এর বদলে এই কমান্ডগুলো দিয়ে সিস্টেমের কিবোর্ড সেটিংসে শর্টকাট যোগ করুন:", + "pt-BR": "Seu desktop Wayland não deixa os apps ouvirem teclas fora das próprias janelas. Em vez disso, adicione os atalhos nas configurações de teclado do sistema, com estes comandos:", + "ru": "Ваш рабочий стол Wayland не позволяет приложениям слушать клавиши вне своих окон. Добавьте сочетания в настройках клавиатуры системы с такими командами:", + "id": "Desktop Wayland Anda tidak mengizinkan aplikasi mendengarkan tombol di luar jendelanya sendiri. Tambahkan pintasan di pengaturan keyboard sistem Anda, dengan perintah berikut:" + }, + "all green": { + "zh-Hans": "全部通过", + "hi": "सब हरा", + "es": "todo en verde", + "ar": "كلها خضراء", + "fr": "tout est vert", + "bn": "সব সবুজ", + "pt-BR": "tudo verde", + "ru": "всё зелёное", + "id": "semua hijau" + }, + "coucou-hook.exe is not in place yet. Restart Coucou; if it still fails, build it with `cargo build -p coucou-hook`.": { + "zh-Hans": "coucou-hook.exe 尚未就位。请重启 Coucou;如果仍然失败,请用 `cargo build -p coucou-hook` 构建它。", + "hi": "coucou-hook.exe अभी अपनी जगह पर नहीं है। Coucou फिर से शुरू करें; अगर फिर भी न हो, तो इसे `cargo build -p coucou-hook` से बनाएँ।", + "es": "coucou-hook.exe aún no está en su sitio. Reinicia Coucou; si sigue fallando, compílalo con `cargo build -p coucou-hook`.", + "ar": "coucou-hook.exe ليس في مكانه بعد. أعد تشغيل Coucou؛ وإن استمر الفشل فابنِه بالأمر `cargo build -p coucou-hook`.", + "fr": "coucou-hook.exe n'est pas encore en place. Redémarre Coucou ; si ça échoue encore, compile-le avec `cargo build -p coucou-hook`.", + "bn": "coucou-hook.exe এখনো জায়গামতো নেই। Coucou আবার চালু করুন; তবুও না হলে `cargo build -p coucou-hook` দিয়ে এটি বানান।", + "pt-BR": "O coucou-hook.exe ainda não está no lugar. Reinicie o Coucou; se continuar falhando, compile-o com `cargo build -p coucou-hook`.", + "ru": "coucou-hook.exe ещё не на месте. Перезапустите Coucou; если не поможет, соберите его командой `cargo build -p coucou-hook`.", + "id": "coucou-hook.exe belum ada di tempatnya. Mulai ulang Coucou; jika masih gagal, build dengan `cargo build -p coucou-hook`." + }, + "failing": { + "zh-Hans": "失败", + "hi": "विफल", + "es": "con fallos", + "ar": "فاشل", + "fr": "en échec", + "bn": "ব্যর্থ", + "pt-BR": "falhando", + "ru": "с ошибками", + "id": "gagal" + }, + "finished": { + "zh-Hans": "已完成", + "hi": "पूरा हुआ", + "es": "terminó", + "ar": "انتهى", + "fr": "a terminé", + "bn": "শেষ হয়েছে", + "pt-BR": "terminou", + "ru": "завершил", + "id": "selesai" + }, + "installed": { + "zh-Hans": "已安装", + "hi": "इंस्टॉल है", + "es": "instalado", + "ar": "مثبَّت", + "fr": "installé", + "bn": "ইনস্টল আছে", + "pt-BR": "instalado", + "ru": "установлен", + "id": "terpasang" + }, + "is asking": { + "zh-Hans": "在提问", + "hi": "पूछ रहा है", + "es": "está preguntando", + "ar": "يسأل", + "fr": "demande", + "bn": "জিজ্ঞেস করছে", + "pt-BR": "está perguntando", + "ru": "спрашивает", + "id": "bertanya" + }, + "is asking ({index} of {total})": { + "zh-Hans": "在提问(第 {index} 个,共 {total} 个)", + "hi": "पूछ रहा है ({total} में से {index})", + "es": "está preguntando ({index} de {total})", + "ar": "يسأل ({index} من {total})", + "fr": "demande ({index} sur {total})", + "bn": "জিজ্ঞেস করছে ({total}টির মধ্যে {index})", + "pt-BR": "está perguntando ({index} de {total})", + "ru": "спрашивает ({index} из {total})", + "id": "bertanya ({index} dari {total})" + }, + "is asking a question": { + "zh-Hans": "在提问", + "hi": "एक प्रश्न पूछ रहा है", + "es": "está haciendo una pregunta", + "ar": "يطرح سؤالًا", + "fr": "pose une question", + "bn": "একটি প্রশ্ন করছে", + "pt-BR": "está fazendo uma pergunta", + "ru": "задаёт вопрос", + "id": "mengajukan pertanyaan" + }, + "lines": { + "zh-Hans": "行", + "hi": "पंक्तियाँ", + "es": "líneas", + "ar": "أسطر", + "fr": "lignes", + "bn": "লাইন", + "pt-BR": "linhas", + "ru": "строк", + "id": "baris" + }, + "no repos": { + "zh-Hans": "没有仓库", + "hi": "कोई रिपॉज़िटरी नहीं", + "es": "sin repositorios", + "ar": "لا مستودعات", + "fr": "aucun dépôt", + "bn": "কোনো রিপোজিটরি নেই", + "pt-BR": "nenhum repositório", + "ru": "нет репозиториев", + "id": "tidak ada repositori" + }, + "not installed": { + "zh-Hans": "未安装", + "hi": "इंस्टॉल नहीं है", + "es": "no instalado", + "ar": "غير مثبَّت", + "fr": "non installé", + "bn": "ইনস্টল নেই", + "pt-BR": "não instalado", + "ru": "не установлен", + "id": "belum terpasang" + }, + "passing": { + "zh-Hans": "通过", + "hi": "सफल", + "es": "correcto", + "ar": "ناجح", + "fr": "réussi", + "bn": "সফল", + "pt-BR": "passando", + "ru": "успешно", + "id": "lulus" + }, + "running": { + "zh-Hans": "运行中", + "hi": "चल रहा है", + "es": "en curso", + "ar": "قيد التشغيل", + "fr": "en cours", + "bn": "চলছে", + "pt-BR": "em execução", + "ru": "выполняется", + "id": "berjalan" + }, + "seconds after you leave the island": { + "zh-Hans": "鼠标离开岛后的秒数", + "hi": "आइलैंड छोड़ने के बाद सेकंड", + "es": "segundos después de salir de la isla", + "ar": "ثانية بعد مغادرة الشريط", + "fr": "secondes après avoir quitté l'île", + "bn": "আইল্যান্ড ছাড়ার পর সেকেন্ড", + "pt-BR": "segundos depois de sair da ilha", + "ru": "секунд после ухода с острова", + "id": "detik setelah Anda meninggalkan pulau" + }, + "settings.json: {what} has an unexpected type — Coucou has not touched it.": { + "zh-Hans": "settings.json:{what} 的类型不符合预期 — Coucou 没有修改它。", + "hi": "settings.json: {what} का प्रकार अपेक्षित नहीं है — Coucou ने इसे नहीं छुआ।", + "es": "settings.json: {what} tiene un tipo inesperado — Coucou no lo ha tocado.", + "ar": "settings.json: {what} من نوع غير متوقع — لم يمسّه Coucou.", + "fr": "settings.json : {what} a un type inattendu — Coucou n'y a pas touché.", + "bn": "settings.json: {what}-এর ধরন অপ্রত্যাশিত — Coucou এতে হাত দেয়নি।", + "pt-BR": "settings.json: {what} tem um tipo inesperado — o Coucou não mexeu nele.", + "ru": "settings.json: у {what} неожиданный тип — Coucou его не трогал.", + "id": "settings.json: {what} bertipe tak terduga — Coucou tidak menyentuhnya." + }, + "unknown": { + "zh-Hans": "未知", + "hi": "अज्ञात", + "es": "desconocido", + "ar": "غير معروف", + "fr": "inconnu", + "bn": "অজানা", + "pt-BR": "desconhecido", + "ru": "неизвестно", + "id": "tidak diketahui" + }, + "{allowed} allowed, {denied} denied": { + "zh-Hans": "允许 {allowed} 次,拒绝 {denied} 次", + "hi": "{allowed} अनुमत, {denied} अस्वीकृत", + "es": "{allowed} permitidos, {denied} denegados", + "ar": "{allowed} مسموح، {denied} مرفوض", + "fr": "{allowed} autorisées, {denied} refusées", + "bn": "{allowed}টি অনুমোদিত, {denied}টি প্রত্যাখ্যাত", + "pt-BR": "{allowed} permitidas, {denied} negadas", + "ru": "разрешено: {allowed}, отклонено: {denied}", + "id": "{allowed} diizinkan, {denied} ditolak" + }, + "{count} contributions": { + "zh-Hans": { + "other": "{count} 次贡献" + }, + "hi": { + "one": "{count} योगदान", + "other": "{count} योगदान" + }, + "es": { + "one": "{count} contribución", + "other": "{count} contribuciones" + }, + "ar": { + "zero": "{count} مساهمة", + "one": "مساهمة واحدة", + "two": "مساهمتان", + "few": "{count} مساهمات", + "many": "{count} مساهمة", + "other": "{count} مساهمة" + }, + "fr": { + "one": "{count} contribution", + "other": "{count} contributions" + }, + "bn": { + "one": "{count}টি অবদান", + "other": "{count}টি অবদান" + }, + "pt-BR": { + "one": "{count} contribuição", + "other": "{count} contribuições" + }, + "ru": { + "one": "{count} вклад", + "few": "{count} вклада", + "many": "{count} вкладов", + "other": "{count} вклада" + }, + "id": { + "other": "{count} kontribusi" + } + }, + "{count} models": { + "zh-Hans": { + "other": "{count} 个模型" + }, + "hi": { + "one": "{count} मॉडल", + "other": "{count} मॉडल" + }, + "es": { + "one": "{count} modelo", + "other": "{count} modelos" + }, + "ar": { + "zero": "{count} نموذج", + "one": "نموذج واحد", + "two": "نموذجان", + "few": "{count} نماذج", + "many": "{count} نموذجًا", + "other": "{count} نموذج" + }, + "fr": { + "one": "{count} modèle", + "other": "{count} modèles" + }, + "bn": { + "one": "{count}টি মডেল", + "other": "{count}টি মডেল" + }, + "pt-BR": { + "one": "{count} modelo", + "other": "{count} modelos" + }, + "ru": { + "one": "{count} модель", + "few": "{count} модели", + "many": "{count} моделей", + "other": "{count} модели" + }, + "id": { + "other": "{count} model" + } + }, + "{count} repos": { + "zh-Hans": { + "other": "{count} 个仓库" + }, + "hi": { + "one": "{count} रिपॉज़िटरी", + "other": "{count} रिपॉज़िटरी" + }, + "es": { + "one": "{count} repositorio", + "other": "{count} repositorios" + }, + "ar": { + "zero": "{count} مستودع", + "one": "مستودع واحد", + "two": "مستودعان", + "few": "{count} مستودعات", + "many": "{count} مستودعًا", + "other": "{count} مستودع" + }, + "fr": { + "one": "{count} dépôt", + "other": "{count} dépôts" + }, + "bn": { + "one": "{count}টি রিপোজিটরি", + "other": "{count}টি রিপোজিটরি" + }, + "pt-BR": { + "one": "{count} repositório", + "other": "{count} repositórios" + }, + "ru": { + "one": "{count} репозиторий", + "few": "{count} репозитория", + "many": "{count} репозиториев", + "other": "{count} репозитория" + }, + "id": { + "other": "{count} repositori" + } + }, + "{file} isn't UTF-8 text — Coucou won't touch it.": { + "zh-Hans": "{file} 不是 UTF-8 文本 — Coucou 不会修改它。", + "hi": "{file} UTF-8 टेक्स्ट नहीं है — Coucou इसे नहीं छुएगा।", + "es": "{file} no es texto UTF-8 — Coucou no lo tocará.", + "ar": "{file} ليس نصًا بترميز UTF-8 — لن يمسّه Coucou.", + "fr": "{file} n'est pas du texte UTF-8 — Coucou n'y touchera pas.", + "bn": "{file} UTF-8 টেক্সট নয় — Coucou এতে হাত দেবে না।", + "pt-BR": "{file} não é texto UTF-8 — o Coucou não vai mexer nele.", + "ru": "{file} — не текст в UTF-8, Coucou его не тронет.", + "id": "{file} bukan teks UTF-8 — Coucou tidak akan menyentuhnya." + }, + "{file} isn't a JSON object — Coucou won't touch it.": { + "zh-Hans": "{file} 不是 JSON 对象 — Coucou 不会修改它。", + "hi": "{file} JSON ऑब्जेक्ट नहीं है — Coucou इसे नहीं छुएगा।", + "es": "{file} no es un objeto JSON — Coucou no lo tocará.", + "ar": "{file} ليس كائن JSON — لن يمسّه Coucou.", + "fr": "{file} n'est pas un objet JSON — Coucou n'y touchera pas.", + "bn": "{file} JSON অবজেক্ট নয় — Coucou এতে হাত দেবে না।", + "pt-BR": "{file} não é um objeto JSON — o Coucou não vai mexer nele.", + "ru": "{file} — не объект JSON, Coucou его не тронет.", + "id": "{file} bukan objek JSON — Coucou tidak akan menyentuhnya." + }, + "{file} isn't valid JSON ({error}). Fix or move it, then try again — Coucou won't overwrite it.": { + "zh-Hans": "{file} 不是有效的 JSON({error})。请修复或移走它后重试 — Coucou 不会覆盖它。", + "hi": "{file} मान्य JSON नहीं है ({error})। इसे ठीक करें या हटाएँ, फिर दोबारा कोशिश करें — Coucou इसे ओवरराइट नहीं करेगा।", + "es": "{file} no es un JSON válido ({error}). Corrígelo o muévelo y vuelve a intentarlo — Coucou no lo sobrescribirá.", + "ar": "{file} ليس JSON صالحًا ({error}). أصلحه أو انقله ثم حاول مجددًا — لن يكتب Coucou فوقه.", + "fr": "{file} n'est pas un JSON valide ({error}). Corrige-le ou déplace-le, puis réessaie — Coucou ne l'écrasera pas.", + "bn": "{file} বৈধ JSON নয় ({error})। এটি ঠিক করুন বা সরান, তারপর আবার চেষ্টা করুন — Coucou এটি ওভাররাইট করবে না।", + "pt-BR": "{file} não é um JSON válido ({error}). Corrija ou mova o arquivo e tente de novo — o Coucou não vai sobrescrevê-lo.", + "ru": "{file} — некорректный JSON ({error}). Исправьте или переместите его и попробуйте снова — Coucou не будет его перезаписывать.", + "id": "{file} bukan JSON yang valid ({error}). Perbaiki atau pindahkan, lalu coba lagi — Coucou tidak akan menimpanya." + }, + "{h}h {m}m": { + "zh-Hans": "{h} 小时 {m} 分钟", + "hi": "{h} घं. {m} मि.", + "es": "{h} h {m} min", + "ar": "{h} س {m} د", + "fr": "{h} h {m} min", + "bn": "{h} ঘ. {m} মি.", + "pt-BR": "{h} h {m} min", + "ru": "{h} ч {m} мин", + "id": "{h} j {m} mnt" + }, + "{keys} types “{char}” on your keyboard, so it can't be a shortcut. Pick another key.": { + "zh-Hans": "{keys} 在你的键盘上会输入“{char}”,所以不能用作快捷键。请换一个键。", + "hi": "{keys} आपके कीबोर्ड पर “{char}” टाइप करता है, इसलिए यह शॉर्टकट नहीं हो सकता। कोई दूसरी कुंजी चुनें।", + "es": "{keys} escribe «{char}» en tu teclado, así que no puede ser un atajo. Elige otra tecla.", + "ar": "{keys} يكتب «{char}» على لوحة مفاتيحك، لذا لا يمكن أن يكون اختصارًا. اختر مفتاحًا آخر.", + "fr": "{keys} tape « {char} » sur ton clavier, ce ne peut donc pas être un raccourci. Choisis une autre touche.", + "bn": "{keys} আপনার কিবোর্ডে “{char}” টাইপ করে, তাই এটি শর্টকাট হতে পারে না। অন্য একটি কী বেছে নিন।", + "pt-BR": "{keys} digita “{char}” no seu teclado, então não pode ser um atalho. Escolha outra tecla.", + "ru": "{keys} вводит «{char}» на вашей клавиатуре, поэтому не может быть сочетанием. Выберите другую клавишу.", + "id": "{keys} mengetik “{char}” di keyboard Anda, jadi tidak bisa menjadi pintasan. Pilih tombol lain." + }, + "{model} isn't installed. Pick another model above the chat box.": { + "zh-Hans": "{model} 未安装。请在聊天框上方选择其他模型。", + "hi": "{model} इंस्टॉल नहीं है। चैट बॉक्स के ऊपर कोई दूसरा मॉडल चुनें।", + "es": "{model} no está instalado. Elige otro modelo encima del cuadro de chat.", + "ar": "{model} غير مثبَّت. اختر نموذجًا آخر فوق مربع الدردشة.", + "fr": "{model} n'est pas installé. Choisis un autre modèle au-dessus de la zone de chat.", + "bn": "{model} ইনস্টল করা নেই। চ্যাট বক্সের উপরে অন্য মডেল বেছে নিন।", + "pt-BR": "{model} não está instalado. Escolha outro modelo acima da caixa de chat.", + "ru": "{model} не установлена. Выберите другую модель над полем чата.", + "id": "{model} belum terpasang. Pilih model lain di atas kotak obrolan." + }, + "{name} API key missing. Add it in Settings.": { + "zh-Hans": "缺少 {name} 的 API 密钥。请在设置中添加。", + "hi": "{name} API कुंजी नहीं है। इसे सेटिंग्स में जोड़ें।", + "es": "Falta la clave de API de {name}. Añádela en Configuración.", + "ar": "مفتاح API الخاص بـ {name} مفقود. أضفه من الإعدادات.", + "fr": "Clé d'API {name} manquante. Ajoute-la dans les Réglages.", + "bn": "{name} API কী নেই। সেটিংসে যোগ করুন।", + "pt-BR": "Falta a chave de API do {name}. Adicione nas Configurações.", + "ru": "Нет API-ключа {name}. Добавьте его в настройках.", + "id": "Kunci API {name} tidak ada. Tambahkan di Pengaturan." + }, + "{name} rate limit reached (429): {detail}": { + "zh-Hans": "{name} 已达到速率限制(429):{detail}", + "hi": "{name} की दर सीमा पूरी हो गई (429): {detail}", + "es": "{name}: se alcanzó el límite de solicitudes (429): {detail}", + "ar": "بلغ {name} حدّ المعدّل (429): {detail}", + "fr": "{name} : limite de débit atteinte (429) : {detail}", + "bn": "{name}-এর রেট লিমিটে পৌঁছেছে (429): {detail}", + "pt-BR": "{name}: limite de requisições atingido (429): {detail}", + "ru": "{name}: достигнут лимит запросов (429): {detail}", + "id": "Batas laju {name} tercapai (429): {detail}" + }, + "{name} rejected the API key ({status}). Check it in Settings.": { + "zh-Hans": "{name} 拒绝了 API 密钥({status})。请在设置中检查。", + "hi": "{name} ने API कुंजी अस्वीकार कर दी ({status})। इसे सेटिंग्स में जाँचें।", + "es": "{name} rechazó la clave de API ({status}). Revísala en Configuración.", + "ar": "رفض {name} مفتاح API ({status}). تحقّق منه في الإعدادات.", + "fr": "{name} a refusé la clé d'API ({status}). Vérifie-la dans les Réglages.", + "bn": "{name} API কী প্রত্যাখ্যান করেছে ({status})। সেটিংসে যাচাই করুন।", + "pt-BR": "{name} recusou a chave de API ({status}). Verifique-a nas Configurações.", + "ru": "{name} отклонил API-ключ ({status}). Проверьте его в настройках.", + "id": "{name} menolak kunci API ({status}). Periksa di Pengaturan." + }, + "{name} wasn't written by Coucou — Coucou has not touched it.": { + "zh-Hans": "{name} 不是由 Coucou 写入的 — Coucou 没有修改它。", + "hi": "{name} Coucou ने नहीं लिखा था — Coucou ने इसे नहीं छुआ।", + "es": "{name} no lo escribió Coucou — Coucou no lo ha tocado.", + "ar": "لم يكتب Coucou الملف {name} — لم يمسّه Coucou.", + "fr": "{name} n'a pas été écrit par Coucou — Coucou n'y a pas touché.", + "bn": "{name} Coucou লেখেনি — Coucou এতে হাত দেয়নি।", + "pt-BR": "{name} não foi escrito pelo Coucou — o Coucou não mexeu nele.", + "ru": "{name} записан не Coucou — Coucou его не трогал.", + "id": "{name} tidak ditulis oleh Coucou — Coucou tidak menyentuhnya." + }, + "{name} — {width}×{height} at {x},{y}": { + "zh-Hans": "{name} — {width}×{height},位于 {x},{y}", + "hi": "{name} — {width}×{height}, स्थिति {x},{y}", + "es": "{name} — {width}×{height} en {x},{y}", + "ar": "{name} — {width}×{height} عند {x},{y}", + "fr": "{name} — {width}×{height} à {x},{y}", + "bn": "{name} — {width}×{height}, অবস্থান {x},{y}", + "pt-BR": "{name} — {width}×{height} em {x},{y}", + "ru": "{name} — {width}×{height} в точке {x},{y}", + "id": "{name} — {width}×{height} di {x},{y}" + }, + "{name}: model not found (404). Pick another one above the chat box. {detail}": { + "zh-Hans": "{name}:未找到模型(404)。请在聊天框上方选择其他模型。{detail}", + "hi": "{name}: मॉडल नहीं मिला (404)। चैट बॉक्स के ऊपर कोई दूसरा चुनें। {detail}", + "es": "{name}: modelo no encontrado (404). Elige otro encima del cuadro de chat. {detail}", + "ar": "{name}: لم يُعثر على النموذج (404). اختر غيره فوق مربع الدردشة. {detail}", + "fr": "{name} : modèle introuvable (404). Choisis-en un autre au-dessus de la zone de chat. {detail}", + "bn": "{name}: মডেল পাওয়া যায়নি (404)। চ্যাট বক্সের উপরে অন্য একটি বেছে নিন। {detail}", + "pt-BR": "{name}: modelo não encontrado (404). Escolha outro acima da caixa de chat. {detail}", + "ru": "{name}: модель не найдена (404). Выберите другую над полем чата. {detail}", + "id": "{name}: model tidak ditemukan (404). Pilih yang lain di atas kotak obrolan. {detail}" + }, + "{name}: not enough credits (402). {detail}": { + "zh-Hans": "{name}:额度不足(402)。{detail}", + "hi": "{name}: पर्याप्त क्रेडिट नहीं (402)। {detail}", + "es": "{name}: no hay créditos suficientes (402). {detail}", + "ar": "{name}: الرصيد غير كافٍ (402). {detail}", + "fr": "{name} : crédits insuffisants (402). {detail}", + "bn": "{name}: যথেষ্ট ক্রেডিট নেই (402)। {detail}", + "pt-BR": "{name}: créditos insuficientes (402). {detail}", + "ru": "{name}: недостаточно кредитов (402). {detail}", + "id": "{name}: kredit tidak cukup (402). {detail}" + }, + "{n} available": { + "zh-Hans": "可用 {n} 次", + "hi": "{n} उपलब्ध", + "es": "{n} disponibles", + "ar": "{n} متاحة", + "fr": "{n} disponibles", + "bn": "{n}টি উপলব্ধ", + "pt-BR": "{n} disponíveis", + "ru": "доступно: {n}", + "id": "{n} tersedia" + }, + "{n}d": { + "zh-Hans": "{n} 天", + "hi": "{n} दि.", + "es": "{n} d", + "ar": "{n} ي", + "fr": "{n} j", + "bn": "{n} দি.", + "pt-BR": "{n} d", + "ru": "{n} д", + "id": "{n} h" + }, + "{n}h": { + "zh-Hans": "{n} 小时", + "hi": "{n} घं.", + "es": "{n} h", + "ar": "{n} س", + "fr": "{n} h", + "bn": "{n} ঘ.", + "pt-BR": "{n} h", + "ru": "{n} ч", + "id": "{n} j" + }, + "{n}m": { + "zh-Hans": "{n} 分钟", + "hi": "{n} मि.", + "es": "{n} min", + "ar": "{n} د", + "fr": "{n} min", + "bn": "{n} মি.", + "pt-BR": "{n} min", + "ru": "{n} мин", + "id": "{n} mnt" + }, + "{path} changed since the preview. Nothing was written — review the new diff.": { + "zh-Hans": "{path} 在预览后已被修改。未写入任何内容 — 请查看新的差异。", + "hi": "प्रीव्यू के बाद {path} बदल गया। कुछ नहीं लिखा गया — नया डिफ़ देखें।", + "es": "{path} cambió desde la vista previa. No se escribió nada — revisa el nuevo diff.", + "ar": "تغيّر {path} منذ المعاينة. لم يُكتب أي شيء — راجع الفروقات الجديدة.", + "fr": "{path} a changé depuis l'aperçu. Rien n'a été écrit — relis le nouveau diff.", + "bn": "প্রিভিউয়ের পর {path} বদলে গেছে। কিছু লেখা হয়নি — নতুন ডিফ দেখুন।", + "pt-BR": "{path} mudou desde a prévia. Nada foi gravado — revise o novo diff.", + "ru": "{path} изменился после предпросмотра. Ничего не записано — проверьте новый diff.", + "id": "{path} berubah sejak pratinjau. Tidak ada yang ditulis — tinjau diff yang baru." + }, + "{time} ago": { + "zh-Hans": "{time}前", + "hi": "{time} पहले", + "es": "hace {time}", + "ar": "منذ {time}", + "fr": "il y a {time}", + "bn": "{time} আগে", + "pt-BR": "há {time}", + "ru": "{time} назад", + "id": "{time} yang lalu" + }, + "{total} past year": { + "zh-Hans": "过去一年 {total}", + "hi": "पिछले वर्ष {total}", + "es": "{total} el último año", + "ar": "{total} في العام الماضي", + "fr": "{total} sur un an", + "bn": "গত বছরে {total}", + "pt-BR": "{total} no último ano", + "ru": "{total} за год", + "id": "{total} setahun terakhir" + }, + "{used}/{max} slots in use — the main tool doesn't take one.": { + "zh-Hans": "已用 {used}/{max} 个位置 — 主要工具不占位置。", + "hi": "{used}/{max} स्लॉट उपयोग में — मुख्य टूल कोई स्लॉट नहीं लेता।", + "es": "{used}/{max} espacios en uso — la herramienta principal no ocupa ninguno.", + "ar": "{used}/{max} من الخانات مستخدمة — الأداة الرئيسية لا تشغل خانة.", + "fr": "{used}/{max} emplacements utilisés — l'outil principal n'en prend pas.", + "bn": "{used}/{max} স্লট ব্যবহৃত — প্রধান টুল কোনো স্লট নেয় না।", + "pt-BR": "{used}/{max} espaços em uso — a ferramenta principal não ocupa nenhum.", + "ru": "Занято мест: {used}/{max} — основной инструмент место не занимает.", + "id": "{used}/{max} slot terpakai — alat utama tidak memakai slot." + }, + "{what} has an unexpected type — Coucou has not touched it.": { + "zh-Hans": "{what} 的类型不符合预期 — Coucou 没有修改它。", + "hi": "{what} का प्रकार अपेक्षित नहीं है — Coucou ने इसे नहीं छुआ।", + "es": "{what} tiene un tipo inesperado — Coucou no lo ha tocado.", + "ar": "{what} من نوع غير متوقع — لم يمسّه Coucou.", + "fr": "{what} a un type inattendu — Coucou n'y a pas touché.", + "bn": "{what}-এর ধরন অপ্রত্যাশিত — Coucou এতে হাত দেয়নি।", + "pt-BR": "{what} tem um tipo inesperado — o Coucou não mexeu nele.", + "ru": "У {what} неожиданный тип — Coucou его не трогал.", + "id": "{what} bertipe tak terduga — Coucou tidak menyentuhnya." + }, + "• subagent done": { + "zh-Hans": "• 子代理完成", + "hi": "• सब-एजेंट पूरा", + "es": "• subagente terminado", + "ar": "• انتهى الوكيل الفرعي", + "fr": "• sous-agent terminé", + "bn": "• সাব-এজেন্ট শেষ", + "pt-BR": "• subagente concluído", + "ru": "• субагент завершил", + "id": "• subagen selesai" + }, + "⚠ failed": { + "zh-Hans": "⚠ 失败", + "hi": "⚠ विफल", + "es": "⚠ falló", + "ar": "⚠ فشل", + "fr": "⚠ échec", + "bn": "⚠ ব্যর্থ", + "pt-BR": "⚠ falhou", + "ru": "⚠ ошибка", + "id": "⚠ gagal" + }, + "A key is only sent over https, or to a server on this computer.": { + "zh-Hans": "密钥只会通过 https 发送,或发送到本机上的服务器。", + "hi": "कुंजी केवल https पर, या इसी कंप्यूटर के सर्वर को भेजी जाती है।", + "es": "La clave solo se envía por https o a un servidor de este ordenador.", + "ar": "لا يُرسَل المفتاح إلا عبر https، أو إلى خادم على هذا الحاسوب.", + "fr": "La clé n'est envoyée qu'en https, ou à un serveur sur cet ordinateur.", + "bn": "কী কেবল https-এর মাধ্যমে, অথবা এই কম্পিউটারের কোনো সার্ভারে পাঠানো হয়।", + "pt-BR": "A chave só é enviada por https ou para um servidor neste computador.", + "ru": "Ключ отправляется только по https или на сервер на этом компьютере.", + "id": "Kunci hanya dikirim lewat https, atau ke server di komputer ini." + }, + "Only a file dropped on the island can be sent with a question.": { + "zh-Hans": "只有拖放到岛上的文件才能随问题一起发送。", + "hi": "सवाल के साथ केवल वही फ़ाइल भेजी जा सकती है जो island पर छोड़ी गई हो।", + "es": "Solo se puede enviar con una pregunta un archivo soltado en la isla.", + "ar": "لا يمكن إرسال ملف مع سؤال إلا إذا أُفلِت على الجزيرة.", + "fr": "Seul un fichier déposé sur l'île peut être envoyé avec une question.", + "bn": "প্রশ্নের সঙ্গে কেবল আইল্যান্ডে ছাড়া ফাইলই পাঠানো যায়।", + "pt-BR": "Só um arquivo solto na ilha pode ser enviado com uma pergunta.", + "ru": "С вопросом можно отправить только файл, перетащенный на остров.", + "id": "Hanya file yang dijatuhkan ke pulau yang bisa dikirim bersama pertanyaan." + } + } +} diff --git a/windows/src/i18n/i18n.ts b/windows/src/i18n/i18n.ts new file mode 100644 index 000000000..d58fe8b03 --- /dev/null +++ b/windows/src/i18n/i18n.ts @@ -0,0 +1,268 @@ +// Interface language — the same ten languages as the Mac (0.2.0). +// +// Strings are looked up by their English text, like the Mac's catalog: +// `t("Allow")`, `t("Uploading {name}", { name })`. Two tables, both keyed by +// the English text: +// - strings.json, generated from the Mac's Localizable.xcstrings by +// scripts/gen-strings.mjs (never edited by hand); +// - extra.json, the strings only Windows and Linux have, written by hand. +// A string missing from both, or from one language, shows in English. +// +// The Rust side (src-tauri/src/i18n.rs) embeds the same two files and looks up +// the same way, for the tray menu, notifications and the errors it returns. +// +// Changing the language never reloads a window: `setLanguage` tells whoever +// listens (`onLanguageChange`), views/dom.ts relabels the text it was given +// as `tl(…)`, and the views redraw the rest on their next sync. + +import MAC from "./strings.json"; +import EXTRA from "./extra.json"; + +export const LANGUAGE_CODES = ["en", "zh-Hans", "hi", "es", "ar", "fr", "bn", "pt-BR", "ru", "id"] as const; +export type Language = (typeof LANGUAGE_CODES)[number]; + +/** The picker's entries, each in its own language, in the Mac's order. */ +export const LANGUAGES: readonly { code: Language; name: string }[] = [ + { code: "en", name: "English" }, + { code: "zh-Hans", name: "简体中文" }, + { code: "hi", name: "हिन्दी" }, + { code: "es", name: "Español" }, + { code: "ar", name: "العربية" }, + { code: "fr", name: "Français" }, + { code: "bn", name: "বাংলা" }, + { code: "pt-BR", name: "Português (Brasil)" }, + { code: "ru", name: "Русский" }, + { code: "id", name: "Bahasa Indonesia" }, +]; + +export type Vars = Record; +type Plural = Partial>; +type Entry = Partial>; +type Table = Record; + +/** extra.json wins over the Mac's table where both have a string. */ +const TABLE: Table = { + ...((MAC as { strings: Table }).strings), + ...((EXTRA as { strings: Table }).strings), +}; + +let current: Language = "en"; +const listeners = new Set<(lang: Language) => void>(); + +export function isLanguage(code: unknown): code is Language { + return typeof code === "string" && (LANGUAGE_CODES as readonly string[]).includes(code); +} + +/** + * The language to show: the one picked in Settings when it is one of ours, + * else the first of the system's languages we have ("pt" and "pt-PT" read as + * Brazilian Portuguese, "zh-CN", "zh-SG" and "zh" as Simplified Chinese), + * else English. + */ +export function resolveLanguage(picked: string | undefined | null, system: readonly string[] = []): Language { + if (isLanguage(picked)) return picked; + for (const raw of system) { + const tag = String(raw ?? "").replace(/_/g, "-").toLowerCase(); + if (!tag) continue; + const base = tag.split("-")[0]; + if (base === "zh") { + // Traditional Chinese is not one of ours: English rather than the wrong script. + if (/^zh-(hant|tw|hk|mo)\b/.test(tag)) continue; + return "zh-Hans"; + } + if (base === "pt") return "pt-BR"; + const exact = LANGUAGE_CODES.find((c) => c.toLowerCase() === base); + if (exact) return exact; + } + return "en"; +} + +/** The system's languages, as the webview knows them. */ +export function systemLanguages(): string[] { + if (typeof navigator === "undefined") return []; + const list = navigator.languages?.length ? [...navigator.languages] : []; + if (navigator.language && !list.includes(navigator.language)) list.push(navigator.language); + return list; +} + +export function language(): Language { + return current; +} + +/** Arabic reads right to left. */ +export function isRtl(lang: Language = current): boolean { + return lang === "ar"; +} + +/** Switches the language. Returns true when it changed (listeners were told). */ +export function setLanguage(lang: Language): boolean { + if (!isLanguage(lang) || lang === current) return false; + current = lang; + applyDocumentLanguage(); + for (const listener of [...listeners]) listener(lang); + return true; +} + +/** Called after every language change; returns the unsubscribe. */ +export function onLanguageChange(listener: (lang: Language) => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** + * follows the language, so the webview picks fitting fonts, and + * says which way it reads: the island's CSS turns its text + * containers right to left with it, leaving Mochi, the pills and the header + * where they are. (The settings window sets `dir` on the whole page.) + */ +export function applyDocumentLanguage() { + if (typeof document === "undefined" || !document.documentElement) return; + const root = document.documentElement; + root.lang = current; + if (root.dataset) root.dataset.dir = isRtl() ? "rtl" : "ltr"; +} + +function interpolate(text: string, vars?: Vars): string { + if (!vars) return text; + return text.replace(/\{(\w+)\}/g, (m, name: string) => (name in vars ? String(vars[name]) : m)); +} + +function pluralRule(lang: Language, count: number): Intl.LDMLPluralRule { + try { + return new Intl.PluralRules(lang).select(count); + } catch { + return count === 1 ? "one" : "other"; + } +} + +function pickPlural(forms: Plural, lang: Language, count: number): string | undefined { + return forms[pluralRule(lang, count)] ?? forms.other ?? Object.values(forms)[0]; +} + +/** The raw translation of `key` in `lang`, or undefined. */ +export function lookup(key: string, lang: Language = current): string | Plural | undefined { + if (lang === "en") return undefined; + return TABLE[key]?.[lang]; +} + +/** + * `key` (English text) in the current language, its `{placeholders}` filled + * from `vars`. Falls back to the English text. + */ +export function t(key: string, vars?: Vars): string { + const found = lookup(key); + let text: string; + if (typeof found === "string") text = found; + else if (found) text = pickPlural(found, current, Number(vars?.count ?? 0)) ?? key; + else text = key; + return interpolate(text, vars); +} + +/** + * A count with its words: `tn("{count} file", "{count} files", n)`. Languages + * with more forms (Russian, Arabic…) pick theirs from the entry keyed by the + * English plural; `{count}` is filled in. + */ +export function tn(one: string, other: string, count: number, vars?: Vars): string { + const all = { ...vars, count }; + const found = lookup(other); + if (found && typeof found !== "string") { + const text = pickPlural(found, current, count); + if (text) return interpolate(text, all); + } + if (typeof found === "string") return interpolate(found, all); + // English, or a language without this string: the English forms. + const englishForms = TABLE[other]?.en; + if (englishForms && typeof englishForms !== "string") { + const text = pickPlural(englishForms, "en", count); + if (text) return interpolate(text, all); + } + return interpolate(count === 1 ? one : other, all); +} + +/** + * Marks a literal as a key without translating it, for tables built at load + * time (pill categories, outfit names…): the code translates it with `t()` + * where it is shown. The i18n test finds the keys through it. + */ +export function N_(key: T): T { + return key; +} + +/** + * A table of labels whose every read is translated: `labels({ a: N_("Bow") }).a` + * is "Bow" in English, "Nœud" in French. For tables other modules index into. + */ +export function labels(keys: Record): Record { + const out = {} as Record; + for (const k of Object.keys(keys) as K[]) { + Object.defineProperty(out, k, { enumerable: true, get: () => t(keys[k]) }); + } + return out; +} + +/** + * A string that follows the language by itself once it is on screen. Given to + * `h()` (views/dom.ts) as a text, a title, a placeholder or a child, it is + * redrawn in place when the language changes. + */ +export class Msg { + readonly key: string; + readonly vars?: Vars; + constructor(key: string, vars?: Vars) { + this.key = key; + this.vars = vars; + } + toString(): string { + return t(this.key, this.vars); + } +} + +/** `t()` for text built once: see `Msg`. */ +export function tl(key: string, vars?: Vars): Msg { + return new Msg(key, vars); +} + +// ── Dates ───────────────────────────────────────────────────────────────────── +// English keeps the Mac's fixed abbreviations; the other languages ask Intl. + +const EN_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const EN_WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function intl(options: Intl.DateTimeFormatOptions, date: Date): string | null { + try { + return new Intl.DateTimeFormat(current, { ...options, timeZone: "UTC" }).format(date); + } catch { + return null; + } +} + +/** "Oct" for 9 (months from 0). */ +export function monthShort(month: number): string { + if (current === "en") return EN_MONTHS[month] ?? ""; + return intl({ month: "short" }, new Date(Date.UTC(2024, month, 15))) ?? EN_MONTHS[month] ?? ""; +} + +/** "Mon" for 1 (Sunday is 0). */ +export function weekdayShort(day: number): string { + if (current === "en") return EN_WEEKDAYS[day] ?? ""; + // 7 January 2024 was a Sunday. + return intl({ weekday: "short" }, new Date(Date.UTC(2024, 0, 7 + day))) ?? EN_WEEKDAYS[day] ?? ""; +} + +/** "Oct 9" (months from 0), in the language's own order. */ +export function dayMonth(month: number, day: number): string { + if (current === "en") return `${EN_MONTHS[month] ?? ""} ${day}`; + return intl({ month: "short", day: "numeric" }, new Date(Date.UTC(2024, month, day))) ?? `${EN_MONTHS[month] ?? ""} ${day}`; +} + +/** Every key of both tables, for the tests. */ +export function allKeys(): string[] { + return Object.keys(TABLE); +} + +/** True when `key` has a translation in every language but English. */ +export function isComplete(key: string): boolean { + const entry = TABLE[key]; + return !!entry && LANGUAGE_CODES.every((l) => l === "en" || entry[l] !== undefined); +} diff --git a/windows/src/i18n/strings.json b/windows/src/i18n/strings.json new file mode 100644 index 000000000..f32fb6aa6 --- /dev/null +++ b/windows/src/i18n/strings.json @@ -0,0 +1,3381 @@ +{ + "_generated": "By scripts/gen-strings.mjs from NotchBuddy/Resources/Localizable.xcstrings. Do not edit: run `node scripts/gen-strings.mjs`.", + "languages": [ + "en", + "zh-Hans", + "hi", + "es", + "ar", + "fr", + "bn", + "pt-BR", + "ru", + "id" + ], + "strings": { + " is ready.": { + "zh-Hans": " 已就绪。", + "hi": " तैयार है।", + "es": " está listo.", + "ar": " جاهز.", + "fr": " est prêt.", + "bn": " প্রস্তুত।", + "pt-BR": " está pronto.", + "ru": " готов.", + "id": " siap." + }, + "5 hours": { + "zh-Hans": "5 小时", + "hi": "5 घंटे", + "es": "5 horas", + "ar": "٥ ساعات", + "fr": "5 heures", + "bn": "৫ ঘণ্টা", + "pt-BR": "5 horas", + "ru": "5 часов", + "id": "5 jam" + }, + "API key (AI Studio)": { + "zh-Hans": "API 密钥 (AI Studio)", + "hi": "API कुंजी (AI Studio)", + "es": "Clave API (AI Studio)", + "ar": "مفتاح API (AI Studio)", + "fr": "Clé API (AI Studio)", + "bn": "API কী (AI Studio)", + "pt-BR": "Chave de API (AI Studio)", + "ru": "Ключ API (AI Studio)", + "id": "Kunci API (AI Studio)" + }, + "API key (sk-ant-…)": { + "zh-Hans": "API 密钥 (sk-ant-…)", + "hi": "API कुंजी (sk-ant-…)", + "es": "Clave API (sk-ant-…)", + "ar": "مفتاح API (sk-ant-…)", + "fr": "Clé API (sk-ant-…)", + "bn": "API কী (sk-ant-…)", + "pt-BR": "Chave de API (sk-ant-…)", + "ru": "Ключ API (sk-ant-…)", + "id": "Kunci API (sk-ant-…)" + }, + "API key (sk-…)": { + "zh-Hans": "API 密钥 (sk-…)", + "hi": "API कुंजी (sk-…)", + "es": "Clave API (sk-…)", + "ar": "مفتاح API (sk-…)", + "fr": "Clé API (sk-…)", + "bn": "API কী (sk-…)", + "pt-BR": "Chave de API (sk-…)", + "ru": "Ключ API (sk-…)", + "id": "Kunci API (sk-…)" + }, + "AUTO": { + "zh-Hans": "自动", + "hi": "AUTO", + "es": "AUTO", + "ar": "تلقائي", + "fr": "AUTO", + "bn": "অটো", + "pt-BR": "AUTO", + "ru": "АВТО", + "id": "AUTO" + }, + "Active pills": { + "zh-Hans": "活跃标签页", + "hi": "सक्रिय टैब", + "es": "Pestañas activas", + "ar": "التبويبات النشطة", + "fr": "Onglets actifs", + "bn": "সক্রিয় ট্যাব", + "pt-BR": "Abas ativas", + "ru": "Активные вкладки", + "id": "Tab aktif" + }, + "Activity": { + "zh-Hans": "动态", + "hi": "गतिविधि", + "es": "Actividad", + "ar": "النشاط", + "fr": "Activité", + "bn": "কার্যকলাপ", + "pt-BR": "Atividade", + "ru": "Активность", + "id": "Aktivitas" + }, + "Agent": { + "zh-Hans": "代理", + "hi": "एजेंट", + "es": "Agente", + "ar": "وكيل", + "fr": "Agent", + "bn": "এজেন্ট", + "pt-BR": "Agente", + "ru": "Агент", + "id": "Agen" + }, + "Agents": { + "zh-Hans": "代理", + "hi": "एजेंट्स", + "es": "Agentes", + "ar": "الوكلاء", + "fr": "Agents", + "bn": "এজেন্টস", + "pt-BR": "Agentes", + "ru": "Агенты", + "id": "Agen" + }, + "Allow": { + "zh-Hans": "允许", + "hi": "अनुमति दें", + "es": "Permitir", + "ar": "السماح", + "fr": "Autoriser", + "bn": "অনুমতি দিন", + "pt-BR": "Permitir", + "ru": "Разрешить", + "id": "Izinkan" + }, + "Allow Coucou to control Music": { + "zh-Hans": "允许 Coucou 控制音乐", + "hi": "Coucou को Music नियंत्रित करने दें", + "es": "Permitir que Coucou controle Music", + "ar": "السماح لـ Coucou بالتحكم في الموسيقى", + "fr": "Autoriser Coucou à contrôler Music", + "bn": "Coucou-কে Music নিয়ন্ত্রণ করতে দিন", + "pt-BR": "Permitir que o Coucou controle o Music", + "ru": "Разрешить Coucou управлять Music", + "id": "Izinkan Coucou mengontrol Music" + }, + "Always": { + "zh-Hans": "始终允许", + "hi": "हमेशा", + "es": "Siempre", + "ar": "دائماً", + "fr": "Toujours", + "bn": "সর্বদা", + "pt-BR": "Sempre", + "ru": "Всегда", + "id": "Selalu" + }, + "Amp plugin": { + "zh-Hans": "Amp 插件", + "hi": "Amp प्लगइन", + "es": "Plugin de Amp", + "ar": "ملحق Amp", + "fr": "Plugin Amp", + "bn": "Amp প্লাগইন", + "pt-BR": "Plugin do Amp", + "ru": "Плагин Amp", + "id": "Plugin Amp" + }, + "An instruction sent from the iPhone uses the model open in chat. The system prompt and conversation history apply. Instructions sent when Coucou is not running are queued and executed on next launch.": { + "zh-Hans": "从 iPhone 发送的指令使用聊天中当前打开的模型。系统提示和对话历史适用。Coucou 未运行时发送的指令将排队,并在下次启动时执行。", + "hi": "iPhone से भेजा गया निर्देश चैट में वर्तमान में खुले मॉडल का उपयोग करता है। सिस्टम प्रॉम्प्ट और बातचीत का इतिहास लागू होता है। Coucou चल नहीं रहे होने पर भेजे गए निर्देश कतार में रखे जाते हैं और अगली बार चालू होने पर चलाए जाते हैं।", + "es": "Una instrucción enviada desde el iPhone usa el modelo abierto actualmente en el chat. Se aplican el prompt de sistema y el historial de conversación. Las instrucciones enviadas mientras Coucou no está en ejecución se ponen en cola y se ejecutan en el siguiente inicio.", + "ar": "التعليمات المُرسَلة من iPhone تستخدم النموذج المفتوح حاليًا في الدردشة. يُطبَّق موجه النظام وتاريخ المحادثة. التعليمات المُرسَلة أثناء توقف Coucou تُضاف إلى قائمة الانتظار وتُنفَّذ عند التشغيل التالي.", + "fr": "Une instruction envoyée depuis l'iPhone utilise le modèle ouvert dans le chat. Le prompt système et l'historique de conversation s'appliquent. Les instructions envoyées quand Coucou ne tourne pas sont mises en file d'attente et exécutées au prochain démarrage.", + "bn": "iPhone থেকে পাঠানো নির্দেশনা চ্যাটে বর্তমানে খোলা মডেল ব্যবহার করে। সিস্টেম প্রম্পট এবং কথোপকথনের ইতিহাস প্রযোজ্য। Coucou চলছে না থাকলে পাঠানো নির্দেশনাগুলো কিউতে রাখা হয় এবং পরবর্তী চালুতে চালানো হয়।", + "pt-BR": "Uma instrução enviada pelo iPhone usa o modelo aberto no momento no chat. O prompt de sistema e o histórico de conversa se aplicam. Instruções enviadas enquanto o Coucou não está em execução ficam na fila e são executadas no próximo início.", + "ru": "Инструкция, отправленная с iPhone, использует модель, открытую в чате. Системный промпт и история разговора применяются. Инструкции, отправленные пока Coucou не запущен, ставятся в очередь и выполняются при следующем запуске.", + "id": "Instruksi yang dikirim dari iPhone menggunakan model yang sedang terbuka di chat. Prompt sistem dan riwayat percakapan berlaku. Instruksi yang dikirim saat Coucou tidak berjalan dimasukkan ke antrean dan dijalankan saat peluncuran berikutnya." + }, + "Anthropic API": { + "zh-Hans": "Anthropic API", + "hi": "Anthropic API", + "es": "API de Anthropic", + "ar": "واجهة برمجة Anthropic", + "fr": "API Anthropic", + "bn": "Anthropic API", + "pt-BR": "API da Anthropic", + "ru": "Anthropic API", + "id": "API Anthropic" + }, + "Antigravity hooks": { + "zh-Hans": "Antigravity 钩子", + "hi": "Antigravity हुक", + "es": "Hooks de Antigravity", + "ar": "خطافات Antigravity", + "fr": "Hooks Antigravity", + "bn": "Antigravity হুক", + "pt-BR": "Hooks do Antigravity", + "ru": "Хуки Antigravity", + "id": "Hooks Antigravity" + }, + "Applies on next launch": { + "zh-Hans": "下次启动时生效", + "hi": "अगली बार लॉन्च पर लागू होगा", + "es": "Se aplica en el próximo inicio", + "ar": "يُطبَّق عند التشغيل التالي", + "fr": "Appliqué au prochain lancement", + "bn": "পরবর্তী চালুতে প্রযোজ্য হবে", + "pt-BR": "Aplica no próximo lançamento", + "ru": "Применится при следующем запуске", + "id": "Berlaku saat peluncuran berikutnya" + }, + "Approval requests appear in the notch. If Coucou is not running, Hermes falls back to its own prompt.": { + "zh-Hans": "审批请求显示在刘海中。若 Coucou 未运行,Hermes 将回退到其自己的提示。", + "hi": "नॉच में अनुमोदन अनुरोध दिखाए जाते हैं। यदि Coucou नहीं चल रहा तो Hermes अपने प्रॉम्प्ट पर वापस जाता है।", + "es": "Las solicitudes de aprobación aparecen en el notch. Si Coucou no está en ejecución, Hermes usa su propio prompt.", + "ar": "تظهر طلبات الموافقة في الشريط. إذا لم تكن Coucou تعمل، يعود Hermes إلى طرفيته الخاصة.", + "fr": "Les demandes d'approbation s'affichent dans le notch. Si Coucou ne tourne pas, Hermes utilise son propre prompt.", + "bn": "নচে অনুমোদন অনুরোধ দেখানো হয়। Coucou না চললে Hermes তার নিজের প্রম্পটে ফলব্যাক করে।", + "pt-BR": "Solicitações de aprovação aparecem no notch. Se Coucou não estiver em execução, Hermes usa seu próprio prompt.", + "ru": "Запросы подтверждения отображаются в notch. Если Coucou не запущен, Hermes использует собственный prompt.", + "id": "Permintaan persetujuan muncul di notch. Jika Coucou tidak berjalan, Hermes menggunakan promptnya sendiri." + }, + "Approval requests are handled by Hermes (terminal or gateway).": { + "zh-Hans": "审批请求由 Hermes 处理(终端或网关)。", + "hi": "अनुमोदन अनुरोध Hermes द्वारा संभाले जाते हैं (टर्मिनल या गेटवे)।", + "es": "Las solicitudes de aprobación las gestiona Hermes (terminal o gateway).", + "ar": "تعالج Hermes طلبات الموافقة (الطرفية أو البوابة).", + "fr": "Les demandes d'approbation sont gérées par Hermes (terminal ou passerelle).", + "bn": "Hermes অনুমোদন অনুরোধ পরিচালনা করে (টার্মিনাল বা গেটওয়ে)।", + "pt-BR": "Solicitações de aprovação tratadas pelo Hermes (terminal ou gateway).", + "ru": "Запросы подтверждения обрабатывает Hermes (терминал или шлюз).", + "id": "Permintaan persetujuan ditangani oleh Hermes (terminal atau gateway)." + }, + "Approved": { + "zh-Hans": "已批准", + "hi": "अनुमोदित", + "es": "Aprobado", + "ar": "موافق عليه", + "fr": "Approuvé", + "bn": "অনুমোদিত", + "pt-BR": "Aprovado", + "ru": "Одобрено", + "id": "Disetujui" + }, + "Ask Claude": { + "zh-Hans": "问 Claude", + "hi": "Claude से पूछें", + "es": "Preguntar a Claude", + "ar": "اسأل Claude", + "fr": "Demander à Claude", + "bn": "Claude-কে জিজ্ঞেস করুন", + "pt-BR": "Perguntar ao Claude", + "ru": "Спросить Claude", + "id": "Tanya Claude" + }, + "Ask a question": { + "zh-Hans": "提问", + "hi": "एक प्रश्न पूछें", + "es": "Hacer una pregunta", + "ar": "اطرح سؤالاً", + "fr": "Poser une question", + "bn": "একটি প্রশ্ন করুন", + "pt-BR": "Fazer uma pergunta", + "ru": "Задать вопрос", + "id": "Ajukan pertanyaan" + }, + "Ask a question about it": { + "zh-Hans": "提问", + "hi": "इसके बारे में प्रश्न पूछें", + "es": "Hacer una pregunta", + "ar": "اطرح سؤالًا عنه", + "fr": "Poser une question à ce sujet", + "bn": "এটা সম্পর্কে প্রশ্ন করুন", + "pt-BR": "Fazer uma pergunta sobre isso", + "ru": "Задать вопрос", + "id": "Ajukan pertanyaan tentang ini" + }, + "Ask me anything…": { + "zh-Hans": "问我任何事…", + "hi": "कुछ भी पूछें…", + "es": "Pregúntame algo…", + "ar": "اسألني أي شيء…", + "fr": "Demande-moi n'importe quoi…", + "bn": "যেকোনো কিছু জিজ্ঞেস করুন…", + "pt-BR": "Pergunte qualquer coisa…", + "ru": "Спроси меня о чём угодно…", + "id": "Tanyakan apa saja…" + }, + "Attach front window": { + "zh-Hans": "附加前端窗口", + "hi": "फ्रंट विंडो अटैच करें", + "es": "Adjuntar ventana frontal", + "ar": "إرفاق النافذة الأمامية", + "fr": "Attacher la fenêtre de premier plan", + "bn": "সামনের উইন্ডো সংযুক্ত করো", + "pt-BR": "Anexar janela frontal", + "ru": "Прикрепить переднее окно", + "id": "Lampirkan jendela depan" + }, + "Auto-close · {seconds}s": { + "zh-Hans": "自动关闭 · {seconds} 秒", + "hi": "ऑटो-क्लोज़ · {seconds}स", + "es": "Cierre automático · {seconds}s", + "ar": "إغلاق تلقائي · {seconds}ث", + "fr": "Fermeture auto · {seconds}s", + "bn": "স্বয়ংক্রিয়-বন্ধ · {seconds}স", + "pt-BR": "Fechar auto · {seconds}s", + "ru": "Автозакрытие · {seconds} с", + "id": "Tutup otomatis · {seconds}dt" + }, + "Automation not allowed": { + "zh-Hans": "不允许自动化", + "hi": "स्वचालन की अनुमति नहीं", + "es": "Automatización no permitida", + "ar": "الأتمتة غير مسموح بها", + "fr": "Automatisation non autorisée", + "bn": "অটোমেশন অনুমোদিত নয়", + "pt-BR": "Automação não permitida", + "ru": "Автоматизация не разрешена", + "id": "Otomatisasi tidak diizinkan" + }, + "Behavior": { + "zh-Hans": "行为", + "hi": "व्यवहार", + "es": "Comportamiento", + "ar": "السلوك", + "fr": "Comportement", + "bn": "আচরণ", + "pt-BR": "Comportamento", + "ru": "Поведение", + "id": "Perilaku" + }, + "Busiest day": { + "zh-Hans": "最忙的一天", + "hi": "सबसे व्यस्त दिन", + "es": "Día más activo", + "ar": "أكثر يوم نشاطًا", + "fr": "Jour le plus chargé", + "bn": "সবচেয়ে ব্যস্ত দিন", + "pt-BR": "Dia mais ativo", + "ru": "Самый насыщенный день", + "id": "Hari tersibuk" + }, + "COMMANDS": { + "zh-Hans": "命令", + "hi": "कमांड", + "es": "COMANDOS", + "ar": "الأوامر", + "fr": "COMMANDES", + "bn": "কমান্ড", + "pt-BR": "COMANDOS", + "ru": "КОМАНДЫ", + "id": "PERINTAH" + }, + "Can't reach {name} at {url}. Is it running?": { + "zh-Hans": "无法访问 {name}({url})。它正在运行吗?", + "hi": "{name} को {url} पर नहीं पहुंचा जा सका। क्या यह चल रहा है?", + "es": "No se pudo contactar con {name} en {url}. ¿Está en ejecución?", + "ar": "تعذَّر الوصول إلى {name} على {url}. هل هو قيد التشغيل؟", + "fr": "Impossible d'atteindre {name} sur {url}. Est-il en cours d'exécution ?", + "bn": "{name} কে {url} তে পৌঁছানো যাচ্ছে না। এটি কি চলছে?", + "pt-BR": "Não foi possível alcançar {name} em {url}. Ele está em execução?", + "ru": "Не удалось подключиться к {name} по адресу {url}. Он запущен?", + "id": "Tidak dapat menjangkau {name} di {url}. Apakah sedang berjalan?" + }, + "Cancel": { + "zh-Hans": "取消", + "hi": "रद्द करें", + "es": "Cancelar", + "ar": "إلغاء", + "fr": "Annuler", + "bn": "বাতিল", + "pt-BR": "Cancelar", + "ru": "Отмена", + "id": "Batal" + }, + "Chat": { + "zh-Hans": "聊天", + "hi": "चैट", + "es": "Chat", + "ar": "الدردشة", + "fr": "Chat", + "bn": "চ্যাট", + "pt-BR": "Chat", + "ru": "Чат", + "id": "Obrolan" + }, + "Chat with {name}": { + "zh-Hans": "与 {name} 聊天", + "hi": "{name} के साथ चैट", + "es": "Chatear con {name}", + "ar": "الدردشة مع {name}", + "fr": "Discuter avec {name}", + "bn": "{name}-এর সাথে চ্যাট", + "pt-BR": "Conversar com {name}", + "ru": "Чат с {name}", + "id": "Chat dengan {name}" + }, + "Chat — other providers": { + "zh-Hans": "聊天 — 其他服务商", + "hi": "चैट — अन्य प्रदाता", + "es": "Chat — otros proveedores", + "ar": "الدردشة — مزودون آخرون", + "fr": "Chat — autres fournisseurs", + "bn": "চ্যাট — অন্যান্য প্রদানকারী", + "pt-BR": "Chat — outros provedores", + "ru": "Чат — другие провайдеры", + "id": "Chat — penyedia lain" + }, + "Choose the tools you use. Coucou only shows what you declare here.": { + "zh-Hans": "选择你使用的工具。Coucou 只显示你在这里声明的内容。", + "hi": "वे टूल चुनें जो आप उपयोग करते हैं। Coucou केवल वही दिखाता है जो आप यहाँ घोषित करते हैं।", + "es": "Elige las herramientas que usas. Coucou solo muestra lo que declares aquí.", + "ar": "اختر الأدوات التي تستخدمها. يعرض Coucou فقط ما تُعلنه هنا.", + "fr": "Choisis les outils que tu utilises. Coucou n'affiche que ce que tu déclares ici.", + "bn": "আপনি যে টুলগুলো ব্যবহার করেন তা বেছে নিন। Coucou শুধুমাত্র আপনার ঘোষণা করা জিনিস দেখায়।", + "pt-BR": "Escolha as ferramentas que você usa. O Coucou mostra apenas o que você declarar aqui.", + "ru": "Выбери инструменты, которые ты используешь. Coucou показывает только то, что ты здесь объявишь.", + "id": "Pilih alat yang Anda gunakan. Coucou hanya menampilkan yang Anda deklarasikan di sini." + }, + "Classic token with repo scope, or fine-grained with read access to Pull requests, commit statuses, and Actions.": { + "zh-Hans": "具有 repo 权限的经典令牌,或具有 Pull requests、Commit statuses 和 Actions 读取权限的细粒度令牌。", + "hi": "repo स्कोप के साथ क्लासिक टोकन, या Pull requests, Commit statuses और Actions में रीड एक्सेस के साथ फाइन-ग्रेन्ड टोकन।", + "es": "Token clásico con scope repo, o de grano fino con acceso de lectura a Pull requests, Commit statuses y Actions.", + "ar": "رمز كلاسيكي بصلاحية repo، أو رمز دقيق بصلاحية قراءة لـ Pull requests وحالات الإيداع والـ Actions.", + "fr": "Token classique avec le scope repo, ou à portée fine avec accès en lecture aux Pull requests, statuts de commit et Actions.", + "bn": "repo স্কোপ সহ ক্লাসিক টোকেন, অথবা Pull requests, Commit statuses এবং Actions-এ পড়ার অ্যাক্সেস সহ ফাইন-গ্রেইনড টোকেন।", + "pt-BR": "Token clássico com escopo repo, ou fine-grained com acesso de leitura a Pull requests, Commit statuses e Actions.", + "ru": "Классический токен с правом repo или гранулярный с доступом на чтение к Pull requests, Commit statuses и Actions.", + "id": "Token klasik dengan scope repo, atau token fine-grained dengan akses baca ke Pull requests, Commit statuses, dan Actions." + }, + "Claude Code finished": { + "zh-Hans": "Claude Code 已完成", + "hi": "Claude Code समाप्त हो गया", + "es": "Claude Code terminó", + "ar": "انتهى Claude Code", + "fr": "Claude Code a terminé", + "bn": "Claude Code শেষ হয়েছে", + "pt-BR": "Claude Code terminou", + "ru": "Claude Code завершил", + "id": "Claude Code selesai" + }, + "Claude Code hooks": { + "zh-Hans": "Claude Code 钩子", + "hi": "Claude Code हुक", + "es": "Hooks de Claude Code", + "ar": "خطافات Claude Code", + "fr": "Hooks Claude Code", + "bn": "Claude Code হুক", + "pt-BR": "Hooks do Claude Code", + "ru": "Хуки Claude Code", + "id": "Hooks Claude Code" + }, + "Claude Code is asking": { + "zh-Hans": "Claude Code 正在询问", + "hi": "Claude Code पूछ रहा है", + "es": "Claude Code está preguntando", + "ar": "Claude Code يسأل", + "fr": "Claude Code demande", + "bn": "Claude Code জিজ্ঞেস করছে", + "pt-BR": "Claude Code está perguntando", + "ru": "Claude Code спрашивает", + "id": "Claude Code bertanya" + }, + "Claude is reading {name}…": { + "zh-Hans": "Claude 正在读取 {name}…", + "hi": "Claude {name} पढ़ रहा है…", + "es": "Claude está leyendo {name}…", + "ar": "Claude يقرأ {name}…", + "fr": "Claude lit {name}…", + "bn": "Claude {name} পড়ছে…", + "pt-BR": "Claude está lendo {name}…", + "ru": "Claude читает {name}…", + "id": "Claude sedang membaca {name}…" + }, + "Claude is searching…": { + "zh-Hans": "Claude 正在搜索…", + "hi": "Claude खोज रहा है…", + "es": "Claude está buscando…", + "ar": "Claude يبحث…", + "fr": "Claude cherche…", + "bn": "Claude খুঁজছে…", + "pt-BR": "Claude está pesquisando…", + "ru": "Claude ищет…", + "id": "Claude sedang mencari…" + }, + "Claude plan": { + "zh-Hans": "Claude 计划", + "hi": "Claude प्लान", + "es": "Plan Claude", + "ar": "خطة Claude", + "fr": "Forfait Claude", + "bn": "Claude প্ল্যান", + "pt-BR": "Plano Claude", + "ru": "План Claude", + "id": "Paket Claude" + }, + "Clear": { + "zh-Hans": "清除", + "hi": "साफ़ करें", + "es": "Borrar", + "ar": "مسح", + "fr": "Effacer", + "bn": "মুছুন", + "pt-BR": "Limpar", + "ru": "Очистить", + "id": "Hapus" + }, + "Clear history": { + "zh-Hans": "清除历史记录", + "hi": "इतिहास साफ़ करें", + "es": "Borrar historial", + "ar": "مسح السجل", + "fr": "Effacer l'historique", + "bn": "ইতিহাস মুছুন", + "pt-BR": "Limpar histórico", + "ru": "Очистить историю", + "id": "Hapus riwayat" + }, + "Close": { + "zh-Hans": "关闭", + "hi": "बंद करें", + "es": "Cerrar", + "ar": "إغلاق", + "fr": "Fermer", + "bn": "বন্ধ", + "pt-BR": "Fechar", + "ru": "Закрыть", + "id": "Tutup" + }, + "Close after": { + "zh-Hans": "自动关闭", + "hi": "बाद में बंद करें", + "es": "Cerrar después de", + "ar": "إغلاق بعد", + "fr": "Fermer après", + "bn": "পরে বন্ধ করুন", + "pt-BR": "Fechar após", + "ru": "Закрыть через", + "id": "Tutup setelah" + }, + "Close notch (if not pinned)": { + "zh-Hans": "关闭岛(如未固定)", + "hi": "आइलैंड बंद करें (पिन न हो तो)", + "es": "Cerrar island (si no está fijado)", + "ar": "إغلاق الشريط (إذا لم يكن مثبتاً)", + "fr": "Fermer l'encoché (s'il n'est pas épinglé)", + "bn": "আইল্যান্ড বন্ধ করো (পিন না থাকলে)", + "pt-BR": "Fechar island (se não fixado)", + "ru": "Закрыть нотч (если не закреплён)", + "id": "Tutup island (jika tidak disematkan)" + }, + "Codex hooks": { + "zh-Hans": "Codex 钩子", + "hi": "Codex हुक", + "es": "Hooks de Codex", + "ar": "خطافات Codex", + "fr": "Hooks Codex", + "bn": "Codex হুক", + "pt-BR": "Hooks do Codex", + "ru": "Хуки Codex", + "id": "Hooks Codex" + }, + "Coming soon": { + "zh-Hans": "即将推出", + "hi": "जल्द आ रहा है", + "es": "Próximamente", + "ar": "قريباً", + "fr": "Bientôt disponible", + "bn": "শীঘ্রই আসছে", + "pt-BR": "Em breve", + "ru": "Скоро", + "id": "Segera hadir" + }, + "Completed successfully.": { + "zh-Hans": "已成功完成。", + "hi": "सफलतापूर्वक पूर्ण हुआ।", + "es": "Completado con éxito.", + "ar": "اكتمل بنجاح.", + "fr": "Terminé avec succès.", + "bn": "সফলভাবে সম্পন্ন।", + "pt-BR": "Concluído com sucesso.", + "ru": "Завершено успешно.", + "id": "Selesai dengan berhasil." + }, + "Confirm and write": { + "zh-Hans": "确认并写入", + "hi": "पुष्टि करें और लिखें", + "es": "Confirmar y escribir", + "ar": "تأكيد والكتابة", + "fr": "Confirmer et écrire", + "bn": "নিশ্চিত করুন এবং লিখুন", + "pt-BR": "Confirmar e gravar", + "ru": "Подтвердить и записать", + "id": "Konfirmasi & tulis" + }, + "Connect": { + "zh-Hans": "连接", + "hi": "कनेक्ट करें", + "es": "Conectar", + "ar": "اتصال", + "fr": "Connecter", + "bn": "সংযুক্ত করুন", + "pt-BR": "Conectar", + "ru": "Подключить", + "id": "Hubungkan" + }, + "Connect to a local model server. No API key required.": { + "zh-Hans": "连接到本地模型服务器。无需 API 密钥。", + "hi": "एक स्थानीय मॉडल सर्वर से कनेक्ट करें। कोई API कुंजी की आवश्यकता नहीं।", + "es": "Conéctate a un servidor de modelos local. No se necesita clave API.", + "ar": "اتصل بخادم نماذج محلي. لا حاجة لمفتاح API.", + "fr": "Connecte-toi à un serveur de modèles local. Aucune clé API requise.", + "bn": "একটি স্থানীয় মডেল সার্ভারে সংযুক্ত হন। কোনো API কী লাগবে না।", + "pt-BR": "Conecte-se a um servidor de modelos local. Não é necessária chave de API.", + "ru": "Подключись к локальному серверу моделей. API-ключ не нужен.", + "id": "Hubungkan ke server model lokal. Tidak perlu kunci API." + }, + "Connected": { + "zh-Hans": "已连接", + "hi": "जुड़ा है", + "es": "Conectado", + "ar": "متصل", + "fr": "Connecté", + "bn": "সংযুক্ত", + "pt-BR": "Conectado", + "ru": "Подключено", + "id": "Terhubung" + }, + "Connected · loading…": { + "zh-Hans": "已连接 · 加载中…", + "hi": "जुड़ा है · लोड हो रहा है…", + "es": "Conectado · cargando…", + "ar": "متصل · جارٍ التحميل…", + "fr": "Connecté · chargement…", + "bn": "সংযুক্ত · লোড হচ্ছে…", + "pt-BR": "Conectado · carregando…", + "ru": "Подключено · загрузка…", + "id": "Terhubung · memuat…" + }, + "Connected · {detail}": { + "zh-Hans": "已连接 · {detail}", + "hi": "जुड़ा है · {detail}", + "es": "Conectado · {detail}", + "ar": "متصل · {detail}", + "fr": "Connecté · {detail}", + "bn": "সংযুক্ত · {detail}", + "pt-BR": "Conectado · {detail}", + "ru": "Подключено · {detail}", + "id": "Terhubung · {detail}" + }, + "Connecting…": { + "zh-Hans": "正在连接…", + "hi": "कनेक्ट हो रहा है…", + "es": "Conectando…", + "ar": "جارٍ الاتصال…", + "fr": "Connexion…", + "bn": "সংযুক্ত হচ্ছে…", + "pt-BR": "Conectando…", + "ru": "Подключение…", + "id": "Menghubungkan…" + }, + "Continue…": { + "zh-Hans": "继续…", + "hi": "जारी रखें…", + "es": "Continuar…", + "ar": "متابعة…", + "fr": "Continuer…", + "bn": "চালিয়ে যান…", + "pt-BR": "Continuar…", + "ru": "Продолжить…", + "id": "Lanjutkan…" + }, + "Copy": { + "zh-Hans": "复制", + "hi": "कॉपी", + "es": "Copiar", + "ar": "نسخ", + "fr": "Copier", + "bn": "কপি", + "pt-BR": "Copiar", + "ru": "Скопировать", + "id": "Salin" + }, + "Custom…": { + "zh-Hans": "自定义…", + "hi": "कस्टम…", + "es": "Personalizado…", + "ar": "مخصص…", + "fr": "Personnalisé…", + "bn": "কাস্টম…", + "pt-BR": "Personalizado…", + "ru": "Свой…", + "id": "Kustom…" + }, + "Demo": { + "zh-Hans": "演示", + "hi": "डेमो", + "es": "Demo", + "ar": "تجريبي", + "fr": "Démo", + "bn": "ডেমো", + "pt-BR": "Demo", + "ru": "Демо", + "id": "Demo" + }, + "Demo mode": { + "zh-Hans": "演示模式", + "hi": "डेमो मोड", + "es": "Modo demo", + "ar": "وضع العرض", + "fr": "Mode démo", + "bn": "ডেমো মোড", + "pt-BR": "Modo demo", + "ru": "Демо-режим", + "id": "Mode demo" + }, + "Denied": { + "zh-Hans": "已拒绝", + "hi": "अस्वीकृत", + "es": "Denegado", + "ar": "مرفوض", + "fr": "Refusé", + "bn": "প্রত্যাখ্যাত", + "pt-BR": "Negado", + "ru": "Отклонено", + "id": "Ditolak" + }, + "Deny": { + "zh-Hans": "拒绝", + "hi": "अस्वीकार", + "es": "Denegar", + "ar": "رفض", + "fr": "Refuser", + "bn": "প্রত্যাখ্যান", + "pt-BR": "Negar", + "ru": "Отклонить", + "id": "Tolak" + }, + "Deployments": { + "zh-Hans": "部署", + "hi": "तैनाती", + "es": "Despliegues", + "ar": "نشر", + "fr": "Déploiements", + "bn": "ডিপ্লয়মেন্ট", + "pt-BR": "Implantações", + "ru": "Деплои", + "id": "Penerapan" + }, + "Diff too large": { + "zh-Hans": "Diff 太大", + "hi": "Diff बहुत बड़ा है", + "es": "Diff demasiado grande", + "ar": "الفرق كبير جدًا", + "fr": "Diff trop grand", + "bn": "ডিফ খুব বড়", + "pt-BR": "Diff muito grande", + "ru": "Diff слишком большой", + "id": "Diff terlalu besar" + }, + "Disconnect": { + "zh-Hans": "断开连接", + "hi": "डिस्कनेक्ट करें", + "es": "Desconectar", + "ar": "قطع الاتصال", + "fr": "Déconnecter", + "bn": "সংযোগ বিচ্ছিন্ন করুন", + "pt-BR": "Desconectar", + "ru": "Отключить", + "id": "Putuskan" + }, + "Draft": { + "zh-Hans": "草稿", + "hi": "ड्राफ्ट", + "es": "Borrador", + "ar": "مسودة", + "fr": "Brouillon", + "bn": "ড্রাফ্ট", + "pt-BR": "Rascunho", + "ru": "Черновик", + "id": "Draf" + }, + "Drop a file or window, or ask me anything.": { + "zh-Hans": "拖入文件或窗口,或问我任何问题。", + "hi": "कोई फ़ाइल या विंडो ड्रॉप करें, या कुछ भी पूछें।", + "es": "Suelta un archivo o ventana, o pregúntame cualquier cosa.", + "ar": "أسقط ملفاً أو نافذة، أو اسألني أي شيء.", + "fr": "Dépose un fichier ou une fenêtre, ou demande-moi n'importe quoi.", + "bn": "একটি ফাইল বা উইন্ডো ড্রপ করুন, অথবা যেকোনো কিছু জিজ্ঞেস করুন।", + "pt-BR": "Solte um arquivo ou janela, ou me pergunte qualquer coisa.", + "ru": "Брось файл или окно, или спроси меня о чём угодно.", + "id": "Seret file atau jendela, atau tanya apa saja." + }, + "Drop your files here": { + "zh-Hans": "把文件拖到这里", + "hi": "अपनी फ़ाइलें यहाँ छोड़ें", + "es": "Suelta tus archivos aquí", + "ar": "أسقط ملفاتك هنا", + "fr": "Dépose tes fichiers ici", + "bn": "আপনার ফাইলগুলি এখানে ড্রপ করুন", + "pt-BR": "Solte seus arquivos aqui", + "ru": "Перетащите файлы сюда", + "id": "Letakkan file Anda di sini" + }, + "Duplicate": { + "zh-Hans": "重复", + "hi": "डुप्लीकेट", + "es": "Duplicado", + "ar": "مكرر", + "fr": "Doublon", + "bn": "ডুপ্লিকেট", + "pt-BR": "Duplicado", + "ru": "Дублирующийся", + "id": "Duplikat" + }, + "Edits": { + "zh-Hans": "编辑", + "hi": "संपादित करता है", + "es": "Edita", + "ar": "يُعدّل", + "fr": "Modifie", + "bn": "সম্পাদনা করে", + "pt-BR": "Edita", + "ru": "Редактирует", + "id": "Mengedit" + }, + "Emails": { + "zh-Hans": "邮件", + "hi": "ईमेल", + "es": "Correos", + "ar": "الإيميلات", + "fr": "Emails", + "bn": "ইমেইল", + "pt-BR": "Emails", + "ru": "Письма", + "id": "Email" + }, + "Enable sounds": { + "zh-Hans": "启用声音", + "hi": "ध्वनि सक्षम करें", + "es": "Habilitar sonidos", + "ar": "تفعيل الأصوات", + "fr": "Activer les sons", + "bn": "শব্দ সক্ষম করুন", + "pt-BR": "Ativar sons", + "ru": "Включить звуки", + "id": "Aktifkan suara" + }, + "Explore all features without an API key or Claude Code. Nothing is written to disk.": { + "zh-Hans": "无需 API 密钥或 Claude Code,探索所有功能。不会写入任何内容到磁盘。", + "hi": "API कुंजी या Claude Code के बिना सभी सुविधाएँ एक्सप्लोर करें। डिस्क पर कुछ नहीं लिखा जाता।", + "es": "Explora todas las funciones sin clave de API ni Claude Code. Nada se escribe en el disco.", + "ar": "استكشف جميع الميزات بدون مفتاح API أو Claude Code. لا يُكتب شيء على القرص.", + "fr": "Explore toutes les fonctions sans clé d'API ni Claude Code. Rien n'est écrit sur le disque.", + "bn": "API কী বা Claude Code ছাড়াই সমস্ত ফিচার এক্সপ্লোর করুন। ডিস্কে কিছু লেখা হয় না।", + "pt-BR": "Explore todos os recursos sem chave de API ou Claude Code. Nada é gravado no disco.", + "ru": "Изучи все функции без API-ключа и Claude Code. Ничего не записывается на диск.", + "id": "Jelajahi semua fitur tanpa kunci API atau Claude Code. Tidak ada yang ditulis ke disk." + }, + "FILES": { + "zh-Hans": "文件", + "hi": "फ़ाइलें", + "es": "ARCHIVOS", + "ar": "الملفات", + "fr": "FICHIERS", + "bn": "ফাইল", + "pt-BR": "ARQUIVOS", + "ru": "ФАЙЛЫ", + "id": "BERKAS" + }, + "Failed": { + "zh-Hans": "失败", + "hi": "विफल", + "es": "Fallido", + "ar": "فشل", + "fr": "Échoué", + "bn": "ব্যর্থ", + "pt-BR": "Falhou", + "ru": "Ошибка", + "id": "Gagal" + }, + "Fetches": { + "zh-Hans": "获取", + "hi": "प्राप्त करता है", + "es": "Obtiene", + "ar": "يجلب", + "fr": "Récupère", + "bn": "আনে", + "pt-BR": "Busca", + "ru": "Получает", + "id": "Mengambil" + }, + "Gemini CLI hooks": { + "zh-Hans": "Gemini CLI 钩子", + "hi": "Gemini CLI हुक", + "es": "Hooks de Gemini CLI", + "ar": "خطافات Gemini CLI", + "fr": "Hooks Gemini CLI", + "bn": "Gemini CLI হুক", + "pt-BR": "Hooks do Gemini CLI", + "ru": "Хуки Gemini CLI", + "id": "Hooks Gemini CLI" + }, + "General": { + "zh-Hans": "通用", + "hi": "सामान्य", + "es": "General", + "ar": "عام", + "fr": "Général", + "bn": "সাধারণ", + "pt-BR": "Geral", + "ru": "Основные", + "id": "Umum" + }, + "GitHub Copilot CLI hooks": { + "zh-Hans": "GitHub Copilot CLI 钩子", + "hi": "GitHub Copilot CLI हुक", + "es": "Hooks de GitHub Copilot CLI", + "ar": "خطافات GitHub Copilot CLI", + "fr": "Hooks GitHub Copilot CLI", + "bn": "GitHub Copilot CLI হুক", + "pt-BR": "Hooks do GitHub Copilot CLI", + "ru": "Хуки GitHub Copilot CLI", + "id": "Hooks GitHub Copilot CLI" + }, + "Give me a sec — back to work in three seconds.": { + "zh-Hans": "给我一秒钟——三秒后恢复工作。", + "hi": "एक पल — तीन सेकंड में वापस काम पर।", + "es": "Dame un segundo — de vuelta al trabajo en tres segundos.", + "ar": "لحظة — سأعود للعمل في ثلاث ثوانٍ.", + "fr": "Une seconde — de retour au travail dans trois secondes.", + "bn": "এক সেকেন্ড — তিন সেকেন্ডে কাজে ফিরে যাচ্ছি।", + "pt-BR": "Um segundo — voltando ao trabalho em três segundos.", + "ru": "Секунду — вернусь к работе через три секунды.", + "id": "Satu detik — kembali bekerja dalam tiga detik." + }, + "Global shortcuts — active from any app": { + "zh-Hans": "全局快捷键 — 在任何应用中有效", + "hi": "ग्लोबल शॉर्टकट — किसी भी ऐप से काम करते हैं", + "es": "Atajos globales — funcionan desde cualquier app", + "ar": "اختصارات عامة — تعمل من أي تطبيق", + "fr": "Raccourcis globaux — actifs depuis n'importe quelle app", + "bn": "গ্লোবাল শর্টকাট — যেকোনো অ্যাপ থেকে কাজ করে", + "pt-BR": "Atalhos globais — funcionam em qualquer app", + "ru": "Глобальные сочетания — работают из любого приложения", + "id": "Pintasan global — bekerja dari app mana pun" + }, + "Gmail node timed out after 30s. Retry or open n8n.": { + "zh-Hans": "Gmail 节点在 30 秒后超时。请重试或打开 n8n。", + "hi": "Gmail नोड 30 सेकंड बाद टाइम आउट हो गया। पुनः प्रयास करें या n8n खोलें।", + "es": "El nodo de Gmail expiró después de 30s. Reintenta o abre n8n.", + "ar": "انتهت مهلة عقدة Gmail بعد 30 ثانية. أعد المحاولة أو افتح n8n.", + "fr": "Le nœud Gmail a expiré après 30s. Réessaie ou ouvre n8n.", + "bn": "Gmail নোড 30 সেকেন্ড পরে টাইম আউট হয়েছে। পুনরায় চেষ্টা করুন বা n8n খুলুন।", + "pt-BR": "Nó do Gmail expirou após 30s. Tente novamente ou abra o n8n.", + "ru": "Узел Gmail завершился по таймауту через 30с. Повторите или откройте n8n.", + "id": "Node Gmail habis waktu setelah 30 detik. Coba lagi atau buka n8n." + }, + "Go to alert": { + "zh-Hans": "跳转到警告", + "hi": "अलर्ट पर जाएं", + "es": "Ir a la alerta", + "ar": "الانتقال إلى التنبيه", + "fr": "Aller à l'alerte", + "bn": "সতর্কতায় যাও", + "pt-BR": "Ir para o alerta", + "ru": "Перейти к уведомлению", + "id": "Ke notifikasi" + }, + "Go to tab by number": { + "zh-Hans": "按编号切换标签", + "hi": "नंबर से टैब पर जाएं", + "es": "Cambiar a pestaña por número", + "ar": "التبديل إلى تبويب برقمه", + "fr": "Passer à l'onglet par numéro", + "bn": "নম্বর দিয়ে ট্যাবে যাও", + "pt-BR": "Mudar para aba por número", + "ru": "Перейти на вкладку по номеру", + "id": "Pindah ke tab berdasarkan nomor" + }, + "Goes through the Coucou relay to Apple's push service. Only the agent name and state are sent: no project name, command, or path.": { + "zh-Hans": "通过 Coucou 中继传至 Apple 推送服务。仅发送 Agent 的名称和状态:不含项目名称、命令或路径。", + "hi": "Coucou रिले के माध्यम से Apple के पुश सर्विस से जाता है। केवल एजेंट का नाम और स्थिति भेजी जाती है: कोई प्रोजेक्ट नाम, कमांड या पाथ नहीं।", + "es": "Pasa por el relay de Coucou al servicio push de Apple. Solo se envían el nombre y el estado del agente: sin nombre de proyecto, comando ni ruta.", + "ar": "يمر عبر ريلاي Coucou إلى خدمة Apple للإشعارات. يُرسَل فقط اسم الوكيل وحالته: لا اسم مشروع ولا أمر ولا مسار.", + "fr": "Passe par le relais Coucou vers le service push d'Apple. Seuls le nom et l'état de l'agent sont envoyés : aucun nom de projet, commande ou chemin.", + "bn": "Apple-এর পুশ সার্ভিসে Coucou রিলের মাধ্যমে যায়। শুধুমাত্র এজেন্টের নাম এবং অবস্থা পাঠানো হয়: কোনো প্রজেক্ট নাম, কমান্ড বা পাথ নয়।", + "pt-BR": "Passa pelo relay do Coucou para o serviço push da Apple. Apenas o nome e o estado do agente são enviados: sem nome de projeto, comando ou caminho.", + "ru": "Проходит через ретранслятор Coucou к сервису push Apple. Отправляются только имя и состояние агента: без имени проекта, команды или пути.", + "id": "Melewati relay Coucou ke layanan push Apple. Hanya nama dan status agen yang dikirim: tanpa nama proyek, perintah, atau jalur." + }, + "Google AI": { + "zh-Hans": "Google AI", + "hi": "Google AI", + "es": "Google AI", + "ar": "Google AI", + "fr": "Google AI", + "bn": "Google AI", + "pt-BR": "Google AI", + "ru": "Google AI", + "id": "Google AI" + }, + "Hermes Agent": { + "zh-Hans": "Hermes Agent", + "hi": "Hermes Agent", + "es": "Hermes Agent", + "ar": "ملحق Hermes", + "fr": "Hermes Agent", + "bn": "Hermes প্লাগইন", + "pt-BR": "Hermes Agent", + "ru": "Hermes Agent", + "id": "Hermes Agent" + }, + "Hermes plugin removed": { + "zh-Hans": "✓ Hermes 插件已移除。", + "hi": "✓ Hermes प्लगइन हटा दिया गया।", + "es": "✓ Plugin de Hermes eliminado.", + "ar": "✓ تمت إزالة ملحق Hermes.", + "fr": "✓ Plugin Hermes supprimé.", + "bn": "✓ Hermes প্লাগইন সরানো হয়েছে।", + "pt-BR": "✓ Plugin do Hermes removido.", + "ru": "✓ Плагин Hermes удалён.", + "id": "✓ Plugin Hermes dihapus." + }, + "Hide after": { + "zh-Hans": "隐藏于", + "hi": "बाद में छुपाएं", + "es": "Ocultar después de", + "ar": "إخفاء بعد", + "fr": "Masquer après", + "bn": "পরে লুকান", + "pt-BR": "Ocultar após", + "ru": "Скрыть через", + "id": "Sembunyikan setelah" + }, + "Hide project names in shared images": { + "zh-Hans": "在分享图片中隐藏项目名称", + "hi": "साझा छवियों में प्रोजेक्ट नाम छुपाएं", + "es": "Ocultar nombres de proyectos en imágenes compartidas", + "ar": "إخفاء أسماء المشاريع في الصور المشتركة", + "fr": "Masquer les noms de projets dans les images partagées", + "bn": "শেয়ার করা ছবিতে প্রকল্পের নাম লুকান", + "pt-BR": "Ocultar nomes de projetos em imagens compartilhadas", + "ru": "Скрывать названия проектов в изображениях", + "id": "Sembunyikan nama proyek di gambar yang dibagikan" + }, + "Hooks installed": { + "zh-Hans": "Hooks 已安装", + "hi": "Hooks स्थापित", + "es": "Hooks instalados", + "ar": "Hooks مثبتة", + "fr": "Hooks installés", + "bn": "Hooks ইনস্টল করা আছে", + "pt-BR": "Hooks instalados", + "ru": "Хуки установлены", + "id": "Hooks terpasang" + }, + "Hooks installed — open Codex and run /hooks or open Hooks in app settings to approve them": { + "zh-Hans": "Hooks 已安装 — 打开 Codex 并运行 /hooks,或在应用设置中打开 Hooks 以信任它们", + "hi": "हुक इंस्टॉल हो गए — Codex खोलें और /hooks चलाएं या उन्हें विश्वसनीय बनाने के लिए ऐप की सेटिंग में Hooks खोलें", + "es": "Hooks instalados — abre Codex y ejecuta /hooks o abre Hooks en los ajustes de la app para confiar en ellos", + "ar": "الخطافات مثبَّتة — افتح Codex ونفِّذ /hooks أو افتح Hooks في إعدادات التطبيق للموافقة عليها", + "fr": "Hooks installés — ouvre Codex et lance /hooks ou ouvre Hooks dans les réglages de l'app pour les approuver", + "bn": "হুক ইনস্টল হয়েছে — Codex খুলুন এবং /hooks চালান বা অ্যাপের সেটিংসে Hooks খুলুন বিশ্বাস করতে", + "pt-BR": "Hooks instalados — abra o Codex e execute /hooks ou abra Hooks nas configurações do app para confiar neles", + "ru": "Хуки установлены — открой Codex и выполни /hooks или открой Hooks в настройках приложения, чтобы подтвердить", + "id": "Hooks terpasang — buka Codex dan jalankan /hooks atau buka Hooks di pengaturan aplikasi untuk mempercayainya" + }, + "Hooks installed — restart Antigravity to activate them": { + "zh-Hans": "Hooks 已安装 — 重启 Antigravity 以激活", + "hi": "हुक इंस्टॉल हो गए — सक्रिय करने के लिए Antigravity रीस्टार्ट करें", + "es": "Hooks instalados — reinicia Antigravity para activarlos", + "ar": "الخطافات مثبَّتة — أعِد تشغيل Antigravity للتفعيل", + "fr": "Hooks installés — redémarre Antigravity pour les activer", + "bn": "হুক ইনস্টল হয়েছে — সক্রিয় করতে Antigravity রিস্টার্ট করুন", + "pt-BR": "Hooks instalados — reinicie o Antigravity para ativar", + "ru": "Хуки установлены — перезапусти Antigravity для активации", + "id": "Hooks terpasang — mulai ulang Antigravity untuk mengaktifkan" + }, + "Hooks installed — restart Copilot CLI to activate them": { + "zh-Hans": "Hooks 已安装 — 重启 Copilot CLI 以激活", + "hi": "हुक इंस्टॉल हो गए — सक्रिय करने के लिए Copilot CLI रीस्टार्ट करें", + "es": "Hooks instalados — reinicia Copilot CLI para activarlos", + "ar": "الخطافات مثبَّتة — أعِد تشغيل Copilot CLI للتفعيل", + "fr": "Hooks installés — redémarre Copilot CLI pour les activer", + "bn": "হুক ইনস্টল হয়েছে — সক্রিয় করতে Copilot CLI রিস্টার্ট করুন", + "pt-BR": "Hooks instalados — reinicie o Copilot CLI para ativar", + "ru": "Хуки установлены — перезапусти Copilot CLI для активации", + "id": "Hooks terpasang — mulai ulang Copilot CLI untuk mengaktifkan" + }, + "Hooks installed — restart Gemini CLI to activate them": { + "zh-Hans": "Hooks 已安装 — 重启 Gemini CLI 以激活", + "hi": "हुक इंस्टॉल हो गए — सक्रिय करने के लिए Gemini CLI रीस्टार्ट करें", + "es": "Hooks instalados — reinicia Gemini CLI para activarlos", + "ar": "الخطافات مثبَّتة — أعِد تشغيل Gemini CLI للتفعيل", + "fr": "Hooks installés — redémarre Gemini CLI pour les activer", + "bn": "হুক ইনস্টল হয়েছে — সক্রিয় করতে Gemini CLI রিস্টার্ট করুন", + "pt-BR": "Hooks instalados — reinicie o Gemini CLI para ativar", + "ru": "Хуки установлены — перезапусти Gemini CLI для активации", + "id": "Hooks terpasang — mulai ulang Gemini CLI untuk mengaktifkan" + }, + "Hooks installed — restart Muse Code to activate them": { + "zh-Hans": "Hooks 已安装 — 重启 Muse Code 以激活", + "hi": "हुक इंस्टॉल हो गए — सक्रिय करने के लिए Muse Code रीस्टार्ट करें", + "es": "Hooks instalados — reinicia Muse Code para activarlos", + "ar": "الخطافات مثبَّتة — أعِد تشغيل Muse Code للتفعيل", + "fr": "Hooks installés — redémarre Muse Code pour les activer", + "bn": "হুক ইনস্টল হয়েছে — সক্রিয় করতে Muse Code রিস্টার্ট করুন", + "pt-BR": "Hooks instalados — reinicie o Muse Code para ativar", + "ru": "Хуки установлены — перезапусти Muse Code для активации", + "id": "Hooks terpasang — mulai ulang Muse Code untuk mengaktifkan" + }, + "Hooks not installed": { + "zh-Hans": "Hooks 未安装", + "hi": "Hooks स्थापित नहीं", + "es": "Hooks no instalados", + "ar": "Hooks غير مثبتة", + "fr": "Hooks non installés", + "bn": "Hooks ইনস্টল নেই", + "pt-BR": "Hooks não instalados", + "ru": "Хуки не установлены", + "id": "Hooks belum terpasang" + }, + "Hooks outdated — update them to answer Claude's questions from the notch": { + "zh-Hans": "Hooks 已过期 — 请更新以便从刘海处回答 Claude 的问题", + "hi": "हुक पुराने हैं — नॉच से Claude के सवालों का जवाब देने के लिए अपडेट करें", + "es": "Hooks desactualizados — actualízalos para responder las preguntas de Claude desde el notch", + "ar": "الخطافات قديمة — حدِّثها للإجابة على أسئلة Claude من الشريط العلوي", + "fr": "Hooks obsolètes — mets-les à jour pour répondre aux questions de Claude depuis l'encoche", + "bn": "হুক পুরনো — নচ থেকে Claude-এর প্রশ্নের উত্তর দিতে আপডেট করুন", + "pt-BR": "Hooks desatualizados — atualize-os para responder às perguntas do Claude pelo notch", + "ru": "Хуки устарели — обнови их, чтобы отвечать на вопросы Claude из ноча", + "id": "Hooks usang — perbarui untuk menjawab pertanyaan Claude dari notch" + }, + "Hotkey": { + "zh-Hans": "快捷键", + "hi": "हॉटकी", + "es": "Tecla de acceso", + "ar": "المفتاح السريع", + "fr": "Raccourci", + "bn": "হটকি", + "pt-BR": "Tecla de atalho", + "ru": "Горячая клавиша", + "id": "Tombol pintas" + }, + "Install": { + "zh-Hans": "安装", + "hi": "इंस्टॉल करें", + "es": "Instalar", + "ar": "تثبيت", + "fr": "Installer", + "bn": "ইনস্টল করুন", + "pt-BR": "Instalar", + "ru": "Установить", + "id": "Pasang" + }, + "Install Coucou hooks in ~/.claude?": { + "zh-Hans": "是否将 Coucou Hooks 安装到 ~/.claude?", + "hi": "~/.claude में Coucou हुक इंस्टॉल करें?", + "es": "¿Instalar los hooks de Coucou en ~/.claude?", + "ar": "هل تريد تثبيت خطافات Coucou في ~/.claude؟", + "fr": "Installer les hooks Coucou dans ~/.claude ?", + "bn": "~/.claude-এ Coucou হুক ইনস্টল করবেন?", + "pt-BR": "Instalar os hooks do Coucou em ~/.claude?", + "ru": "Установить хуки Coucou в ~/.claude?", + "id": "Pasang hooks Coucou di ~/.claude?" + }, + "Install hooks": { + "zh-Hans": "安装 Hooks", + "hi": "हुक इंस्टॉल करें", + "es": "Instalar hooks", + "ar": "تثبيت الخطافات", + "fr": "Installer les hooks", + "bn": "হুক ইনস্টল করুন", + "pt-BR": "Instalar hooks", + "ru": "Установить хуки", + "id": "Pasang hooks" + }, + "Install plugin": { + "zh-Hans": "安装插件", + "hi": "प्लगइन इंस्टॉल करें", + "es": "Instalar plugin", + "ar": "تثبيت الملحق", + "fr": "Installer le plugin", + "bn": "প্লাগইন ইনস্টল করুন", + "pt-BR": "Instalar plugin", + "ru": "Установить плагин", + "id": "Pasang plugin" + }, + "Install relay": { + "zh-Hans": "安装中继", + "hi": "रिले इंस्टॉल करें", + "es": "Instalar relay", + "ar": "تثبيت المُرحِّل", + "fr": "Installer le relais", + "bn": "রিলে ইনস্টল করুন", + "pt-BR": "Instalar relay", + "ru": "Установить ретранслятор", + "id": "Pasang relay" + }, + "Integrations": { + "zh-Hans": "集成", + "hi": "एकीकरण", + "es": "Integraciones", + "ar": "التكاملات", + "fr": "Intégrations", + "bn": "ইন্টিগ্রেশন", + "pt-BR": "Integrações", + "ru": "Интеграции", + "id": "Integrasi" + }, + "Jump to terminal": { + "zh-Hans": "跳转到终端", + "hi": "टर्मिनल पर जाएं", + "es": "Saltar al terminal", + "ar": "الانتقال إلى الطرفية", + "fr": "Aller au terminal", + "bn": "টার্মিনালে যাও", + "pt-BR": "Ir para o terminal", + "ru": "Перейти в терминал", + "id": "Lompat ke terminal" + }, + "Keep a history of my coding sessions": { + "zh-Hans": "保留我的编程会话历史记录", + "hi": "मेरे कोडिंग सत्रों का इतिहास रखें", + "es": "Guardar un historial de mis sesiones de código", + "ar": "الاحتفاظ بسجل جلسات الكود", + "fr": "Enregistrer l'historique de mes sessions de code", + "bn": "আমার কোডিং সেশনের ইতিহাস রাখুন", + "pt-BR": "Manter um histórico das minhas sessões de código", + "ru": "Хранить историю сессий", + "id": "Simpan riwayat sesi kode saya" + }, + "Key configured · {model}": { + "zh-Hans": "密钥已配置 · {model}", + "hi": "की कॉन्फ़िगर हुई · {model}", + "es": "Clave configurada · {model}", + "ar": "المفتاح مُهيَّأ · {model}", + "fr": "Clé configurée · {model}", + "bn": "কী কনফিগার করা হয়েছে · {model}", + "pt-BR": "Chave configurada · {model}", + "ru": "Ключ настроен · {model}", + "id": "Kunci dikonfigurasi · {model}" + }, + "Key not configured": { + "zh-Hans": "密钥未配置", + "hi": "की कॉन्फ़िगर नहीं हुई", + "es": "Clave no configurada", + "ar": "المفتاح غير مُهيَّأ", + "fr": "Clé non configurée", + "bn": "কী কনফিগার নেই", + "pt-BR": "Chave não configurada", + "ru": "Ключ не настроен", + "id": "Kunci belum dikonfigurasi" + }, + "LM Studio disconnected.": { + "zh-Hans": "LM Studio 已断开连接。", + "hi": "LM Studio डिस्कनेक्ट हो गया।", + "es": "LM Studio desconectado.", + "ar": "تم قطع اتصال LM Studio.", + "fr": "LM Studio déconnecté.", + "bn": "LM Studio সংযোগ বিচ্ছিন্ন হয়েছে।", + "pt-BR": "LM Studio desconectado.", + "ru": "LM Studio отключён.", + "id": "LM Studio terputus." + }, + "Language": { + "zh-Hans": "语言", + "hi": "भाषा", + "es": "Idioma", + "ar": "اللغة", + "fr": "Langue", + "bn": "ভাষা", + "pt-BR": "Idioma", + "ru": "Язык", + "id": "Bahasa" + }, + "Launch at Mac startup": { + "zh-Hans": "在 Mac 启动时自动启动", + "hi": "Mac स्टार्टअप पर लॉन्च करें", + "es": "Iniciar al arrancar el Mac", + "ar": "تشغيل عند بدء تشغيل Mac", + "fr": "Lancer au démarrage du Mac", + "bn": "Mac স্টার্টআপে চালু করুন", + "pt-BR": "Iniciar ao ligar o Mac", + "ru": "Запускать при старте Mac", + "id": "Jalankan saat Mac startup" + }, + "Let my iPhone send instructions to Claude Code": { + "zh-Hans": "允许我的 iPhone 向 Claude Code 发送指令", + "hi": "मेरे iPhone को Claude Code को निर्देश भेजने दें", + "es": "Permitir que mi iPhone envíe instrucciones a Claude Code", + "ar": "السماح لـ iPhone بإرسال تعليمات إلى Claude Code", + "fr": "Laisser mon iPhone envoyer des instructions à Claude Code", + "bn": "আমার iPhone-কে Claude Code-এ নির্দেশ পাঠাতে দাও", + "pt-BR": "Deixar meu iPhone enviar instruções ao Claude Code", + "ru": "Позволять iPhone отправлять инструкции в Claude Code", + "id": "Biarkan iPhone saya mengirim instruksi ke Claude Code" + }, + "Lists": { + "zh-Hans": "列出", + "hi": "सूची", + "es": "Lista", + "ar": "يُدرج", + "fr": "Liste", + "bn": "তালিকা", + "pt-BR": "Lista", + "ru": "Перечисляет", + "id": "Daftar" + }, + "Loading models…": { + "zh-Hans": "正在加载模型…", + "hi": "मॉडल लोड हो रहे हैं…", + "es": "Cargando modelos…", + "ar": "جارٍ تحميل النماذج…", + "fr": "Chargement des modèles…", + "bn": "মডেল লোড হচ্ছে…", + "pt-BR": "Carregando modelos…", + "ru": "Загрузка моделей…", + "id": "Memuat model…" + }, + "Loading…": { + "zh-Hans": "加载中…", + "hi": "लोड हो रहा है…", + "es": "Cargando…", + "ar": "جارٍ التحميل…", + "fr": "Chargement…", + "bn": "লোড হচ্ছে…", + "pt-BR": "Carregando…", + "ru": "Загрузка…", + "id": "Memuat…" + }, + "Local models": { + "zh-Hans": "本地模型", + "hi": "स्थानीय मॉडल", + "es": "Modelos locales", + "ar": "النماذج المحلية", + "fr": "Modèles locaux", + "bn": "স্থানীয় মডেল", + "pt-BR": "Modelos locais", + "ru": "Локальные модели", + "id": "Model lokal" + }, + "Longest session": { + "zh-Hans": "最长会话", + "hi": "सबसे लंबा सेशन", + "es": "Sesión más larga", + "ar": "أطول جلسة", + "fr": "Session la plus longue", + "bn": "দীর্ঘতম সেশন", + "pt-BR": "Sessão mais longa", + "ru": "Самая долгая сессия", + "id": "Sesi terpanjang" + }, + "Mail not available.": { + "zh-Hans": "邮件不可用。", + "hi": "मेल उपलब्ध नहीं है।", + "es": "Correo no disponible.", + "ar": "البريد غير متاح.", + "fr": "Messagerie non disponible.", + "bn": "মেইল পাওয়া যাচ্ছে না।", + "pt-BR": "E-mail não disponível.", + "ru": "Почта недоступна.", + "id": "Mail tidak tersedia." + }, + "Main": { + "zh-Hans": "主要", + "hi": "मुख्य", + "es": "Principal", + "ar": "رئيسي", + "fr": "Principal", + "bn": "প্রধান", + "pt-BR": "Principal", + "ru": "Главный", + "id": "Utama" + }, + "Missing recipient.": { + "zh-Hans": "收件人缺失。", + "hi": "प्राप्तकर्ता गुम है।", + "es": "Falta el destinatario.", + "ar": "المستلم مفقود.", + "fr": "Destinataire manquant.", + "bn": "প্রাপক অনুপস্থিত।", + "pt-BR": "Destinatário ausente.", + "ru": "Получатель не указан.", + "id": "Penerima tidak ada." + }, + "Mochi to desktop on/off": { + "zh-Hans": "Mochi 桌面开/关", + "hi": "Mochi डेस्कटॉप पर चालू / बंद", + "es": "Mochi en el escritorio sí / no", + "ar": "Mochi على / إيقاف سطح المكتب", + "fr": "Mochi sur le bureau oui / non", + "bn": "Mochi ডেস্কটপে চালু / বন্ধ", + "pt-BR": "Mochi na área de trabalho sim / não", + "ru": "Mochi на рабочем столе вкл / выкл", + "id": "Mochi di desktop ya / tidak" + }, + "Model": { + "zh-Hans": "模型", + "hi": "मॉडल", + "es": "Modelo", + "ar": "النموذج", + "fr": "Modèle", + "bn": "মডেল", + "pt-BR": "Modelo", + "ru": "Модель", + "id": "Model" + }, + "Model ID (e.g. claude-sonnet-4-6)": { + "zh-Hans": "模型 ID(如 claude-sonnet-4-6)", + "hi": "मॉडल ID (जैसे claude-sonnet-4-6)", + "es": "ID del modelo (p. ej. claude-sonnet-4-6)", + "ar": "معرِّف النموذج (مثلاً claude-sonnet-4-6)", + "fr": "ID du modèle (ex. claude-sonnet-4-6)", + "bn": "মডেল ID (যেমন claude-sonnet-4-6)", + "pt-BR": "ID do modelo (ex.: claude-sonnet-4-6)", + "ru": "ID модели (напр. claude-sonnet-4-6)", + "id": "ID model (mis. claude-sonnet-4-6)" + }, + "Move Mochi to my iPhone's Dynamic Island when my Mac is locked": { + "zh-Hans": "Mac 锁定时将 Mochi 移至我 iPhone 的灵动岛", + "hi": "Mac लॉक होने पर Mochi को मेरे iPhone की Dynamic Island पर ले जाएं", + "es": "Mover a Mochi a la Dynamic Island de mi iPhone cuando mi Mac esté bloqueado", + "ar": "نقل Mochi إلى Dynamic Island في iPhone عند إغلاق Mac", + "fr": "Déplacer Mochi vers la Dynamic Island de mon iPhone quand mon Mac est verrouillé", + "bn": "Mac লক থাকলে Mochi-কে আমার iPhone-এর Dynamic Island-এ নিয়ে যান", + "pt-BR": "Mover o Mochi para a Dynamic Island do meu iPhone quando meu Mac estiver bloqueado", + "ru": "Переносить Mochi на Dynamic Island моего iPhone, когда Mac заблокирован", + "id": "Pindahkan Mochi ke Dynamic Island iPhone saya saat Mac terkunci" + }, + "Muse Code hooks": { + "zh-Hans": "Muse Code 钩子", + "hi": "Muse Code हुक", + "es": "Hooks de Muse Code", + "ar": "خطافات Muse Code", + "fr": "Hooks Muse Code", + "bn": "Muse Code হুক", + "pt-BR": "Hooks do Muse Code", + "ru": "Хуки Muse Code", + "id": "Hooks Muse Code" + }, + "Mute / unmute sounds": { + "zh-Hans": "静音/取消静音", + "hi": "ध्वनि म्यूट / अनम्यूट", + "es": "Silenciar / activar sonidos", + "ar": "كتم / إلغاء كتم الأصوات", + "fr": "Couper / rétablir les sons", + "bn": "শব্দ মিউট / আনমিউট", + "pt-BR": "Silenciar / ativar sons", + "ru": "Выключить / включить звук", + "id": "Bisukan / aktifkan suara" + }, + "My PRs": { + "zh-Hans": "我的 PR", + "hi": "मेरे PR", + "es": "Mis PRs", + "ar": "طلبات السحب", + "fr": "Mes PRs", + "bn": "আমার PR", + "pt-BR": "Meus PRs", + "ru": "Мои PR", + "id": "PR saya" + }, + "Navigate list items": { + "zh-Hans": "在列表项中导航", + "hi": "सूची आइटम नेविगेट करें", + "es": "Navegar por los elementos de la lista", + "ar": "التنقل بين عناصر القائمة", + "fr": "Naviguer dans les éléments de la liste", + "bn": "তালিকার আইটেম নেভিগেট করো", + "pt-BR": "Navegar pelos itens da lista", + "ru": "Навигация по элементам списка", + "id": "Navigasi item daftar" + }, + "New conversation": { + "zh-Hans": "新建对话", + "hi": "नई बातचीत", + "es": "Nueva conversación", + "ar": "محادثة جديدة", + "fr": "Nouvelle conversation", + "bn": "নতুন কথোপকথন", + "pt-BR": "Nova conversa", + "ru": "Новый разговор", + "id": "Percakapan baru" + }, + "New email": { + "zh-Hans": "新邮件", + "hi": "नया ईमेल", + "es": "Nuevo email", + "ar": "بريد جديد", + "fr": "Nouvel email", + "bn": "নতুন ইমেইল", + "pt-BR": "Novo email", + "ru": "Новое письмо", + "id": "Email baru" + }, + "Next": { + "zh-Hans": "下一步", + "hi": "अगला", + "es": "Siguiente", + "ar": "التالي", + "fr": "Suivant", + "bn": "পরবর্তী", + "pt-BR": "Próximo", + "ru": "Далее", + "id": "Berikutnya" + }, + "Next / previous tab": { + "zh-Hans": "下一个/上一个标签", + "hi": "अगला / पिछला टैब", + "es": "Siguiente / pestaña anterior", + "ar": "التبويب التالي / السابق", + "fr": "Onglet suivant / précédent", + "bn": "পরের / আগের ট্যাব", + "pt-BR": "Próxima / aba anterior", + "ru": "Следующая / предыдущая вкладка", + "id": "Tab berikutnya / sebelumnya" + }, + "Next tab": { + "zh-Hans": "下一个标签", + "hi": "अगला टैब", + "es": "Siguiente pestaña", + "ar": "التبويب التالي", + "fr": "Onglet suivant", + "bn": "পরের ট্যাব", + "pt-BR": "Próxima aba", + "ru": "Следующая вкладка", + "id": "Tab berikutnya" + }, + "No activity last week": { + "zh-Hans": "上周无活动", + "hi": "पिछले सप्ताह कोई गतिविधि नहीं", + "es": "Sin actividad la semana pasada", + "ar": "لا نشاط الأسبوع الماضي", + "fr": "Aucune activité la semaine dernière", + "bn": "গত সপ্তাহে কোনো কার্যক্রম নেই", + "pt-BR": "Sem atividade na semana passada", + "ru": "На прошлой неделе активности не было", + "id": "Tidak ada aktivitas minggu lalu" + }, + "No calls scheduled": { + "zh-Hans": "没有计划的通话", + "hi": "कोई कॉल निर्धारित नहीं", + "es": "Sin llamadas programadas", + "ar": "لا مكالمات مجدولة", + "fr": "Aucun appel prévu", + "bn": "কোনো কল নির্ধারিত নেই", + "pt-BR": "Nenhuma chamada agendada", + "ru": "Нет звонков", + "id": "Tidak ada panggilan terjadwal" + }, + "No changes": { + "zh-Hans": "没有变更", + "hi": "कोई परिवर्तन नहीं", + "es": "Sin cambios", + "ar": "لا تغييرات", + "fr": "Aucun changement", + "bn": "কোনো পরিবর্তন নেই", + "pt-BR": "Sem alterações", + "ru": "Нет изменений", + "id": "Tidak ada perubahan" + }, + "No contributions": { + "zh-Hans": "没有贡献", + "hi": "कोई योगदान नहीं", + "es": "Sin contribuciones", + "ar": "لا مساهمات", + "fr": "Aucune contribution", + "bn": "কোনো অবদান নেই", + "pt-BR": "Sem contribuições", + "ru": "Нет вкладов", + "id": "Tidak ada kontribusi" + }, + "No error details available.": { + "zh-Hans": "无错误详情。", + "hi": "कोई त्रुटि विवरण उपलब्ध नहीं है।", + "es": "No hay detalles del error disponibles.", + "ar": "لا توجد تفاصيل للخطأ.", + "fr": "Aucun détail d'erreur disponible.", + "bn": "কোনো ত্রুটির বিবরণ পাওয়া যায়নি।", + "pt-BR": "Nenhum detalhe de erro disponível.", + "ru": "Нет подробностей об ошибке.", + "id": "Tidak ada detail kesalahan yang tersedia." + }, + "No models yet — download one in {name} first.": { + "zh-Hans": "暂无模型 — 请先在 {name} 中下载一个。", + "hi": "अभी कोई मॉडल नहीं — पहले {name} में एक डाउनलोड करें।", + "es": "Aún no hay modelos — descarga uno en {name} primero.", + "ar": "لا نماذج حتى الآن — قم بتنزيل أحد النماذج في {name} أولاً.", + "fr": "Aucun modèle pour l'instant — télécharges-en un dans {name} d'abord.", + "bn": "এখনো কোনো মডেল নেই — প্রথমে {name}-এ একটি ডাউনলোড করুন।", + "pt-BR": "Nenhum modelo ainda — baixe um no {name} primeiro.", + "ru": "Нет моделей — скачай одну в {name} сначала.", + "id": "Belum ada model — unduh satu di {name} terlebih dahulu." + }, + "Not connected": { + "zh-Hans": "未连接", + "hi": "कनेक्ट नहीं", + "es": "No conectado", + "ar": "غير متصل", + "fr": "Non connecté", + "bn": "সংযুক্ত নয়", + "pt-BR": "Não conectado", + "ru": "Не подключено", + "id": "Tidak terhubung" + }, + "Not playing": { + "zh-Hans": "未播放", + "hi": "नहीं चल रहा", + "es": "No está reproduciendo", + "ar": "لا يعزف", + "fr": "Pas en lecture", + "bn": "চলছে না", + "pt-BR": "Não está tocando", + "ru": "Не воспроизводится", + "id": "Tidak diputar" + }, + "Notch shortcuts — active when the notch is in front": { + "zh-Hans": "岛快捷键 — 岛获得焦点时有效", + "hi": "आइलैंड शॉर्टकट — आइलैंड फोकस में होने पर सक्रिय", + "es": "Atajos del island — activos cuando el island está enfocado", + "ar": "اختصارات الشريط — نشطة عند التركيز على الشريط", + "fr": "Raccourcis de l'encoché — actifs quand l'encoché est au premier plan", + "bn": "আইল্যান্ড শর্টকাট — আইল্যান্ড ফোকাসে থাকলে সক্রিয়", + "pt-BR": "Atalhos do island — ativos quando o island está em foco", + "ru": "Сочетания нотча — работают когда нотч в фокусе", + "id": "Pintasan island — aktif saat island difokuskan" + }, + "Notebook": { + "zh-Hans": "Notebook", + "hi": "Notebook", + "es": "Notebook", + "ar": "Notebook", + "fr": "Notebook", + "bn": "নোটবুক", + "pt-BR": "Notebook", + "ru": "Notebook", + "id": "Notebook" + }, + "Nothing here": { + "zh-Hans": "这里没有内容", + "hi": "यहाँ कुछ नहीं", + "es": "Nada aquí", + "ar": "لا شيء هنا", + "fr": "Rien ici", + "bn": "এখানে কিছু নেই", + "pt-BR": "Nada aqui", + "ru": "Ничего нет", + "id": "Tidak ada apa-apa" + }, + "Nothing running right now.": { + "zh-Hans": "当前没有任务。", + "hi": "अभी कुछ नहीं चल रहा।", + "es": "Nada en ejecución.", + "ar": "لا يوجد شيء يعمل الآن.", + "fr": "Rien ne tourne pour l'instant.", + "bn": "এখন কিছু চলছে না।", + "pt-BR": "Nada em execução no momento.", + "ru": "Сейчас ничего не запущено.", + "id": "Tidak ada yang berjalan sekarang." + }, + "OK": { + "zh-Hans": "确定", + "hi": "ठीक है", + "es": "Aceptar", + "ar": "موافق", + "fr": "OK", + "bn": "ঠিক আছে", + "pt-BR": "OK", + "ru": "ОК", + "id": "OK" + }, + "Ollama disconnected.": { + "zh-Hans": "Ollama 已断开连接。", + "hi": "Ollama डिस्कनेक्ट हो गया।", + "es": "Ollama desconectado.", + "ar": "تم قطع اتصال Ollama.", + "fr": "Ollama déconnecté.", + "bn": "Ollama সংযোগ বিচ্ছিন্ন হয়েছে।", + "pt-BR": "Ollama desconectado.", + "ru": "Ollama отключён.", + "id": "Ollama terputus." + }, + "Only http:// and https:// URLs are accepted.": { + "zh-Hans": "仅支持 http:// 和 https:// URL。", + "hi": "केवल http:// और https:// URL समर्थित हैं।", + "es": "Solo se admiten URL con http:// y https://.", + "ar": "يُدعم http:// وhttps:// فقط.", + "fr": "Seules les URL http:// et https:// sont acceptées.", + "bn": "শুধুমাত্র http:// এবং https:// URL সমর্থিত।", + "pt-BR": "Apenas URLs com http:// e https:// são suportadas.", + "ru": "Поддерживаются только URL с http:// и https://.", + "id": "Hanya URL http:// dan https:// yang didukung." + }, + "Open": { + "zh-Hans": "打开", + "hi": "खोलें", + "es": "Abrir", + "ar": "فتح", + "fr": "Ouvrir", + "bn": "খুলুন", + "pt-BR": "Abrir", + "ru": "Открыть", + "id": "Buka" + }, + "Open / close current diff": { + "zh-Hans": "打开/关闭当前差异", + "hi": "वर्तमान डिफ खोलें / बंद करें", + "es": "Abrir / cerrar diff actual", + "ar": "فتح / إغلاق الفرق الحالي", + "fr": "Ouvrir / fermer le diff actuel", + "bn": "বর্তমান ডিফ খোলা / বন্ধ", + "pt-BR": "Abrir / fechar diff atual", + "ru": "Открыть / закрыть текущий diff", + "id": "Buka / tutup diff saat ini" + }, + "Open / close notch": { + "zh-Hans": "打开/关闭岛", + "hi": "आइलैंड खोलें / बंद करें", + "es": "Abrir / cerrar island", + "ar": "فتح / إغلاق الشريط", + "fr": "Ouvrir / fermer l'encoché", + "bn": "আইল্যান্ড খোলা / বন্ধ", + "pt-BR": "Abrir / fechar island", + "ru": "Открыть / закрыть нотч", + "id": "Buka / tutup island" + }, + "Open / close wardrobe": { + "zh-Hans": "打开/关闭衣橱", + "hi": "वार्डरोब खोलें / बंद करें", + "es": "Abrir / cerrar guardarropa", + "ar": "فتح / إغلاق خزانة الملابس", + "fr": "Ouvrir / fermer la garde-robe", + "bn": "পোশাক খোলা / বন্ধ", + "pt-BR": "Abrir / fechar guarda-roupa", + "ru": "Открыть / закрыть гардероб", + "id": "Buka / tutup lemari" + }, + "Open Codex": { + "zh-Hans": "打开 Codex", + "hi": "Codex खोलें", + "es": "Abrir Codex", + "ar": "فتح Codex", + "fr": "Ouvrir Codex", + "bn": "Codex খুলুন", + "pt-BR": "Abrir Codex", + "ru": "Открыть Codex", + "id": "Buka Codex" + }, + "Open Coucou": { + "zh-Hans": "打开 Coucou", + "hi": "Coucou खोलें", + "es": "Abrir Coucou", + "ar": "فتح Coucou", + "fr": "Ouvrir Coucou", + "bn": "Coucou খুলুন", + "pt-BR": "Abrir Coucou", + "ru": "Открыть Coucou", + "id": "Buka Coucou" + }, + "Open Cursor": { + "zh-Hans": "打开 Cursor", + "hi": "Cursor खोलें", + "es": "Abrir Cursor", + "ar": "فتح Cursor", + "fr": "Ouvrir Cursor", + "bn": "Cursor খুলুন", + "pt-BR": "Abrir Cursor", + "ru": "Открыть Cursor", + "id": "Buka Cursor" + }, + "Open Music": { + "zh-Hans": "打开 Music", + "hi": "Music खोलें", + "es": "Abrir Music", + "ar": "فتح Music", + "fr": "Ouvrir Music", + "bn": "Music খুলুন", + "pt-BR": "Abrir Music", + "ru": "Открыть Music", + "id": "Buka Music" + }, + "Open Settings…": { + "zh-Hans": "打开设置…", + "hi": "सेटिंग्स खोलें…", + "es": "Abrir ajustes…", + "ar": "فتح الإعدادات…", + "fr": "Ouvrir les réglages…", + "bn": "সেটিংস খুলুন…", + "pt-BR": "Abrir Configurações…", + "ru": "Открыть настройки…", + "id": "Buka Pengaturan…" + }, + "Open Visual Studio Code": { + "zh-Hans": "打开 Visual Studio Code", + "hi": "Visual Studio Code खोलें", + "es": "Abrir Visual Studio Code", + "ar": "فتح Visual Studio Code", + "fr": "Ouvrir Visual Studio Code", + "bn": "Visual Studio Code খুলুন", + "pt-BR": "Abrir Visual Studio Code", + "ru": "Открыть Visual Studio Code", + "id": "Buka Visual Studio Code" + }, + "Open chat": { + "zh-Hans": "打开聊天", + "hi": "चैट खोलें", + "es": "Abrir chat", + "ar": "فتح المحادثة", + "fr": "Ouvrir le chat", + "bn": "চ্যাট খোলো", + "pt-BR": "Abrir chat", + "ru": "Открыть чат", + "id": "Buka chat" + }, + "Open in n8n": { + "zh-Hans": "在 n8n 中打开", + "hi": "n8n में खोलें", + "es": "Abrir en n8n", + "ar": "فتح في n8n", + "fr": "Ouvrir dans n8n", + "bn": "n8n-এ খুলুন", + "pt-BR": "Abrir no n8n", + "ru": "Открыть в n8n", + "id": "Buka di n8n" + }, + "Open selected item": { + "zh-Hans": "打开选定项", + "hi": "चयनित आइटम खोलें", + "es": "Abrir elemento seleccionado", + "ar": "فتح العنصر المحدد", + "fr": "Ouvrir l'élément sélectionné", + "bn": "নির্বাচিত আইটেম খোলো", + "pt-BR": "Abrir item selecionado", + "ru": "Открыть выбранный элемент", + "id": "Buka item yang dipilih" + }, + "Open settings": { + "zh-Hans": "打开设置", + "hi": "सेटिंग्स खोलें", + "es": "Abrir Ajustes", + "ar": "فتح الإعدادات", + "fr": "Ouvrir les réglages", + "bn": "সেটিংস খোলো", + "pt-BR": "Abrir Ajustes", + "ru": "Открыть настройки", + "id": "Buka Pengaturan" + }, + "Open terminal": { + "zh-Hans": "打开终端", + "hi": "टर्मिनल खोलें", + "es": "Abrir terminal", + "ar": "فتح الطرفية", + "fr": "Ouvrir le terminal", + "bn": "টার্মিনাল খুলুন", + "pt-BR": "Abrir terminal", + "ru": "Открыть терминал", + "id": "Buka terminal" + }, + "Open {name}": { + "zh-Hans": "打开 {name}", + "hi": "{name} खोलें", + "es": "Abrir {name}", + "ar": "فتح {name}", + "fr": "Ouvrir {name}", + "bn": "{name} খুলুন", + "pt-BR": "Abrir {name}", + "ru": "Открыть {name}", + "id": "Buka {name}" + }, + "OpenAI": { + "zh-Hans": "OpenAI", + "hi": "OpenAI", + "es": "OpenAI", + "ar": "OpenAI", + "fr": "OpenAI", + "bn": "OpenAI", + "pt-BR": "OpenAI", + "ru": "OpenAI", + "id": "OpenAI" + }, + "OpenCode plugin": { + "zh-Hans": "OpenCode 插件", + "hi": "OpenCode प्लगइन", + "es": "Plugin de OpenCode", + "ar": "ملحق OpenCode", + "fr": "Plugin OpenCode", + "bn": "OpenCode প্লাগইন", + "pt-BR": "Plugin do OpenCode", + "ru": "Плагин OpenCode", + "id": "Plugin OpenCode" + }, + "Other…": { + "zh-Hans": "其他…", + "hi": "अन्य…", + "es": "Otro…", + "ar": "أخرى…", + "fr": "Autre…", + "bn": "অন্যান্য…", + "pt-BR": "Outro…", + "ru": "Другое…", + "id": "Lainnya…" + }, + "Overview": { + "zh-Hans": "概览", + "hi": "अवलोकन", + "es": "Resumen", + "ar": "نظرة عامة", + "fr": "Aperçu", + "bn": "সংক্ষিপ্ত বিবরণ", + "pt-BR": "Visão geral", + "ru": "Обзор", + "id": "Ikhtisar" + }, + "Payments": { + "zh-Hans": "付款", + "hi": "भुगतान", + "es": "Pagos", + "ar": "المدفوعات", + "fr": "Paiements", + "bn": "পেমেন্ট", + "pt-BR": "Pagamentos", + "ru": "Платежи", + "id": "Pembayaran" + }, + "Pin / unpin notch": { + "zh-Hans": "固定/取消固定岛", + "hi": "आइलैंड पिन / अनपिन", + "es": "Fijar / desfijar island", + "ar": "تثبيت / إلغاء تثبيت الشريط", + "fr": "Épingler / désépingler l'encoché", + "bn": "আইল্যান্ড পিন / আনপিন", + "pt-BR": "Fixar / soltar island", + "ru": "Закрепить / открепить нотч", + "id": "Sematkan / lepas island" + }, + "Plan usage": { + "zh-Hans": "计划用量", + "hi": "प्लान उपयोग", + "es": "Uso del plan", + "ar": "استخدام الخطة", + "fr": "Utilisation du forfait", + "bn": "প্ল্যান ব্যবহার", + "pt-BR": "Uso do plano", + "ru": "Использование плана", + "id": "Penggunaan paket" + }, + "Playing · {title}": { + "zh-Hans": "正在播放 · {title}", + "hi": "चल रहा है · {title}", + "es": "Reproduciendo · {title}", + "ar": "قيد التشغيل · {title}", + "fr": "Lecture · {title}", + "bn": "চালু আছে · {title}", + "pt-BR": "Tocando · {title}", + "ru": "Играет · {title}", + "id": "Memutar · {title}" + }, + "Plugin installed — restart Amp to activate it": { + "zh-Hans": "插件已安装 — 重启 Amp 以激活", + "hi": "प्लगइन इंस्टॉल हो गया — सक्रिय करने के लिए Amp रीस्टार्ट करें", + "es": "Plugin instalado — reinicia Amp para activarlo", + "ar": "الملحق مثبَّت — أعِد تشغيل Amp للتفعيل", + "fr": "Plugin installé — redémarre Amp pour l'activer", + "bn": "প্লাগইন ইনস্টল হয়েছে — সক্রিয় করতে Amp রিস্টার্ট করুন", + "pt-BR": "Plugin instalado — reinicie o Amp para ativar", + "ru": "Плагин установлен — перезапусти Amp для активации", + "id": "Plugin terpasang — mulai ulang Amp untuk mengaktifkan" + }, + "Plugin installed — restart OpenCode to activate it": { + "zh-Hans": "插件已安装 — 重启 OpenCode 以激活", + "hi": "प्लगइन इंस्टॉल हो गया — सक्रिय करने के लिए OpenCode रीस्टार्ट करें", + "es": "Plugin instalado — reinicia OpenCode para activarlo", + "ar": "الملحق مثبَّت — أعِد تشغيل OpenCode للتفعيل", + "fr": "Plugin installé — redémarre OpenCode pour l'activer", + "bn": "প্লাগইন ইনস্টল হয়েছে — সক্রিয় করতে OpenCode রিস্টার্ট করুন", + "pt-BR": "Plugin instalado — reinicie o OpenCode para ativar", + "ru": "Плагин установлен — перезапусти OpenCode для активации", + "id": "Plugin terpasang — mulai ulang OpenCode untuk mengaktifkan" + }, + "Plugin not installed": { + "zh-Hans": "插件未安装", + "hi": "प्लगइन इंस्टॉल नहीं है", + "es": "Plugin no instalado", + "ar": "المكوّن الإضافي غير مثبَّت", + "fr": "Plugin non installé", + "bn": "প্লাগইন ইনস্টল নেই", + "pt-BR": "Plugin não instalado", + "ru": "Плагин не установлен", + "id": "Plugin tidak terpasang" + }, + "Previous tab": { + "zh-Hans": "上一个标签", + "hi": "पिछला टैब", + "es": "Pestaña anterior", + "ar": "التبويب السابق", + "fr": "Onglet précédent", + "bn": "আগের ট্যাব", + "pt-BR": "Aba anterior", + "ru": "Предыдущая вкладка", + "id": "Tab sebelumnya" + }, + "Projects": { + "zh-Hans": "项目", + "hi": "प्रोजेक्ट", + "es": "Proyectos", + "ar": "المشاريع", + "fr": "Projets", + "bn": "প্রজেক্ট", + "pt-BR": "Projetos", + "ru": "Проекты", + "id": "Proyek" + }, + "Quit": { + "zh-Hans": "退出", + "hi": "छोड़ें", + "es": "Salir", + "ar": "إنهاء", + "fr": "Quitter", + "bn": "বাহির হন", + "pt-BR": "Sair", + "ru": "Выход", + "id": "Keluar" + }, + "Reads": { + "zh-Hans": "读取", + "hi": "पढ़ता है", + "es": "Lee", + "ar": "يقرأ", + "fr": "Lit", + "bn": "পড়ে", + "pt-BR": "Lê", + "ru": "Читает", + "id": "Membaca" + }, + "Recent": { + "zh-Hans": "最近", + "hi": "हाल का", + "es": "Reciente", + "ar": "الأخيرة", + "fr": "Récent", + "bn": "সাম্প্রতিক", + "pt-BR": "Recente", + "ru": "Недавние", + "id": "Terbaru" + }, + "Refresh": { + "zh-Hans": "刷新", + "hi": "रिफ्रेश", + "es": "Actualizar", + "ar": "تحديث", + "fr": "Actualiser", + "bn": "রিফ্রেশ", + "pt-BR": "Atualizar", + "ru": "Обновить", + "id": "Muat ulang" + }, + "Relay: installed": { + "zh-Hans": "中继:已安装", + "hi": "रिले: इंस्टॉल है", + "es": "Relay: instalado", + "ar": "المُرحِّل: مثبَّت", + "fr": "Relais : installé", + "bn": "রিলে: ইনস্টল আছে", + "pt-BR": "Relay: instalado", + "ru": "Ретранслятор: установлен", + "id": "Relay: terpasang" + }, + "Relay: not installed": { + "zh-Hans": "中继:未安装", + "hi": "रिले: इंस्टॉल नहीं है", + "es": "Relay: no instalado", + "ar": "المُرحِّل: غير مثبَّت", + "fr": "Relais : non installé", + "bn": "রিলে: ইনস্টল নেই", + "pt-BR": "Relay: não instalado", + "ru": "Ретранслятор: не установлен", + "id": "Relay: belum terpasang" + }, + "Reply in terminal": { + "zh-Hans": "在终端回复", + "hi": "टर्मिनल में उत्तर दें", + "es": "Responder en terminal", + "ar": "الرد في الطرفية", + "fr": "Répondre dans le terminal", + "bn": "টার্মিনালে উত্তর দিন", + "pt-BR": "Responder no terminal", + "ru": "Ответить в терминале", + "id": "Balas di terminal" + }, + "Resend": { + "zh-Hans": "重新发送", + "hi": "पुनः भेजें", + "es": "Reenviar", + "ar": "إعادة الإرسال", + "fr": "Renvoyer", + "bn": "পুনরায় পাঠান", + "pt-BR": "Reenviar", + "ru": "Повторить", + "id": "Kirim ulang" + }, + "Reset all to defaults": { + "zh-Hans": "全部重置为默认值", + "hi": "सभी को डिफ़ॉल्ट पर रीसेट करें", + "es": "Restablecer todo a los valores predeterminados", + "ar": "إعادة تعيين الكل إلى الافتراضي", + "fr": "Tout réinitialiser par défaut", + "bn": "সব ডিফল্টে রিসেট করো", + "pt-BR": "Redefinir tudo para os padrões", + "ru": "Сбросить всё к значениям по умолчанию", + "id": "Reset semua ke default" + }, + "Resetting…": { + "zh-Hans": "重置中…", + "hi": "रीसेट हो रहा है…", + "es": "Reiniciando…", + "ar": "جارٍ إعادة الضبط…", + "fr": "Réinitialisation…", + "bn": "রিসেট হচ্ছে…", + "pt-BR": "Reiniciando…", + "ru": "Сброс…", + "id": "Mereset…" + }, + "Restart Coucou": { + "zh-Hans": "重启 Coucou", + "hi": "Coucou को पुनः आरंभ करें", + "es": "Reiniciar Coucou", + "ar": "إعادة تشغيل Coucou", + "fr": "Redémarrer Coucou", + "bn": "Coucou পুনরায় শুরু করুন", + "pt-BR": "Reiniciar Coucou", + "ru": "Перезапустить Coucou", + "id": "Mulai ulang Coucou" + }, + "Retry": { + "zh-Hans": "重试", + "hi": "पुनः प्रयास", + "es": "Reintentar", + "ar": "إعادة المحاولة", + "fr": "Réessayer", + "bn": "পুনরায় চেষ্টা", + "pt-BR": "Tentar novamente", + "ru": "Повторить", + "id": "Coba lagi" + }, + "Review the JSON below before confirming.": { + "zh-Hans": "确认前请检查以下 JSON。", + "hi": "पुष्टि करने से पहले नीचे दिए JSON की समीक्षा करें।", + "es": "Revisa el JSON a continuación antes de confirmar.", + "ar": "راجع JSON أدناه قبل التأكيد.", + "fr": "Relis le JSON ci-dessous avant de confirmer.", + "bn": "নিশ্চিত করার আগে নীচের JSON পর্যালোচনা করুন।", + "pt-BR": "Revise o JSON abaixo antes de confirmar.", + "ru": "Просмотри JSON ниже перед подтверждением.", + "id": "Tinjau JSON di bawah sebelum mengonfirmasi." + }, + "Review the content below before confirming.": { + "zh-Hans": "确认前请检查以下内容。", + "hi": "पुष्टि करने से पहले नीचे दी गई सामग्री की समीक्षा करें।", + "es": "Revisa el contenido a continuación antes de confirmar.", + "ar": "راجع المحتوى أدناه قبل التأكيد.", + "fr": "Relis le contenu ci-dessous avant de confirmer.", + "bn": "নিশ্চিত করার আগে নীচের বিষয়বস্তু পর্যালোচনা করুন।", + "pt-BR": "Revise o conteúdo abaixo antes de confirmar.", + "ru": "Просмотри содержимое ниже перед подтверждением.", + "id": "Tinjau konten di bawah sebelum mengonfirmasi." + }, + "Runs": { + "zh-Hans": "执行", + "hi": "चलाता है", + "es": "Ejecuta", + "ar": "يُشغّل", + "fr": "Exécute", + "bn": "চালায়", + "pt-BR": "Executa", + "ru": "Запускает", + "id": "Menjalankan" + }, + "SESSIONS": { + "zh-Hans": "会话", + "hi": "सेशन", + "es": "SESIONES", + "ar": "الجلسات", + "fr": "SESSIONS", + "bn": "সেশন", + "pt-BR": "SESSÕES", + "ru": "СЕССИИ", + "id": "SESI" + }, + "Save": { + "zh-Hans": "保存", + "hi": "सहेजें", + "es": "Guardar", + "ar": "حفظ", + "fr": "Enregistrer", + "bn": "সংরক্ষণ", + "pt-BR": "Salvar", + "ru": "Сохранить", + "id": "Simpan" + }, + "Save integrations": { + "zh-Hans": "保存集成", + "hi": "इंटीग्रेशन सहेजें", + "es": "Guardar integraciones", + "ar": "حفظ التكاملات", + "fr": "Enregistrer les intégrations", + "bn": "ইন্টিগ্রেশন সংরক্ষণ করুন", + "pt-BR": "Salvar integrações", + "ru": "Сохранить интеграции", + "id": "Simpan integrasi" + }, + "Save…": { + "zh-Hans": "存储…", + "hi": "सहेजें…", + "es": "Guardar…", + "ar": "حفظ…", + "fr": "Enregistrer…", + "bn": "সংরক্ষণ করুন…", + "pt-BR": "Salvar…", + "ru": "Сохранить…", + "id": "Simpan…" + }, + "Schedule": { + "zh-Hans": "日程", + "hi": "शेड्यूल", + "es": "Horario", + "ar": "الجدول", + "fr": "Agenda", + "bn": "সময়সূচি", + "pt-BR": "Agenda", + "ru": "Расписание", + "id": "Jadwal" + }, + "Searches": { + "zh-Hans": "搜索", + "hi": "खोजता है", + "es": "Busca", + "ar": "يبحث", + "fr": "Recherche", + "bn": "খোঁজে", + "pt-BR": "Pesquisa", + "ru": "Ищет", + "id": "Mencari" + }, + "Searches the web": { + "zh-Hans": "搜索网络", + "hi": "वेब खोजता है", + "es": "Busca en la web", + "ar": "يبحث على الويب", + "fr": "Recherche web", + "bn": "ওয়েব খোঁজে", + "pt-BR": "Pesquisa na web", + "ru": "Ищет в сети", + "id": "Mencari web" + }, + "Send": { + "zh-Hans": "发送", + "hi": "भेजें", + "es": "Enviar", + "ar": "إرسال", + "fr": "Envoyer", + "bn": "পাঠান", + "pt-BR": "Enviar", + "ru": "Отправить", + "id": "Kirim" + }, + "Send by email": { + "zh-Hans": "通过邮件发送", + "hi": "ईमेल से भेजें", + "es": "Enviar por email", + "ar": "إرسال بالبريد الإلكتروني", + "fr": "Envoyer par email", + "bn": "ইমেইলে পাঠান", + "pt-BR": "Enviar por email", + "ru": "Отправить по email", + "id": "Kirim via email" + }, + "Send chat message": { + "zh-Hans": "发送聊天消息", + "hi": "चैट संदेश भेजें", + "es": "Enviar mensaje del chat", + "ar": "إرسال رسالة الدردشة", + "fr": "Envoyer le message de chat", + "bn": "চ্যাট বার্তা পাঠাও", + "pt-BR": "Enviar mensagem do chat", + "ru": "Отправить сообщение в чат", + "id": "Kirim pesan chat" + }, + "Sending…": { + "zh-Hans": "发送中…", + "hi": "भेज रहा है…", + "es": "Enviando…", + "ar": "جارٍ الإرسال…", + "fr": "Envoi en cours…", + "bn": "পাঠানো হচ্ছে…", + "pt-BR": "Enviando…", + "ru": "Отправка…", + "id": "Mengirim…" + }, + "Sends your sessions to your private iCloud for the Coucou app on iPhone. Project names, commands, and questions are encrypted with your iCloud keys. Disabling removes data from iCloud.": { + "zh-Hans": "将你的会话发送到私有 iCloud,供 Coucou iPhone 应用使用。项目名称、命令和问题使用你的 iCloud 密钥加密。关闭后将从 iCloud 中删除。", + "hi": "Coucou iPhone ऐप के लिए आपके सेशन आपके प्राइवेट iCloud में भेजता है। प्रोजेक्ट नाम, कमांड और सवाल आपकी iCloud कुंजियों से एन्क्रिप्ट किए जाते हैं। बंद करने पर iCloud से हटा दिए जाते हैं।", + "es": "Envía tus sesiones a tu iCloud privado para la app Coucou de iPhone. Los nombres de proyecto, comandos y preguntas se cifran con tus claves de iCloud. Desactivarlo las elimina de iCloud.", + "ar": "يُرسل جلساتك إلى iCloud الخاص بك لتطبيق Coucou على iPhone. تُشفَّر أسماء المشاريع والأوامر والأسئلة بمفاتيح iCloud الخاصة بك. إيقاف التشغيل يحذفها من iCloud.", + "fr": "Envoie tes sessions sur ton iCloud privé pour l'app Coucou sur iPhone. Les noms de projets, commandes et questions sont chiffrés avec tes clés iCloud. Désactiver supprime les données d'iCloud.", + "bn": "Coucou iPhone অ্যাপের জন্য আপনার সেশনগুলো আপনার প্রাইভেট iCloud-এ পাঠায়। প্রজেক্টের নাম, কমান্ড এবং প্রশ্নগুলো আপনার iCloud কী দিয়ে এনক্রিপ্ট করা হয়। বন্ধ করলে iCloud থেকে মুছে যাবে।", + "pt-BR": "Envia suas sessões para seu iCloud privado para o app Coucou no iPhone. Nomes de projetos, comandos e perguntas são criptografados com suas chaves do iCloud. Desativar remove-os do iCloud.", + "ru": "Отправляет твои сессии в личный iCloud для приложения Coucou на iPhone. Имена проектов, команды и вопросы шифруются твоими ключами iCloud. Отключение удаляет их из iCloud.", + "id": "Mengirim sesi Anda ke iCloud pribadi Anda untuk aplikasi Coucou iPhone. Nama proyek, perintah, dan pertanyaan dienkripsi dengan kunci iCloud Anda. Mematikannya menghapusnya dari iCloud." + }, + "Session finished": { + "zh-Hans": "会话已结束", + "hi": "सत्र समाप्त", + "es": "Sesión finalizada", + "ar": "انتهت الجلسة", + "fr": "Session terminée", + "bn": "সেশন শেষ", + "pt-BR": "Sessão finalizada", + "ru": "Сессия завершена", + "id": "Sesi selesai" + }, + "Settings — Coucou": { + "zh-Hans": "设置 — Coucou", + "hi": "सेटिंग्स — Coucou", + "es": "Ajustes — Coucou", + "ar": "الإعدادات — Coucou", + "fr": "Réglages — Coucou", + "bn": "সেটিংস — Coucou", + "pt-BR": "Configurações — Coucou", + "ru": "Настройки — Coucou", + "id": "Pengaturan — Coucou" + }, + "Settings…": { + "zh-Hans": "设置…", + "hi": "सेटिंग्स…", + "es": "Configuración…", + "ar": "الإعدادات…", + "fr": "Réglages…", + "bn": "সেটিংস…", + "pt-BR": "Configurações…", + "ru": "Настройки…", + "id": "Pengaturan…" + }, + "Share image": { + "zh-Hans": "分享图片", + "hi": "छवि शेयर करें", + "es": "Compartir imagen", + "ar": "مشاركة الصورة", + "fr": "Partager l'image", + "bn": "ছবি শেয়ার করুন", + "pt-BR": "Compartilhar imagem", + "ru": "Поделиться картинкой", + "id": "Bagikan gambar" + }, + "Share…": { + "zh-Hans": "分享…", + "hi": "शेयर करें…", + "es": "Compartir…", + "ar": "مشاركة…", + "fr": "Partager…", + "bn": "শেয়ার করুন…", + "pt-BR": "Compartilhar…", + "ru": "Поделиться…", + "id": "Bagikan…" + }, + "Shortcut": { + "zh-Hans": "快捷键", + "hi": "शॉर्टकट", + "es": "Atajo", + "ar": "الاختصار", + "fr": "Raccourci", + "bn": "শর্টকাট", + "pt-BR": "Atalho", + "ru": "Сочетание клавиш", + "id": "Pintasan" + }, + "Shortcuts": { + "zh-Hans": "快捷键", + "hi": "शॉर्टकट", + "es": "Atajos", + "ar": "الاختصارات", + "fr": "Raccourcis", + "bn": "শর্টকাট", + "pt-BR": "Atalhos", + "ru": "Горячие клавиши", + "id": "Pintasan" + }, + "Show approval requests in the notch": { + "zh-Hans": "在刘海中显示审批请求", + "hi": "नॉच में अनुमोदन अनुरोध दिखाएं", + "es": "Mostrar solicitudes de aprobación en el notch", + "ar": "عرض طلبات الموافقة في الشريط", + "fr": "Afficher les demandes d'approbation dans le notch", + "bn": "নচে অনুমোদন অনুরোধ দেখান", + "pt-BR": "Mostrar solicitações de aprovação no notch", + "ru": "Показывать запросы подтверждения в notch", + "id": "Tampilkan permintaan persetujuan di notch" + }, + "Show in notch": { + "zh-Hans": "在刘海中显示", + "hi": "नॉच में दिखाएं", + "es": "Mostrar en el notch", + "ar": "عرض في الشريط العلوي", + "fr": "Afficher dans l'encoche", + "bn": "নচে দেখান", + "pt-BR": "Mostrar no notch", + "ru": "Показывать в нотче", + "id": "Tampilkan di notch" + }, + "Show island with shortcut": { + "zh-Hans": "用快捷键显示岛", + "hi": "शॉर्टकट से द्वीप दिखाएं", + "es": "Mostrar isla con atajo", + "ar": "إظهار الجزيرة بالاختصار", + "fr": "Afficher l'île avec le raccourci", + "bn": "শর্টকাট দিয়ে আইল্যান্ড দেখান", + "pt-BR": "Mostrar ilha com atalho", + "ru": "Показывать остров горячей клавишей", + "id": "Tampilkan island dengan pintasan" + }, + "Show my agent sessions on my iPhone": { + "zh-Hans": "在我的 iPhone 上显示我的 Agent 会话", + "hi": "मेरे iPhone पर मेरे एजेंट सेशन दिखाएं", + "es": "Mostrar mis sesiones de agente en mi iPhone", + "ar": "عرض جلسات الوكيل على iPhone", + "fr": "Afficher mes sessions d'agent sur mon iPhone", + "bn": "আমার iPhone-এ আমার এজেন্ট সেশন দেখান", + "pt-BR": "Mostrar minhas sessões de agente no meu iPhone", + "ru": "Показывать мои сессии агента на iPhone", + "id": "Tampilkan sesi agen saya di iPhone saya" + }, + "Shows your Claude plan usage (5-hour and weekly limits) in the notch header. Coucou adds a status line relay in ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.": { + "zh-Hans": "在刘海标头中显示你的 Claude 计划用量(5 小时及每周限额)。Coucou 会在 ~/.claude/settings.json 中添加一条状态栏中继。如果你已有状态栏,它将照常运行。仅限 Pro 和 Max 计划。", + "hi": "नॉच हेडर में आपके Claude प्लान का उपयोग (5 घंटे और साप्ताहिक सीमाएं) दिखाता है। Coucou ~/.claude/settings.json में एक स्टेटस लाइन रिले जोड़ता है। अगर आपके पास पहले से स्टेटस लाइन है, तो वह पहले की तरह काम करती रहेगी। केवल Pro और Max प्लान के लिए।", + "es": "Muestra el uso de tu plan Claude (límites de 5 horas y semanales) en el encabezado del notch. Coucou añade una línea de estado relay en ~/.claude/settings.json. Si ya tienes una línea de estado, seguirá funcionando como antes. Solo planes Pro y Max.", + "ar": "يعرض استخدام خطة Claude (الحدود الخمس ساعات والأسبوعية) في ترويسة الشريط العلوي. تضيف Coucou سطر حالة مُرحِّلاً إلى ~/.claude/settings.json. إن كان لديك سطر حالة بالفعل، يستمر في العمل كالمعتاد. للخططَين Pro وMax فقط.", + "fr": "Affiche ton utilisation du forfait Claude (limites de 5 heures et hebdomadaire) dans l'en-tête de l'encoche. Coucou ajoute un relais de ligne de statut dans ~/.claude/settings.json. Si tu as déjà une ligne de statut, elle continue de fonctionner comme avant. Forfaits Pro et Max uniquement.", + "bn": "নচ হেডারে আপনার Claude প্ল্যানের ব্যবহার (৫ ঘণ্টা ও সাপ্তাহিক সীমা) দেখায়। Coucou ~/.claude/settings.json-এ একটি স্ট্যাটাস লাইন রিলে যোগ করে। আপনার কাছে ইতিমধ্যে স্ট্যাটাস লাইন থাকলে তা আগের মতোই কাজ করবে। শুধুমাত্র Pro এবং Max প্ল্যানের জন্য।", + "pt-BR": "Mostra o uso do seu plano Claude (limites de 5 horas e semanais) no cabeçalho do notch. O Coucou adiciona um relay de linha de status ao ~/.claude/settings.json. Se você já tiver uma linha de status, ela continua funcionando normalmente. Apenas planos Pro e Max.", + "ru": "Показывает использование плана Claude (лимиты на 5 часов и на неделю) в шапке нотча. Coucou добавляет ретранслятор строки статуса в ~/.claude/settings.json. Если строка статуса уже есть, она продолжит работать как прежде. Только для планов Pro и Max.", + "id": "Menampilkan penggunaan paket Claude (batas 5 jam dan mingguan) di header notch. Coucou menambahkan relay baris status ke ~/.claude/settings.json. Jika Anda sudah memiliki baris status, tetap berjalan seperti sebelumnya. Hanya untuk paket Pro dan Max." + }, + "Sound": { + "zh-Hans": "声音", + "hi": "ध्वनि", + "es": "Sonido", + "ar": "الصوت", + "fr": "Son", + "bn": "শব্দ", + "pt-BR": "Som", + "ru": "Звук", + "id": "Suara" + }, + "Startup": { + "zh-Hans": "启动", + "hi": "स्टार्टअप", + "es": "Inicio", + "ar": "بدء التشغيل", + "fr": "Démarrage", + "bn": "স্টার্টআপ", + "pt-BR": "Inicialização", + "ru": "Запуск", + "id": "Mulai" + }, + "Stop demo": { + "zh-Hans": "停止演示", + "hi": "डेमो रोकें", + "es": "Detener demo", + "ar": "إيقاف العرض", + "fr": "Arrêter la démo", + "bn": "ডেমো বন্ধ করুন", + "pt-BR": "Parar demo", + "ru": "Остановить демо", + "id": "Hentikan demo" + }, + "Stored locally on your Mac. Nothing leaves your Mac. Retained for 12 weeks.": { + "zh-Hans": "储存在 Mac 本地,不会离开你的 Mac,保留 12 周。", + "hi": "आपके Mac पर स्थानीय रूप से संग्रहीत। कुछ भी Mac नहीं छोड़ता। 12 सप्ताह तक रखा जाता है।", + "es": "Almacenado localmente en tu Mac. Nada sale de tu Mac. Retenido durante 12 semanas.", + "ar": "مخزَّن محليًا على Mac. لا يغادر أي شيء جهازك. يُحتفظ به لمدة 12 أسبوعًا.", + "fr": "Stocké localement sur ton Mac. Rien ne quitte ton Mac. Conservé pendant 12 semaines.", + "bn": "তোমার Mac-এ স্থানীয়ভাবে সংরক্ষিত। কিছু Mac ছেড়ে যায় না। ১২ সপ্তাহ ধরে রাখা হয়।", + "pt-BR": "Armazenado localmente no seu Mac. Nada sai do seu Mac. Mantido por 12 semanas.", + "ru": "Хранится локально на Mac. Ничего не покидает Mac. Хранится 12 недель.", + "id": "Disimpan secara lokal di Mac. Tidak ada yang keluar dari Mac Anda. Disimpan selama 12 minggu." + }, + "Subject": { + "zh-Hans": "主题", + "hi": "विषय", + "es": "Asunto", + "ar": "الموضوع", + "fr": "Objet", + "bn": "বিষয়", + "pt-BR": "Assunto", + "ru": "Тема", + "id": "Subjek" + }, + "Success": { + "zh-Hans": "成功", + "hi": "सफलता", + "es": "Éxito", + "ar": "نجاح", + "fr": "Réussi", + "bn": "সফল", + "pt-BR": "Sucesso", + "ru": "Успешно", + "id": "Berhasil" + }, + "System": { + "zh-Hans": "系统", + "hi": "सिस्टम", + "es": "Sistema", + "ar": "النظام", + "fr": "Système", + "bn": "সিস্টেম", + "pt-BR": "Sistema", + "ru": "Системный", + "id": "Sistem" + }, + "System conflict": { + "zh-Hans": "系统冲突", + "hi": "सिस्टम कनफ्लिक्ट", + "es": "Conflicto del sistema", + "ar": "تعارض مع النظام", + "fr": "Conflit système", + "bn": "সিস্টেম কনফ্লিক্ট", + "pt-BR": "Conflito do sistema", + "ru": "Системный конфликт", + "id": "Konflik sistem" + }, + "TIME CODING": { + "zh-Hans": "编程时长", + "hi": "कोडिंग समय", + "es": "TIEMPO PROGRAMANDO", + "ar": "وقت البرمجة", + "fr": "TEMPS DE CODE", + "bn": "কোডিং সময়", + "pt-BR": "TEMPO PROGRAMANDO", + "ru": "ВРЕМЯ КОДИНГА", + "id": "WAKTU PEMROGRAMAN" + }, + "Tasks": { + "zh-Hans": "任务", + "hi": "कार्य", + "es": "Tareas", + "ar": "مهام", + "fr": "Tâches", + "bn": "কার্য", + "pt-BR": "Tarefas", + "ru": "Задачи", + "id": "Tugas" + }, + "Tests": { + "zh-Hans": "测试", + "hi": "परीक्षण करता है", + "es": "Prueba", + "ar": "يختبر", + "fr": "Teste", + "bn": "পরীক্ষা করে", + "pt-BR": "Testa", + "ru": "Тестирует", + "id": "Menguji" + }, + "To": { + "zh-Hans": "收件人", + "hi": "प्रति", + "es": "Para", + "ar": "إلى", + "fr": "À", + "bn": "প্রতি", + "pt-BR": "Para", + "ru": "Кому", + "id": "Kepada" + }, + "To review": { + "zh-Hans": "待审查", + "hi": "समीक्षा के लिए", + "es": "Para revisar", + "ar": "للمراجعة", + "fr": "À relire", + "bn": "পর্যালোচনার জন্য", + "pt-BR": "Para revisar", + "ru": "На ревью", + "id": "Untuk ditinjau" + }, + "To use Google Gemini or OpenAI from the chat. Keys are stored in the Keychain.": { + "zh-Hans": "用于在聊天中使用 Google Gemini 或 OpenAI。密钥存储在 Keychain 中。", + "hi": "चैट से Google Gemini या OpenAI उपयोग करने के लिए। कुंजियां Keychain में संग्रहीत की जाती हैं।", + "es": "Para usar Google Gemini u OpenAI desde el chat. Las claves se guardan en el Keychain.", + "ar": "لاستخدام Google Gemini أو OpenAI من الدردشة. تُحفظ المفاتيح في Keychain.", + "fr": "Pour utiliser Google Gemini ou OpenAI depuis le chat. Les clés sont stockées dans le Trousseau.", + "bn": "চ্যাট থেকে Google Gemini বা OpenAI ব্যবহার করতে। কীগুলো Keychain-এ সংরক্ষিত হয়।", + "pt-BR": "Para usar o Google Gemini ou OpenAI pelo chat. As chaves são armazenadas no Keychain.", + "ru": "Для использования Google Gemini или OpenAI в чате. Ключи хранятся в Keychain.", + "id": "Untuk menggunakan Google Gemini atau OpenAI dari chat. Kunci disimpan di Keychain." + }, + "Too many hits at once.": { + "zh-Hans": "一次点击太多次了。", + "hi": "एक बार में बहुत ज़्यादा टैप।", + "es": "Demasiados golpes a la vez.", + "ar": "عدد كبير من النقرات دفعة واحدة.", + "fr": "Trop de clics d'un coup.", + "bn": "একসাথে অনেক বেশি ক্লিক।", + "pt-BR": "Muitas batidas de uma vez.", + "ru": "Слишком много нажатий подряд.", + "id": "Terlalu banyak ketukan sekaligus." + }, + "Top agent": { + "zh-Hans": "最活跃 Agent", + "hi": "शीर्ष एजेंट", + "es": "Agente principal", + "ar": "أفضل وكيل", + "fr": "Agent principal", + "bn": "শীর্ষ এজেন্ট", + "pt-BR": "Agente principal", + "ru": "Топ агент", + "id": "Agen teratas" + }, + "Top project": { + "zh-Hans": "最活跃项目", + "hi": "शीर्ष प्रोजेक्ट", + "es": "Proyecto principal", + "ar": "أفضل مشروع", + "fr": "Projet principal", + "bn": "শীর্ষ প্রজেক্ট", + "pt-BR": "Projeto principal", + "ru": "Топ проект", + "id": "Proyek teratas" + }, + "Try demo mode": { + "zh-Hans": "试用演示模式", + "hi": "डेमो मोड आज़माएँ", + "es": "Probar el modo demo", + "ar": "تجربة وضع العرض", + "fr": "Essayer la démo", + "bn": "ডেমো মোড চেষ্টা করুন", + "pt-BR": "Experimentar modo demo", + "ru": "Попробовать демо", + "id": "Coba mode demo" + }, + "Uninstall": { + "zh-Hans": "卸载", + "hi": "अनइंस्टॉल करें", + "es": "Desinstalar", + "ar": "إلغاء التثبيت", + "fr": "Désinstaller", + "bn": "আনইনস্টল করুন", + "pt-BR": "Desinstalar", + "ru": "Удалить", + "id": "Hapus" + }, + "Uninstall relay": { + "zh-Hans": "卸载中继", + "hi": "रिले अनइंस्टॉल करें", + "es": "Desinstalar relay", + "ar": "إلغاء تثبيت المُرحِّل", + "fr": "Désinstaller le relais", + "bn": "রিলে আনইনস্টল করুন", + "pt-BR": "Desinstalar relay", + "ru": "Удалить ретранслятор", + "id": "Hapus relay" + }, + "Unknown": { + "zh-Hans": "未知", + "hi": "अज्ञात", + "es": "Desconocido", + "ar": "غير معروف", + "fr": "Inconnu", + "bn": "অজানা", + "pt-BR": "Desconhecido", + "ru": "Неизвестно", + "id": "Tidak diketahui" + }, + "Update hooks": { + "zh-Hans": "更新 Hooks", + "hi": "हुक अपडेट करें", + "es": "Actualizar hooks", + "ar": "تحديث الخطافات", + "fr": "Mettre à jour les hooks", + "bn": "হুক আপডেট করুন", + "pt-BR": "Atualizar hooks", + "ru": "Обновить хуки", + "id": "Perbarui hooks" + }, + "Uploading {name}": { + "zh-Hans": "正在上传 {name}", + "hi": "{name} अपलोड हो रहा है", + "es": "Subiendo {name}", + "ar": "جارٍ رفع {name}", + "fr": "Envoi de {name} en cours", + "bn": "{name} আপলোড হচ্ছে", + "pt-BR": "Enviando {name}", + "ru": "Загружается {name}", + "id": "Mengunggah {name}" + }, + "Used by the chat. The list comes from your Anthropic account.": { + "zh-Hans": "由聊天使用。列表来自你的 Anthropic 账户。", + "hi": "चैट द्वारा उपयोग किया जाता है। सूची आपके Anthropic खाते से आती है।", + "es": "Lo usa el chat. La lista proviene de tu cuenta de Anthropic.", + "ar": "يستخدمه الدردشة. تأتي القائمة من حساب Anthropic الخاص بك.", + "fr": "Utilisé par le chat. La liste vient de ton compte Anthropic.", + "bn": "চ্যাটে ব্যবহৃত হয়। তালিকা আপনার Anthropic অ্যাকাউন্ট থেকে আসে।", + "pt-BR": "Usado pelo chat. A lista vem da sua conta Anthropic.", + "ru": "Используется чатом. Список берётся из твоего аккаунта Anthropic.", + "id": "Digunakan oleh chat. Daftar berasal dari akun Anthropic Anda." + }, + "Volume": { + "zh-Hans": "音量", + "hi": "वॉल्यूम", + "es": "Volumen", + "ar": "مستوى الصوت", + "fr": "Volume", + "bn": "ভলিউম", + "pt-BR": "Volume", + "ru": "Громкость", + "id": "Volume" + }, + "Waiting for a response from Claude Code": { + "zh-Hans": "等待 Claude Code 回复", + "hi": "Claude Code के जवाब का इंतजार", + "es": "Esperando respuesta de Claude Code", + "ar": "في انتظار رد من Claude Code", + "fr": "En attente d'une réponse de Claude Code", + "bn": "Claude Code-এর উত্তরের অপেক্ষায়", + "pt-BR": "Aguardando resposta do Claude Code", + "ru": "Ожидание ответа Claude Code", + "id": "Menunggu balasan Claude Code" + }, + "Wardrobe": { + "zh-Hans": "衣橱", + "hi": "अलमारी", + "es": "Guardarropa", + "ar": "خزانة الملابس", + "fr": "Garde-robe", + "bn": "পোশাক ঘর", + "pt-BR": "Guarda-roupa", + "ru": "Гардероб", + "id": "Lemari" + }, + "Watching {count} of {total}": { + "zh-Hans": "监控 {count} / {total}", + "hi": "{count} में से {total} देख रहे हैं", + "es": "Viendo {count} de {total}", + "ar": "مشاهدة {count} من {total}", + "fr": "{count} sur {total} surveillés", + "bn": "{count} টির মধ্যে {total} টি দেখছেন", + "pt-BR": "Observando {count} de {total}", + "ru": "Наблюдение: {count} из {total}", + "id": "Melihat {count} dari {total}" + }, + "Week": { + "zh-Hans": "一周", + "hi": "सप्ताह", + "es": "Semana", + "ar": "أسبوع", + "fr": "Semaine", + "bn": "সপ্তাহ", + "pt-BR": "Semana", + "ru": "Неделя", + "id": "Minggu" + }, + "Weekly recap": { + "zh-Hans": "每周回顾", + "hi": "साप्ताहिक रिकैप", + "es": "Resumen semanal", + "ar": "الملخص الأسبوعي", + "fr": "Récap de la semaine", + "bn": "সাপ্তাহিক রিক্যাপ", + "pt-BR": "Resumo semanal", + "ru": "Итоги недели", + "id": "Rekap mingguan" + }, + "What do you want to do with it?": { + "zh-Hans": "您想对它做什么?", + "hi": "आप इसके साथ क्या करना चाहते हैं?", + "es": "¿Qué quieres hacer con él?", + "ar": "ماذا تريد أن تفعل به؟", + "fr": "Que veux-tu faire avec ?", + "bn": "আপনি এটা দিয়ে কী করতে চান?", + "pt-BR": "O que você quer fazer com ele?", + "ru": "Что вы хотите с ним сделать?", + "id": "Apa yang ingin Anda lakukan dengannya?" + }, + "Workflow stopped.": { + "zh-Hans": "工作流已停止。", + "hi": "वर्कफ़्लो रुक गया।", + "es": "Flujo de trabajo detenido.", + "ar": "توقف سير العمل.", + "fr": "Workflow arrêté.", + "bn": "ওয়ার্কফ্লো বন্ধ হয়ে গেছে।", + "pt-BR": "Fluxo de trabalho parado.", + "ru": "Рабочий процесс остановлен.", + "id": "Alur kerja dihentikan." + }, + "Workflows": { + "zh-Hans": "工作流", + "hi": "वर्कफ़्लो", + "es": "Flujos de trabajo", + "ar": "سير العمل", + "fr": "Workflows", + "bn": "ওয়ার্কফ্লো", + "pt-BR": "Workflows", + "ru": "Рабочие процессы", + "id": "Alur kerja" + }, + "Writes": { + "zh-Hans": "写入", + "hi": "लिखता है", + "es": "Escribe", + "ar": "يكتب", + "fr": "Écrit", + "bn": "লেখে", + "pt-BR": "Escreve", + "ru": "Записывает", + "id": "Menulis" + }, + "Writes:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (backup created first)": { + "zh-Hans": "将写入:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json(先创建备份)", + "hi": "लिखेगा:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (पहले बैकअप बनाया जाएगा)", + "es": "Escribirá:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (se crea una copia de seguridad primero)", + "ar": "سيكتب:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (يُنشئ نسخة احتياطية أولاً)", + "fr": "Écrira :\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (sauvegarde créée d'abord)", + "bn": "লিখবে:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (আগে ব্যাকআপ তৈরি হবে)", + "pt-BR": "Vai escrever:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (backup criado antes)", + "ru": "Запишет:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (резервная копия создаётся сначала)", + "id": "Akan menulis:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (backup dibuat terlebih dahulu)" + }, + "Your answer…": { + "zh-Hans": "您的答案…", + "hi": "आपका उत्तर…", + "es": "Tu respuesta…", + "ar": "إجابتك…", + "fr": "Ta réponse…", + "bn": "আপনার উত্তর…", + "pt-BR": "Sua resposta…", + "ru": "Твой ответ…", + "id": "Jawaban Anda…" + }, + "coding": { + "zh-Hans": "编程", + "hi": "कोडिंग", + "es": "programando", + "ar": "برمجة", + "fr": "de code", + "bn": "কোডিং", + "pt-BR": "programando", + "ru": "программирования", + "id": "pemrograman" + }, + "commands": { + "zh-Hans": "条命令", + "hi": "कमांड", + "es": "comandos", + "ar": "أوامر", + "fr": "commandes", + "bn": "কমান্ড", + "pt-BR": "comandos", + "ru": "команды", + "id": "perintah" + }, + "file": { + "zh-Hans": "个文件", + "hi": "फ़ाइल", + "es": "archivo", + "ar": "ملف", + "fr": "fichier", + "bn": "ফাইল", + "pt-BR": "arquivo", + "ru": "файл", + "id": "berkas" + }, + "files": { + "zh-Hans": "个文件", + "hi": "फ़ाइलें", + "es": "archivos", + "ar": "ملفات", + "fr": "fichiers", + "bn": "ফাইল", + "pt-BR": "arquivos", + "ru": "файлы", + "id": "berkas" + }, + "in {h} h {m}": { + "zh-Hans": "{h} 小时 {m} 分后", + "hi": "{h} घंटे {m} मिनट में", + "es": "en {h} h {m}", + "ar": "خلال {h} س {m} د", + "fr": "dans {h} h {m}", + "bn": "{h} ঘণ্টা {m} মিনিটে", + "pt-BR": "em {h} h {m}", + "ru": "через {h} ч {m} мин", + "id": "dalam {h} j {m}" + }, + "in {m} min": { + "zh-Hans": "{m} 分钟后", + "hi": "{m} मिनट में", + "es": "en {m} min", + "ar": "خلال {m} دقيقة", + "fr": "dans {m} min", + "bn": "{m} মিনিটে", + "pt-BR": "em {m} min", + "ru": "через {m} мин", + "id": "dalam {m} menit" + }, + "just now": { + "zh-Hans": "刚刚", + "hi": "अभी", + "es": "justo ahora", + "ar": "للتو", + "fr": "à l'instant", + "bn": "এইমাত্র", + "pt-BR": "agora há pouco", + "ru": "только что", + "id": "baru saja" + }, + "min without movement": { + "zh-Hans": "分钟无动作", + "hi": "मिनट बिना हलचल", + "es": "min sin movimiento", + "ar": "دقيقة بدون حركة", + "fr": "min sans mouvement", + "bn": "মিনিট নড়াচড়া ছাড়া", + "pt-BR": "min sem movimento", + "ru": "мин без активности", + "id": "mnt tanpa gerakan" + }, + "needs permission": { + "zh-Hans": "需要权限", + "hi": "अनुमति चाहिए", + "es": "necesita permiso", + "ar": "يحتاج إذناً", + "fr": "a besoin d'une permission", + "bn": "অনুমতি প্রয়োজন", + "pt-BR": "precisa de permissão", + "ru": "требует разрешения", + "id": "butuh izin" + }, + "presses this → island opens": { + "zh-Hans": "按此键 → 岛打开", + "hi": "दबाने पर → द्वीप खुलता है", + "es": "presiona esto → se abre la isla", + "ar": "اضغط هذا → تفتح الجزيرة", + "fr": "appuie → l'île s'ouvre", + "bn": "এটি চাপলে → আইল্যান্ড খোলে", + "pt-BR": "pressione → a ilha abre", + "ru": "нажми → остров открывается", + "id": "tekan ini → island terbuka" + }, + "s inactive": { + "zh-Hans": "秒无活动", + "hi": "से. निष्क्रिय", + "es": "s inactivo", + "ar": "ث بدون نشاط", + "fr": "s d'inactivité", + "bn": "সে. নিষ্ক্রিয়", + "pt-BR": "s inativo", + "ru": "с без активности", + "id": "dtk tidak aktif" + }, + "session": { + "zh-Hans": "个会话", + "hi": "सेशन", + "es": "sesión", + "ar": "جلسة", + "fr": "session", + "bn": "সেশন", + "pt-BR": "sessão", + "ru": "сессия", + "id": "sesi" + }, + "sessions": { + "zh-Hans": "个会话", + "hi": "सेशन", + "es": "sesiones", + "ar": "جلسات", + "fr": "sessions", + "bn": "সেশন", + "pt-BR": "sessões", + "ru": "сессии", + "id": "sesi" + }, + "with": { + "zh-Hans": "附带", + "hi": "के साथ", + "es": "con", + "ar": "مع", + "fr": "avec", + "bn": "সহ", + "pt-BR": "com", + "ru": "с", + "id": "dengan" + }, + "with {name}": { + "zh-Hans": "附件 {name}", + "hi": "{name} के साथ", + "es": "con {name}", + "ar": "مع {name}", + "fr": "avec {name}", + "bn": "{name}-এর সাথে", + "pt-BR": "com {name}", + "ru": "с {name}", + "id": "dengan {name}" + }, + "{count}/4 slots used": { + "zh-Hans": "已用 {count}/4 个插槽", + "hi": "{count}/4 स्लॉट उपयोग में", + "es": "{count}/4 ranuras usadas", + "ar": "{count}/4 فتحات مستخدمة", + "fr": "{count}/4 emplacements utilisés", + "bn": "{count}/4 স্লট ব্যবহৃত", + "pt-BR": "{count}/4 slots usados", + "ru": "{count}/4 слотов занято", + "id": "{count}/4 slot digunakan" + }, + "{name} is ready.": { + "zh-Hans": "{name} 已就绪。", + "hi": "{name} तैयार है।", + "es": "{name} está listo.", + "ar": "{name} جاهز.", + "fr": "{name} est prêt.", + "bn": "{name} প্রস্তুত।", + "pt-BR": "{name} está pronto.", + "ru": "{name} готов.", + "id": "{name} siap." + }, + "{n} h ago": { + "zh-Hans": "{n} 小时前", + "hi": "{n} घंटे पहले", + "es": "hace {n} h", + "ar": "منذ {n} ساعة", + "fr": "il y a {n} h", + "bn": "{n} ঘণ্টা আগে", + "pt-BR": "há {n} h", + "ru": "{n} ч назад", + "id": "{n} jam lalu" + }, + "{n} min ago": { + "zh-Hans": "{n} 分钟前", + "hi": "{n} मिनट पहले", + "es": "hace {n} min", + "ar": "منذ {n} دقيقة", + "fr": "il y a {n} min", + "bn": "{n} মিনিট আগে", + "pt-BR": "há {n} min", + "ru": "{n} мин назад", + "id": "{n} menit lalu" + }, + "✓ Amp plugin installed in ~/.config/amp/plugins/coucou.ts": { + "zh-Hans": "✓ Amp 插件已安装至 ~/.config/amp/plugins/coucou.ts", + "hi": "✓ ~/.config/amp/plugins/coucou.ts में Amp प्लगइन इंस्टॉल हुआ", + "es": "✓ Plugin de Amp instalado en ~/.config/amp/plugins/coucou.ts", + "ar": "✓ ملحق Amp مثبَّت في ~/.config/amp/plugins/coucou.ts", + "fr": "✓ Plugin Amp installé dans ~/.config/amp/plugins/coucou.ts", + "bn": "✓ ~/.config/amp/plugins/coucou.ts-এ Amp প্লাগইন ইনস্টল হয়েছে", + "pt-BR": "✓ Plugin do Amp instalado em ~/.config/amp/plugins/coucou.ts", + "ru": "✓ Плагин Amp установлен в ~/.config/amp/plugins/coucou.ts", + "id": "✓ Plugin Amp terpasang di ~/.config/amp/plugins/coucou.ts" + }, + "✓ Amp plugin removed.": { + "zh-Hans": "✓ Amp 插件已移除。", + "hi": "✓ Amp प्लगइन हटा दिया गया।", + "es": "✓ Plugin de Amp eliminado.", + "ar": "✓ تمت إزالة ملحق Amp.", + "fr": "✓ Plugin Amp supprimé.", + "bn": "✓ Amp প্লাগইন সরানো হয়েছে।", + "pt-BR": "✓ Plugin do Amp removido.", + "ru": "✓ Плагин Amp удалён.", + "id": "✓ Plugin Amp dihapus." + }, + "✓ Antigravity hooks installed in ~/.gemini/config/hooks.json": { + "zh-Hans": "✓ Antigravity Hooks 已安装至 ~/.gemini/config/hooks.json", + "hi": "✓ ~/.gemini/config/hooks.json में Antigravity हुक इंस्टॉल हुए", + "es": "✓ Hooks de Antigravity instalados en ~/.gemini/config/hooks.json", + "ar": "✓ خطافات Antigravity مثبَّتة في ~/.gemini/config/hooks.json", + "fr": "✓ Hooks Antigravity installés dans ~/.gemini/config/hooks.json", + "bn": "✓ ~/.gemini/config/hooks.json-এ Antigravity হুক ইনস্টল হয়েছে", + "pt-BR": "✓ Hooks do Antigravity instalados em ~/.gemini/config/hooks.json", + "ru": "✓ Хуки Antigravity установлены в ~/.gemini/config/hooks.json", + "id": "✓ Hooks Antigravity terpasang di ~/.gemini/config/hooks.json" + }, + "✓ Antigravity hooks removed.": { + "zh-Hans": "✓ Antigravity Hooks 已移除。", + "hi": "✓ Antigravity हुक हटा दिए गए।", + "es": "✓ Hooks de Antigravity eliminados.", + "ar": "✓ تمت إزالة خطافات Antigravity.", + "fr": "✓ Hooks Antigravity supprimés.", + "bn": "✓ Antigravity হুক সরানো হয়েছে।", + "pt-BR": "✓ Hooks do Antigravity removidos.", + "ru": "✓ Хуки Antigravity удалены.", + "id": "✓ Hooks Antigravity dihapus." + }, + "✓ Codex hooks installed — run /hooks in Codex or open Hooks in app settings to approve them.": { + "zh-Hans": "✓ Codex Hooks 已安装 — 在 Codex 中运行 /hooks,或在应用设置中打开 Hooks 以信任它们。", + "hi": "✓ Codex हुक इंस्टॉल हो गए — Codex में /hooks चलाएं या उन्हें विश्वसनीय बनाने के लिए ऐप की सेटिंग में Hooks खोलें।", + "es": "✓ Hooks de Codex instalados — ejecuta /hooks en Codex o abre Hooks en los ajustes de la app para confiar en ellos.", + "ar": "✓ خطافات Codex مثبَّتة — نفِّذ /hooks في Codex أو افتح Hooks في إعدادات التطبيق للموافقة عليها.", + "fr": "✓ Hooks Codex installés — lance /hooks dans Codex ou ouvre Hooks dans les réglages de l'app pour les approuver.", + "bn": "✓ Codex হুক ইনস্টল হয়েছে — Codex-এ /hooks চালান বা সেগুলো বিশ্বাস করতে অ্যাপের সেটিংসে Hooks খুলুন।", + "pt-BR": "✓ Hooks do Codex instalados — execute /hooks no Codex ou abra Hooks nas configurações do app para confiar neles.", + "ru": "✓ Хуки Codex установлены — выполни /hooks в Codex или открой Hooks в настройках приложения, чтобы подтвердить.", + "id": "✓ Hooks Codex terpasang — jalankan /hooks di Codex atau buka Hooks di pengaturan aplikasi untuk mempercayainya." + }, + "✓ Codex hooks removed.": { + "zh-Hans": "✓ Codex Hooks 已移除。", + "hi": "✓ Codex हुक हटा दिए गए।", + "es": "✓ Hooks de Codex eliminados.", + "ar": "✓ تمت إزالة خطافات Codex.", + "fr": "✓ Hooks Codex supprimés.", + "bn": "✓ Codex হুক সরানো হয়েছে।", + "pt-BR": "✓ Hooks do Codex removidos.", + "ru": "✓ Хуки Codex удалены.", + "id": "✓ Hooks Codex dihapus." + }, + "✓ Connected · {count} models": { + "zh-Hans": { + "one": "✓ 已连接 · {count} 个模型", + "other": "✓ 已连接 · {count} 个模型" + }, + "hi": { + "one": "✓ कनेक्ट हो गया · {count} मॉडल", + "other": "✓ कनेक्ट हो गया · {count} मॉडल" + }, + "es": { + "one": "✓ Conectado · {count} modelo", + "other": "✓ Conectado · {count} modelos" + }, + "ar": { + "one": "✓ متصل · نموذج {count}", + "other": "✓ متصل · {count} نماذج" + }, + "fr": { + "one": "✓ Connecté · {count} modèle", + "other": "✓ Connecté · {count} modèles" + }, + "bn": { + "one": "✓ সংযুক্ত · {count} টি মডেল", + "other": "✓ সংযুক্ত · {count} টি মডেল" + }, + "pt-BR": { + "one": "✓ Conectado · {count} modelo", + "other": "✓ Conectado · {count} modelos" + }, + "ru": { + "one": "✓ Подключено · {count} модель", + "other": "✓ Подключено · {count} модели" + }, + "id": { + "one": "✓ Terhubung · {count} model", + "other": "✓ Terhubung · {count} model" + }, + "en": { + "one": "✓ Connected · {count} model", + "other": "✓ Connected · {count} models" + } + }, + "✓ Copilot CLI hooks installed in ~/.copilot/hooks/coucou.json": { + "zh-Hans": "✓ Copilot CLI Hooks 已安装至 ~/.copilot/hooks/coucou.json", + "hi": "✓ ~/.copilot/hooks/coucou.json में Copilot CLI हुक इंस्टॉल हुए", + "es": "✓ Hooks de Copilot CLI instalados en ~/.copilot/hooks/coucou.json", + "ar": "✓ خطافات Copilot CLI مثبَّتة في ~/.copilot/hooks/coucou.json", + "fr": "✓ Hooks Copilot CLI installés dans ~/.copilot/hooks/coucou.json", + "bn": "✓ ~/.copilot/hooks/coucou.json-এ Copilot CLI হুক ইনস্টল হয়েছে", + "pt-BR": "✓ Hooks do Copilot CLI instalados em ~/.copilot/hooks/coucou.json", + "ru": "✓ Хуки Copilot CLI установлены в ~/.copilot/hooks/coucou.json", + "id": "✓ Hooks Copilot CLI terpasang di ~/.copilot/hooks/coucou.json" + }, + "✓ Copilot CLI hooks removed.": { + "zh-Hans": "✓ Copilot CLI Hooks 已移除。", + "hi": "✓ Copilot CLI हुक हटा दिए गए।", + "es": "✓ Hooks de Copilot CLI eliminados.", + "ar": "✓ تمت إزالة خطافات Copilot CLI.", + "fr": "✓ Hooks Copilot CLI supprimés.", + "bn": "✓ Copilot CLI হুক সরানো হয়েছে।", + "pt-BR": "✓ Hooks do Copilot CLI removidos.", + "ru": "✓ Хуки Copilot CLI удалены.", + "id": "✓ Hooks Copilot CLI dihapus." + }, + "✓ Gemini CLI hooks installed in ~/.gemini/settings.json": { + "zh-Hans": "✓ Gemini CLI Hooks 已安装至 ~/.gemini/settings.json", + "hi": "✓ ~/.gemini/settings.json में Gemini CLI हुक इंस्टॉल हुए", + "es": "✓ Hooks de Gemini CLI instalados en ~/.gemini/settings.json", + "ar": "✓ خطافات Gemini CLI مثبَّتة في ~/.gemini/settings.json", + "fr": "✓ Hooks Gemini CLI installés dans ~/.gemini/settings.json", + "bn": "✓ ~/.gemini/settings.json-এ Gemini CLI হুক ইনস্টল হয়েছে", + "pt-BR": "✓ Hooks do Gemini CLI instalados em ~/.gemini/settings.json", + "ru": "✓ Хуки Gemini CLI установлены в ~/.gemini/settings.json", + "id": "✓ Hooks Gemini CLI terpasang di ~/.gemini/settings.json" + }, + "✓ Gemini CLI hooks removed.": { + "zh-Hans": "✓ Gemini CLI Hooks 已移除。", + "hi": "✓ Gemini CLI हुक हटा दिए गए।", + "es": "✓ Hooks de Gemini CLI eliminados.", + "ar": "✓ تمت إزالة خطافات Gemini CLI.", + "fr": "✓ Hooks Gemini CLI supprimés.", + "bn": "✓ Gemini CLI হুক সরানো হয়েছে।", + "pt-BR": "✓ Hooks do Gemini CLI removidos.", + "ru": "✓ Хуки Gemini CLI удалены.", + "id": "✓ Hooks Gemini CLI dihapus." + }, + "✓ Google key saved.": { + "zh-Hans": "✓ Google 密钥已保存。", + "hi": "✓ Google कुंजी सहेजी गई।", + "es": "✓ Clave de Google guardada.", + "ar": "✓ تم حفظ مفتاح Google.", + "fr": "✓ Clé Google enregistrée.", + "bn": "✓ Google কী সংরক্ষিত হয়েছে।", + "pt-BR": "✓ Chave do Google salva.", + "ru": "✓ Ключ Google сохранён.", + "id": "✓ Kunci Google tersimpan." + }, + "✓ Hermes plugin installed": { + "zh-Hans": "✓ Hermes 插件已安装", + "hi": "✓ Hermes प्लगइन इंस्टॉल हुआ", + "es": "✓ Plugin de Hermes instalado", + "ar": "✓ ملحق Hermes مثبَّت", + "fr": "✓ Plugin Hermes installé", + "bn": "✓ Hermes প্লাগইন ইনস্টল হয়েছে", + "pt-BR": "✓ Plugin do Hermes instalado", + "ru": "✓ Плагин Hermes установлен", + "id": "✓ Plugin Hermes terpasang" + }, + "✓ Hermes plugin installed in ~/.hermes/plugins/coucou/": { + "zh-Hans": "✓ Hermes 插件已安装至 ~/.hermes/plugins/coucou/", + "hi": "✓ Hermes प्लगइन ~/.hermes/plugins/coucou/ में इंस्टॉल हुआ", + "es": "✓ Plugin de Hermes instalado en ~/.hermes/plugins/coucou/", + "ar": "✓ ملحق Hermes مثبَّت في ~/.hermes/plugins/coucou/", + "fr": "✓ Plugin Hermes installé dans ~/.hermes/plugins/coucou/", + "bn": "✓ Hermes প্লাগইন ~/.hermes/plugins/coucou/-এ ইনস্টল হয়েছে", + "pt-BR": "✓ Plugin do Hermes instalado em ~/.hermes/plugins/coucou/", + "ru": "✓ Плагин Hermes установлен в ~/.hermes/plugins/coucou/", + "id": "✓ Plugin Hermes terpasang di ~/.hermes/plugins/coucou/" + }, + "✓ Hooks installed in ~/.claude/settings.json": { + "zh-Hans": "✓ Hooks 已安装至 ~/.claude/settings.json", + "hi": "✓ ~/.claude/settings.json में हुक इंस्टॉल हुए", + "es": "✓ Hooks instalados en ~/.claude/settings.json", + "ar": "✓ الخطافات مثبَّتة في ~/.claude/settings.json", + "fr": "✓ Hooks installés dans ~/.claude/settings.json", + "bn": "✓ ~/.claude/settings.json-এ হুক ইনস্টল হয়েছে", + "pt-BR": "✓ Hooks instalados em ~/.claude/settings.json", + "ru": "✓ Хуки установлены в ~/.claude/settings.json", + "id": "✓ Hooks terpasang di ~/.claude/settings.json" + }, + "✓ Hooks installed — restart VS Code to activate them.": { + "zh-Hans": "✓ Hooks 已安装 — 重启 VS Code 以激活。", + "hi": "✓ हुक इंस्टॉल हो गए — सक्रिय करने के लिए VS Code रीस्टार्ट करें।", + "es": "✓ Hooks instalados — reinicia VS Code para activarlos.", + "ar": "✓ الخطافات مثبَّتة — أعِد تشغيل VS Code للتفعيل.", + "fr": "✓ Hooks installés — redémarre VS Code pour les activer.", + "bn": "✓ হুক ইনস্টল হয়েছে — সক্রিয় করতে VS Code রিস্টার্ট করুন।", + "pt-BR": "✓ Hooks instalados — reinicie o VS Code para ativar.", + "ru": "✓ Хуки установлены — перезапусти VS Code для активации.", + "id": "✓ Hooks terpasang — mulai ulang VS Code untuk mengaktifkan." + }, + "✓ Hooks removed.": { + "zh-Hans": "✓ Hooks 已移除。", + "hi": "✓ हुक हटा दिए गए।", + "es": "✓ Hooks eliminados.", + "ar": "✓ تمت إزالة الخطافات.", + "fr": "✓ Hooks supprimés.", + "bn": "✓ হুক সরানো হয়েছে।", + "pt-BR": "✓ Hooks removidos.", + "ru": "✓ Хуки удалены.", + "id": "✓ Hooks dihapus." + }, + "✓ Integration keys saved.": { + "zh-Hans": "✓ 集成密钥已保存。", + "hi": "✓ इंटीग्रेशन कीज़ सेव हो गई।", + "es": "✓ Claves de integración guardadas.", + "ar": "✓ تم حفظ مفاتيح التكامل.", + "fr": "✓ Clés d'intégration enregistrées.", + "bn": "✓ ইন্টিগ্রেশন কি সংরক্ষিত হয়েছে।", + "pt-BR": "✓ Chaves de integração salvas.", + "ru": "✓ Ключи интеграции сохранены.", + "id": "✓ Kunci integrasi tersimpan." + }, + "✓ Key saved.": { + "zh-Hans": "✓ 密钥已保存。", + "hi": "✓ कुंजी सहेजी गई।", + "es": "✓ Clave guardada.", + "ar": "✓ تم حفظ المفتاح.", + "fr": "✓ Clé enregistrée.", + "bn": "✓ কী সংরক্ষিত হয়েছে।", + "pt-BR": "✓ Chave salva.", + "ru": "✓ Ключ сохранён.", + "id": "✓ Kunci tersimpan." + }, + "✓ Muse Code hooks installed in ~/.config/muse/settings.json": { + "zh-Hans": "✓ Muse Code Hooks 已安装至 ~/.config/muse/settings.json", + "hi": "✓ ~/.config/muse/settings.json में Muse Code हुक इंस्टॉल हुए", + "es": "✓ Hooks de Muse Code instalados en ~/.config/muse/settings.json", + "ar": "✓ خطافات Muse Code مثبَّتة في ~/.config/muse/settings.json", + "fr": "✓ Hooks Muse Code installés dans ~/.config/muse/settings.json", + "bn": "✓ ~/.config/muse/settings.json-এ Muse Code হুক ইনস্টল হয়েছে", + "pt-BR": "✓ Hooks do Muse Code instalados em ~/.config/muse/settings.json", + "ru": "✓ Хуки Muse Code установлены в ~/.config/muse/settings.json", + "id": "✓ Hooks Muse Code terpasang di ~/.config/muse/settings.json" + }, + "✓ Muse Code hooks removed.": { + "zh-Hans": "✓ Muse Code Hooks 已移除。", + "hi": "✓ Muse Code हुक हटा दिए गए।", + "es": "✓ Hooks de Muse Code eliminados.", + "ar": "✓ تمت إزالة خطافات Muse Code.", + "fr": "✓ Hooks Muse Code supprimés.", + "bn": "✓ Muse Code হুক সরানো হয়েছে।", + "pt-BR": "✓ Hooks do Muse Code removidos.", + "ru": "✓ Хуки Muse Code удалены.", + "id": "✓ Hooks Muse Code dihapus." + }, + "✓ OpenAI key saved.": { + "zh-Hans": "✓ OpenAI 密钥已保存。", + "hi": "✓ OpenAI कुंजी सहेजी गई।", + "es": "✓ Clave de OpenAI guardada.", + "ar": "✓ تم حفظ مفتاح OpenAI.", + "fr": "✓ Clé OpenAI enregistrée.", + "bn": "✓ OpenAI কী সংরক্ষিত হয়েছে।", + "pt-BR": "✓ Chave da OpenAI salva.", + "ru": "✓ Ключ OpenAI сохранён.", + "id": "✓ Kunci OpenAI tersimpan." + }, + "✓ OpenCode plugin installed in ~/.config/opencode/plugins/coucou.js": { + "zh-Hans": "✓ OpenCode 插件已安装至 ~/.config/opencode/plugins/coucou.js", + "hi": "✓ ~/.config/opencode/plugins/coucou.js में OpenCode प्लगइन इंस्टॉल हुआ", + "es": "✓ Plugin de OpenCode instalado en ~/.config/opencode/plugins/coucou.js", + "ar": "✓ ملحق OpenCode مثبَّت في ~/.config/opencode/plugins/coucou.js", + "fr": "✓ Plugin OpenCode installé dans ~/.config/opencode/plugins/coucou.js", + "bn": "✓ ~/.config/opencode/plugins/coucou.js-এ OpenCode প্লাগইন ইনস্টল হয়েছে", + "pt-BR": "✓ Plugin do OpenCode instalado em ~/.config/opencode/plugins/coucou.js", + "ru": "✓ Плагин OpenCode установлен в ~/.config/opencode/plugins/coucou.js", + "id": "✓ Plugin OpenCode terpasang di ~/.config/opencode/plugins/coucou.js" + }, + "✓ OpenCode plugin removed.": { + "zh-Hans": "✓ OpenCode 插件已移除。", + "hi": "✓ OpenCode प्लगइन हटा दिया गया।", + "es": "✓ Plugin de OpenCode eliminado.", + "ar": "✓ تمت إزالة ملحق OpenCode.", + "fr": "✓ Plugin OpenCode supprimé.", + "bn": "✓ OpenCode প্লাগইন সরানো হয়েছে।", + "pt-BR": "✓ Plugin do OpenCode removido.", + "ru": "✓ Плагин OpenCode удалён.", + "id": "✓ Plugin OpenCode dihapus." + }, + "✓ Status line installed.": { + "zh-Hans": "✓ 状态栏已安装。", + "hi": "✓ स्टेटस लाइन इंस्टॉल हो गई।", + "es": "✓ Línea de estado instalada.", + "ar": "✓ تم تثبيت سطر الحالة.", + "fr": "✓ Ligne de statut installée.", + "bn": "✓ স্ট্যাটাস লাইন ইনস্টল হয়েছে।", + "pt-BR": "✓ Linha de status instalada.", + "ru": "✓ Строка статуса установлена.", + "id": "✓ Baris status terpasang." + }, + "✓ Status line removed.": { + "zh-Hans": "✓ 状态栏已移除。", + "hi": "✓ स्टेटस लाइन हटा दी गई।", + "es": "✓ Línea de estado eliminada.", + "ar": "✓ تمت إزالة سطر الحالة.", + "fr": "✓ Ligne de statut supprimée.", + "bn": "✓ স্ট্যাটাস লাইন সরানো হয়েছে।", + "pt-BR": "✓ Linha de status removida.", + "ru": "✓ Строка статуса удалена.", + "id": "✓ Baris status dihapus." + }, + "❌ Select the .claude folder (hidden, in your Home directory).": { + "zh-Hans": "❌ 请选择 .claude 文件夹(隐藏,在你的 Home 目录中)。", + "hi": "❌ .claude फ़ोल्डर चुनें (छुपा हुआ, आपके Home डायरेक्टरी में)।", + "es": "❌ Selecciona la carpeta .claude (oculta, en tu directorio Home).", + "ar": "❌ اختر مجلد .claude (مخفي، في دليل Home الخاص بك).", + "fr": "❌ Sélectionne le dossier .claude (caché, dans ton répertoire Home).", + "bn": "❌ .claude ফোল্ডার নির্বাচন করুন (লুকানো, আপনার Home ডিরেক্টরিতে)।", + "pt-BR": "❌ Selecione a pasta .claude (oculta, no seu diretório Home).", + "ru": "❌ Выбери папку .claude (скрытая, в домашнем каталоге).", + "id": "❌ Pilih folder .claude (tersembunyi, di direktori Home Anda)." + } + } +} diff --git a/windows/src/island/agents.ts b/windows/src/island/agents.ts new file mode 100644 index 000000000..68ccc6cea --- /dev/null +++ b/windows/src/island/agents.ts @@ -0,0 +1,52 @@ +// Agents other than Claude Code, as the island shows them. Ids, names and colours +// are PillCatalog.swift's (pill `agent_`), so a pill looks the same on every +// platform. + +export interface KnownAgent { + name: string; + color: string; +} + +export const KNOWN_AGENTS: Record = { + cursor: { name: "Cursor", color: "#C0C4CC" }, + antigravity: { name: "Antigravity", color: "#E879F9" }, + codex: { name: "Codex", color: "#2DD4BF" }, + gemini: { name: "Gemini CLI", color: "#8AB4F8" }, + copilot: { name: "Copilot CLI", color: "#818CF8" }, + muse: { name: "Muse Code", color: "#38BDF8" }, + opencode: { name: "OpenCode", color: "#4ADE80" }, + amp: { name: "Amp", color: "#F59E0B" }, + hermes: { name: "Hermes", color: "#C084FC" }, + "claude-desktop": { name: "Claude Desktop", color: "#D97757" }, +}; + +/** + * Agents whose permission requests get an Allow / Deny card, as on the Mac. + * Must match `takes_decisions` in the relay (hook/src/reply.rs): any other + * agent's request is handed straight back to its terminal. + */ +export const APPROVAL_AGENTS = new Set(["codex", "copilot", "muse"]); + +/** Same rule as HookServer.validateAgent on macOS. "claude" is reserved. */ +export function validateAgent(raw: string | undefined): string | null { + if (!raw || raw.length > 24 || raw === "claude") return null; + if (!/^[a-z0-9-]+$/.test(raw)) return null; + return raw; +} + +const FALLBACK_COLORS = ["#22C55E", "#EAB308", "#60A5FA", "#E879F9"]; + +export function agentColor(id: string): string { + const known = KNOWN_AGENTS[id]; + if (known) return known.color; + let h = 0; + for (let i = 0; i < id.length; i++) { + h = (Math.imul(31, h) + id.charCodeAt(i)) | 0; + } + return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; +} + +/** The pill label: the agent's own name when Coucou knows it, else its id. */ +export function agentName(id: string): string { + return KNOWN_AGENTS[id]?.name ?? id; +} diff --git a/windows/src/island/desktop.ts b/windows/src/island/desktop.ts new file mode 100644 index 000000000..eac54d246 --- /dev/null +++ b/windows/src/island/desktop.ts @@ -0,0 +1,170 @@ +// Mochi on the desktop, island side: runs his life cycle (the controller in +// mochi/desktop-logic.ts) against the real app, tells the desktop window what +// to show, and handles the drag out of the island. The window itself lives in +// src-tauri/src/desktop.rs and draws him in src/desktop/main.ts. + +import { Bridge, emitToWindow, onEvent, type DesktopMode } from "../core/bridge"; +import type { BotEmoteName } from "../core/layout"; +import { Sound } from "../core/sound"; +import { State } from "../core/state"; +import { + DESKTOP_EVENTS, DesktopMochiController, alertActive, type DesktopSnapshot, +} from "../mochi/desktop-logic"; +import { SeasonCache, parseOutfit } from "../mochi/wardrobe"; + +/** Label of the desktop Mochi's window (desktop.rs LABEL). */ +const WINDOW = "mochi"; + +/** What the life cycle needs from the island. */ +export interface DesktopHost { + /** Shows the island (compact) if it is hidden. */ + reveal(): void; + /** Right-click on the desktop Mochi: the wardrobe, or back. */ + wardrobeFromDesktop(): void; + /** Three pokes on the desktop Mochi. */ + dizzyFromDesktop(): void; +} + +export class DesktopLink { + mode: DesktopMode = "off"; + readonly controller: DesktopMochiController; + + /** The pointer is dragging Mochi out of the island. */ + carrying = false; + + private seasons = new SeasonCache(); + private pushed = ""; + private pendingCarry: { x: number; y: number } | null = null; + private carryFrame = false; + private host: DesktopHost; + + constructor(host: DesktopHost) { + this.host = host; + this.controller = new DesktopMochiController({ + flyOut: async () => (await Bridge.desktopFlyOut()) ?? false, + flyHome: async (forget) => (await Bridge.desktopFlyHome(forget)) ?? true, + setAway: (away) => { + State.mochiOnDesktop = away; + State.notify(); + }, + emote: (emote: BotEmoteName, duration = 1.8) => + void emitToWindow(WINDOW, DESKTOP_EVENTS.emote, { emote, duration }), + play: (sound) => Sound.play(sound), + alertActive: () => alertActive(State), + revealIsland: () => { + if (State.mode === "hidden") this.host.reveal(); + }, + later: (fn, ms) => void window.setTimeout(fn, ms), + }); + } + + /** False where windows can't be placed (GNOME on Wayland): he stays in the island. */ + get supported(): boolean { + return this.mode !== "off"; + } + + async init() { + const info = await Bridge.desktopInfo(); + if (!info || info.mode === "off") return; + this.mode = info.mode; + this.controller.enabled = info.onDesktop; + + await onEvent<{ from: "island" | "desktop"; home: boolean }>(DESKTOP_EVENTS.dropped, (e) => + this.onDropped(e.from, e.home), + ); + await onEvent(DESKTOP_EVENTS.home, () => void this.controller.flyHome()); + await onEvent(DESKTOP_EVENTS.wardrobe, () => this.host.wardrobeFromDesktop()); + await onEvent(DESKTOP_EVENTS.dizzy, () => this.host.dizzyFromDesktop()); + // The window's page (re)loaded: it knows nothing yet. + await onEvent(DESKTOP_EVENTS.ready, () => { + this.pushed = ""; + this.push(); + }); + + State.subscribe(() => this.sync()); + this.sync(); + } + + /** The launch greeting is over: back to his spot if that is where he lives. */ + launch() { + if (this.supported) void this.controller.launchFlyIfNeeded(); + } + + // ── Drag out of the island ────────────────────────────────────────────────── + + /** Whether Mochi can be picked up from the island right now. */ + canPickUp(): boolean { + return this.supported && !State.paused && !State.mochiOnDesktop && this.controller.phase === "home"; + } + + /** (x, y): the pointer, island-window coordinates. */ + pickUp(x: number, y: number) { + this.carrying = true; + State.mochiOnDesktop = true; + State.notify(); + void Bridge.desktopPickUp(x, y).then((ok) => { + if (ok) return; + this.carrying = false; + State.mochiOnDesktop = false; + State.notify(); + }); + } + + /** The pointer moved during the drag. Windows carries him from Rust. */ + carry(x: number, y: number) { + if (!this.carrying || this.mode === "poll") return; + this.pendingCarry = { x, y }; + if (this.carryFrame) return; + this.carryFrame = true; + requestAnimationFrame(() => { + this.carryFrame = false; + const p = this.pendingCarry; + this.pendingCarry = null; + if (p && this.carrying) void Bridge.desktopCarry(p.x, p.y); + }); + } + + /** The button went up. On Windows the cursor poll has seen it already. */ + carryEnd(x: number, y: number) { + if (!this.carrying || this.mode === "poll") return; + this.pendingCarry = null; + void Bridge.desktopCarryEnd(x, y); + } + + private onDropped(from: "island" | "desktop", home: boolean) { + if (from === "island") { + this.carrying = false; + if (home) { + // Dropped back on the island: he is simply there again. + State.mochiOnDesktop = false; + State.notify(); + } else { + this.controller.installed(); + } + } else if (home) { + void this.controller.flyHome(); + } + } + + // ── State → desktop window ────────────────────────────────────────────────── + + private sync() { + this.controller.updateAlert(alertActive(State)); + this.controller.updateState(State.effectiveState); + this.push(); + } + + private push() { + const snapshot: DesktopSnapshot = { + state: State.effectiveState, + outfit: State.wardrobePreview ?? this.seasons.get(parseOutfit(State.settings.mochiOutfit)), + soundEnabled: State.settings.soundEnabled, + soundVolume: State.settings.soundVolume, + paused: State.paused, + }; + const key = JSON.stringify(snapshot); + if (key === this.pushed) return; + this.pushed = key; + void emitToWindow(WINDOW, DESKTOP_EVENTS.state, snapshot); + } +} diff --git a/windows/src/island/fsm.ts b/windows/src/island/fsm.ts index 8a24545ed..64ef42856 100644 --- a/windows/src/island/fsm.ts +++ b/windows/src/island/fsm.ts @@ -8,8 +8,19 @@ export class IslandStateMachine { onTransition: ((from: FsmState, to: FsmState) => void) | null = null; - /** home → petit delay, seconds. */ - homeToPetitDelay = 15; + /** + * home → petit delay, seconds: the auto-close preference. Changing it while a + * countdown runs starts that countdown again with the new delay, so an edit in + * Settings applies at once (IslandStateMachine.homeToPetitDelay on macOS). + */ + get homeToPetitDelay(): number { + return this.homeDelay; + } + set homeToPetitDelay(seconds: number) { + if (!Number.isFinite(seconds) || seconds < 0 || seconds === this.homeDelay) return; + this.homeDelay = seconds; + if (this.state === "home" && this.homeCollapse != null) this.scheduleHomeCollapse(); + } /** petit → hidden delay, seconds. */ petitToHiddenDelay = 60; /** coucou → petit once the greeting animation ends (no hover). */ @@ -19,6 +30,14 @@ export class IslandStateMachine { /** An alert waiting for an answer stays open, even when the mouse leaves. */ pinned = false; + /** + * When the open island will fold, on the performance.now() clock, while the + * mouse-leave countdown runs; null otherwise. The island draws its countdown + * bar from it. + */ + homeCollapseDueAt: number | null = null; + + private homeDelay = 15; private petitHide: number | null = null; private homeCollapse: number | null = null; private greetCollapse: number | null = null; @@ -106,19 +125,26 @@ export class IslandStateMachine { private schedulePetitHide() { this.clear("petitHide"); + // A card folded away while it waits for an answer keeps the compact island + // on screen, so it can be reopened (isHeldOpen on macOS). + if (this.pinned) return; this.petitHide = window.setTimeout(() => { this.petitHide = null; - if (this.state === "petit") this.transition("hidden"); + if (this.state === "petit" && !this.pinned) this.transition("hidden"); }, this.petitToHiddenDelay * 1000); } private scheduleHomeCollapse() { this.clear("homeCollapse"); if (this.pinned) return; + const ms = this.homeDelay * 1000; + this.homeCollapseDueAt = performance.now() + ms; this.homeCollapse = window.setTimeout(() => { this.homeCollapse = null; - if (this.state === "home") this.transition("petit"); - }, this.homeToPetitDelay * 1000); + this.homeCollapseDueAt = null; + // An alert pinned while the countdown ran keeps the island open. + if (this.state === "home" && !this.pinned) this.transition("petit"); + }, ms); } private scheduleGreetCollapse(delay: number) { @@ -133,6 +159,7 @@ export class IslandStateMachine { const id = this[which]; if (id != null) window.clearTimeout(id); this[which] = null; + if (which === "homeCollapse") this.homeCollapseDueAt = null; } cancelTimers() { diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index 573e1f631..63e5cb08b 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -1,18 +1,38 @@ -// Claude Code hook events → island state. +// Hook events from Claude Code and every other agent → island state. // Port of HookServer.processEvent / processPermissionRequest from the macOS app. // Difference from macOS: no terminal filter. On Windows the hook fires from any // terminal (Windows Terminal, VS Code, PowerShell…) and all of them are handled. +// The relay has already mapped every agent's events and fields onto Claude +// Code's (hook/src/normalize.rs), so one handler serves them all. import { Bridge, onEvent } from "../core/bridge"; +import { buildFileDiff, fileName, makeDiffStep, toOneLine } from "../core/diff"; import { Sound } from "../core/sound"; -import { State } from "../core/state"; +import { State, type AskedQuestion } from "../core/state"; +import { pillDefinition } from "../core/pills"; +import { APPROVAL_AGENTS, agentColor, agentName, validateAgent } from "./agents"; import type { Island } from "./island"; +import { parseClaudePlan, restorePlanUsage } from "../core/plan"; +import { setClaudePlanUsage, storedClaudePlanUsage } from "../views/usage"; +import { N_, t } from "../i18n/i18n"; const CLAUDE_ID = "integration_claude"; +const CURSOR_ID = "agent_cursor"; /** Clears the approval card if no decision was made before the hook gave up. */ let pendingTimeout: number | null = null; +/** Takes the approval or question card down and gives the island back. */ +function dropPendingCard(island: Island): void { + if (!State.pendingApproval) return; + State.endApproval(); + island.dropPin(); + if (State.view === "approval" || State.view === "question") { + island.setView(State.defaultView()); + } + State.notify(); +} + /** The return to idle that Stop arms, per pill, so the next turn can cancel it. */ const stopTimers = new Map(); @@ -24,6 +44,9 @@ function cancelStopTimer(id: string): boolean { return true; } +/** Events after which a pending permission request of the same session is moot. */ +const TURN_OVER = new Set(["Stop", "StopFailure", "UserPromptSubmit", "SessionEnd", "Interrupt"]); + interface HookPayload { hook_event_name?: string; request_id?: string; @@ -32,27 +55,20 @@ interface HookPayload { message?: string; /** UserPromptSubmit carries `prompt`; `message` belongs to Notification/Stop. */ prompt?: string; + /** Stop: the turn's final answer (Markdown) — Claude Code's, Hermes's, Codex's. */ + last_assistant_message?: string; tool_name?: string; tool_input?: Record; + /** Set by the relay when an edit was too big to forward whole (> 256 KB). */ + coucou_diff_truncated?: boolean; /** Optional agent tag: lowercase, digits and hyphens, ≤ 24 chars. */ coucou_agent?: string; -} - -/** Same rule as HookServer.validateAgent on macOS. "claude" is reserved. */ -function validateAgent(raw: string | undefined): string | null { - if (!raw || raw.length > 24 || raw === "claude") return null; - if (!/^[a-z0-9-]+$/.test(raw)) return null; - return raw; -} - -const FALLBACK_COLORS = ["#22C55E", "#EAB308", "#60A5FA", "#E879F9"]; - -function agentColor(name: string): string { - let h = 0; - for (let i = 0; i < name.length; i++) { - h = (Math.imul(31, h) + name.charCodeAt(i)) | 0; - } - return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; + /** Hermes: where the session runs (telegram, discord…; "cli" in a terminal). */ + platform?: string; + /** "cursor" when Claude Code runs in Cursor's terminal (set by the relay). */ + term_editor?: string; + /** StatusLine (the plan usage relay): Claude Code's 5-hour and weekly limits. */ + rate_limits?: unknown; } const PROJECT_ALIASES: Record = { @@ -71,26 +87,36 @@ function lastPathComponent(p: string): string { return idx >= 0 ? cleaned.slice(idx + 1) : cleaned; } -/** frenchStep() — same labels as the macOS app. */ +/** + * localizedStep() — same labels as the macOS app, in English and shown in the + * interface language (src/i18n) when the step is recorded. + */ const TOOL_LABELS: Record = { - Bash: "Exécute", - Read: "Lit", - Write: "Écrit", - Edit: "Modifie", - Glob: "Cherche", - Grep: "Recherche", - WebSearch: "Recherche web", - WebFetch: "Récupère", - TodoWrite: "Tâches", - Task: "Agent", - LS: "Liste", - MultiEdit: "Modifie", - NotebookEdit: "Notebook", - PowerShell: "Exécute", + Bash: N_("Runs"), + Read: N_("Reads"), + Write: N_("Writes"), + Edit: N_("Edits"), + Glob: N_("Searches"), + Grep: N_("Searches"), + WebSearch: N_("Searches the web"), + WebFetch: N_("Fetches"), + TodoWrite: N_("Tasks"), + Task: N_("Agent"), + LS: N_("Lists"), + MultiEdit: N_("Edits"), + NotebookEdit: N_("Notebook"), + PowerShell: N_("Runs"), + // Antigravity's tools (#298). + run_command: N_("Runs"), + view_file: N_("Reads"), + write_to_file: N_("Writes"), + replace_file_content: N_("Edits"), + read_url_content: N_("Fetches"), + search_web: N_("Searches the web"), }; function stepLabel(tool: string, input: Record): string { - const label = TOOL_LABELS[tool] ?? tool; + const label = TOOL_LABELS[tool] ? t(TOOL_LABELS[tool]) : tool; const str = (k: string) => (typeof input[k] === "string" ? (input[k] as string) : null); const cmd = str("command"); if (cmd) return `${label} · ${cmd.slice(0, 40)}`; @@ -131,27 +157,88 @@ function approvalTarget(tool: string, input: Record): string { return tool; } -function upsert(projectName: string, cwd: string) { - const t = State.tasks.find((x) => x.id === CLAUDE_ID); - if (!t) return; - t.name = projectName; - if (cwd) t.sessionCwd = cwd; +/** + * The questions of an AskUserQuestion call, if the island can show all of them + * as options to pick from. Anything it cannot is left to the terminal. + */ +function askedQuestions(tool: string, input: Record): AskedQuestion[] | null { + if (tool !== "AskUserQuestion" || !Array.isArray(input.questions)) return null; + const out: AskedQuestion[] = []; + for (const raw of input.questions as Record[]) { + const question = typeof raw?.question === "string" ? raw.question : ""; + const options = (Array.isArray(raw?.options) ? (raw.options as Record[]) : []) + .filter((o) => typeof o?.label === "string" && o.label) + .map((o) => ({ + label: o.label as string, + description: typeof o.description === "string" ? o.description : "", + })); + // A question cut short by the relay would be answered under the wrong text. + if (!question || question.endsWith("…") || options.length < 2) return null; + out.push({ question, options, multiSelect: raw.multiSelect === true }); + } + return out.length > 0 ? out : null; +} + +/** The Claude Code session's pill, named after its project for the session. */ +function upsert(id: string, projectName: string, cwd: string, sessionId: string) { + const t = State.upsertWorkspacePill(id, projectName, cwd); + if (t && sessionId) t.sessionId = sessionId; } -function clearSession() { - const t = State.tasks.find((x) => x.id === CLAUDE_ID); +/** The session is over: the pill goes back as it was, or away if it was only there for it. */ +function clearSession(id: string) { + const t = State.tasks.find((x) => x.id === id); if (!t) return; + if (!State.isKept(id)) { + State.removeTask(id); + return; + } + t.state = "idle"; t.steps = []; t.stepIndex = 0; - t.name = "VS Code"; + delete t.stepSeq; + t.name = pillDefinition(id)?.name ?? t.name; t.pillBadge = null; + t.sessionId = null; + t.finalLine = null; +} + +/** The final message stays on the card until the next turn starts. */ +function clearFinalLine(id: string) { + const t = State.tasks.find((x) => x.id === id); + if (t) t.finalLine = null; +} + +/** + * PostToolUse of Edit / MultiEdit / Write → a diff stored for the pill and a + * ticker step with its +N −M counts. Nothing is kept for a pill that does not + * exist, so a stray event cannot grow memory. An edit the relay had to cut + * would give wrong counts: the PreToolUse step ("Edits · file") stands alone. + */ +function recordDiff(agentId: string, payload: HookPayload) { + if (payload.coucou_diff_truncated) return; + if (!State.tasks.some((t) => t.id === agentId)) return; + const diff = buildFileDiff(payload.tool_name ?? "", payload.tool_input ?? {}); + if (!diff) return; + const id = State.appendSessionDiff(agentId, diff); + State.appendStep(agentId, makeDiffStep(fileName(diff.path), diff.added, diff.removed, id)); } export function registerHookHandlers(island: Island) { + // The last plan numbers seen survive a restart, as on the Mac. + State.planUsage ??= restorePlanUsage(storedClaudePlanUsage()); void onEvent("hook", (payload) => handleHook(island, payload)); } function handleHook(island: Island, payload: HookPayload) { + // Account-wide numbers from the status line relay, not part of any session: + // keep the latest, nothing else (no reveal, no sound), paused or not. + if (payload.hook_event_name === "StatusLine") { + const usage = parseClaudePlan(payload.rate_limits); + if (usage) setClaudePlanUsage(usage); + return; + } + if (State.paused) { // Silence here used to cost Claude Code nearly two minutes: the relay waited // for a decision from an island that had already decided not to look. Say so, @@ -166,12 +253,13 @@ function handleHook(island: Island, payload: HookPayload) { const projectName = aliasProjectName(raw || "Session"); // Route to the right pill. Valid coucou_agent → dynamic "agent_" pill. - // "claude" is reserved; absent or invalid → Claude Code pill unchanged. + // "claude" is reserved; absent or invalid → Claude Code's own pill: Cursor's + // when it runs in Cursor's terminal (Mac #120), VS Code's otherwise. const validAgent = validateAgent(payload.coucou_agent); - const agentId = validAgent ? `agent_${validAgent}` : CLAUDE_ID; + const workspaceId = payload.term_editor === "cursor" ? CURSOR_ID : CLAUDE_ID; + const agentId = validAgent ? `agent_${validAgent}` : workspaceId; const isExternalAgent = validAgent !== null; - - const focused = State.focusId === agentId; + const sessionId = payload.session_id ?? ""; /** Alerts force the island open; work events only reveal the compact island. */ const surface = (view: Parameters[0], isAlert: boolean) => { @@ -187,9 +275,12 @@ function handleHook(island: Island, payload: HookPayload) { /** Ensure the agent pill exists (no-op for Claude Code). */ const ensurePill = () => { if (isExternalAgent) { - State.upsertExternalAgent(agentId, validAgent!, agentColor(validAgent!)); + State.upsertExternalAgent(agentId, agentName(validAgent!), agentColor(validAgent!)); + const t = State.tasks.find((x) => x.id === agentId); + if (t && cwd) t.sessionCwd = cwd; + if (t && sessionId) t.sessionId = sessionId; } else { - upsert(projectName, cwd); + upsert(agentId, projectName, cwd, sessionId); } }; @@ -202,9 +293,34 @@ function handleHook(island: Island, payload: HookPayload) { if (cancelStopTimer(agentId)) State.setPillBadge(agentId, null); }; + // The turn that asked for a permission is over — answered in the terminal, + // interrupted, or a new prompt — so the card would be lying. It goes, and the + // relay is released without a decision. Same rule as the Mac. + const pending = State.pendingApproval; + if ( + pending && + TURN_OVER.has(name) && + pending.pillId === agentId && + pending.sessionId === (payload.session_id ?? "") + ) { + if (pendingTimeout != null) window.clearTimeout(pendingTimeout); + pendingTimeout = null; + if (pending.requestId) void Bridge.approvalDecline(pending.requestId); + dropPendingCard(island); + } + + // Read after the card above is dropped: its pill may have handed the front + // back to the pill you were on. + const focused = State.focusId === agentId; + switch (name) { case "SessionStart": ensurePill(); + clearFinalLine(agentId); + // Hermes through its gateway says where the session comes from. + if (validAgent === "hermes" && payload.platform && payload.platform !== "cli") { + State.appendStep(agentId, payload.platform.charAt(0).toUpperCase() + payload.platform.slice(1)); + } surface("overview", false); Sound.play("work"); break; @@ -212,6 +328,7 @@ function handleHook(island: Island, payload: HookPayload) { case "UserPromptSubmit": { ensurePill(); supersedeStop(); + clearFinalLine(agentId); State.updateTask(agentId, "thinking"); // The field is `prompt`; reading `message` meant this step was always blank. const asked = payload.prompt ?? payload.message; @@ -223,6 +340,7 @@ function handleHook(island: Island, payload: HookPayload) { case "PreToolUse": { ensurePill(); supersedeStop(); + clearFinalLine(agentId); State.updateTask(agentId, "working"); const tool = payload.tool_name ?? "Tool"; State.appendStep(agentId, stepLabel(tool, payload.tool_input ?? {})); @@ -232,13 +350,24 @@ function handleHook(island: Island, payload: HookPayload) { case "PostToolUse": supersedeStop(); + // The question was answered in the terminal: the card would be lying. + if ( + payload.tool_name === "AskUserQuestion" && + State.pendingApproval?.questions && + State.pendingApproval.sessionId === (payload.session_id ?? "") + ) { + if (pendingTimeout != null) window.clearTimeout(pendingTimeout); + pendingTimeout = null; + dropPendingCard(island); + } State.updateTask(agentId, "working"); + recordDiff(agentId, payload); break; case "PostToolUseFailure": supersedeStop(); State.updateTask(agentId, "working"); - State.appendStep(agentId, "⚠ failed"); + State.appendStep(agentId, t("⚠ failed")); break; case "Notification": { @@ -256,11 +385,21 @@ function handleHook(island: Island, payload: HookPayload) { break; } - case "Stop": + case "Stop": { State.updateTask(agentId, "finished"); - if (payload.message) State.appendStep(agentId, payload.message.slice(0, 60)); + // Claude Code puts the turn's answer in the Stop payload itself, so there is + // no transcript to read (the relay does not even forward its path). Other + // agents report their last words the same way (Hermes, Codex) or as + // `message`. + const finalText = toOneLine(payload.last_assistant_message ?? payload.message ?? ""); + if (finalText) { + State.appendStep(agentId, finalText); + const t = State.tasks.find((x) => x.id === agentId); + if (t) t.finalLine = finalText; + } Sound.play("finish"); - if (focused) surface("finished", true); + // A card waiting for an answer is never covered by another alert. + if (focused && !State.pendingApproval) surface("finished", true); else State.setPillBadge(agentId, "finished"); cancelStopTimer(agentId); stopTimers.set( @@ -276,12 +415,20 @@ function handleHook(island: Island, payload: HookPayload) { }, 5200), ); break; + } + + case "Interrupt": + // Codex: the user stopped the turn. Back to idle, nothing to celebrate. + supersedeStop(); + State.updateTask(agentId, "idle"); + State.setPillBadge(agentId, null); + break; case "StopFailure": supersedeStop(); State.updateTask(agentId, "error"); Sound.play("error"); - if (focused) surface("error", true); + if (focused && !State.pendingApproval) surface("error", true); else State.setPillBadge(agentId, "error"); break; @@ -289,27 +436,28 @@ function handleHook(island: Island, payload: HookPayload) { // Nothing left for the timer to do, and it must not outlive the session: a // pill recreated within 5.2 s would be removed by it. cancelStopTimer(agentId); + State.clearSessionDiffs(agentId); if (isExternalAgent) { State.removeTask(agentId); } else { State.updateTask(agentId, "idle"); - clearSession(); + clearSession(agentId); } break; case "SubagentStart": - State.appendStep(agentId, "+ subagent"); + State.appendStep(agentId, t("+ subagent")); break; case "SubagentStop": - State.appendStep(agentId, "• subagent done"); + State.appendStep(agentId, t("• subagent done")); break; case "PermissionRequest": { - // External agents do not get an approval card — showing one would look like - // a Claude Code request. Decline immediately so the agent re-asks in its - // terminal. Approval support for other agents will come with Codex support. - if (isExternalAgent) { + // Only Claude Code and the agents the relay can answer for (Codex, Copilot + // CLI, Muse Code — same as the Mac) get a card. Anyone else's request is + // declined at once, so the agent asks in its own terminal. + if (isExternalAgent && !APPROVAL_AGENTS.has(validAgent!)) { if (payload.request_id) void Bridge.approvalDecline(payload.request_id); break; } @@ -322,44 +470,40 @@ function handleHook(island: Island, payload: HookPayload) { if (requestId) void Bridge.approvalDecline(requestId); break; } - upsert(projectName, cwd); + ensurePill(); supersedeStop(); if (pendingTimeout != null) window.clearTimeout(pendingTimeout); const tool = payload.tool_name ?? "Tool"; const input = payload.tool_input ?? {}; - State.pendingApproval = { + // Claude Code asking a question is not a permission to grant: the island + // shows the options and sends back the one that was picked. Only Claude + // Code asks questions this way. + const questions = isExternalAgent ? null : askedQuestions(tool, input); + const view = questions ? "question" : "approval"; + // The card always comes up, even over another pill or an island that is + // already open: its pill comes to the front, and the one you were on + // comes back once you answer (Mac #117, #120). + State.beginApproval({ requestId, - sessionId: payload.session_id ?? "", + sessionId, + pillId: agentId, tool, command: approvalTarget(tool, input), - }; + ...(questions ? { questions } : {}), + }); // The relay's short ack window closes in 800 ms; everything below this // line is synchronous, so the card really is up by the time it lands. if (requestId) void Bridge.approvalAck(requestId); - State.updateTask(CLAUDE_ID, "approval"); - State.isPinned = true; - Sound.play("approval"); - if (focused) { - island.alert("approval"); - } else { - // Another agent holds the view, so the card would yank it away. The badge - // is the signal instead — but it has to be on screen for that to mean - // anything, hence the reveal. We just told the relay a human can act. - State.setPillBadge(CLAUDE_ID, "approval"); - island.reveal(); - } + State.updateTask(agentId, view); + Sound.play(view); + // Any agent's card (Claude Code, Codex, Copilot CLI, Muse Code) comes up + // the same way: beginApproval brought its pill to the front. + island.alert(view); // Coucou answers within 108 s or not at all; after that the terminal has // taken over and the card would be lying. pendingTimeout = window.setTimeout(() => { pendingTimeout = null; - if (!State.pendingApproval) return; - State.pendingApproval = null; - State.isPinned = false; - island.dropPin(); - State.updateTask(CLAUDE_ID, "working"); - State.setPillBadge(CLAUDE_ID, null); - if (State.view === "approval") island.setView(State.defaultView()); - State.notify(); + dropPendingCard(island); }, 110_000); break; } diff --git a/windows/src/island/integrations.ts b/windows/src/island/integrations.ts index 862ffb14b..712f6cad7 100644 --- a/windows/src/island/integrations.ts +++ b/windows/src/island/integrations.ts @@ -3,40 +3,90 @@ // the pill isn't focused, plays a sound, and clears itself after 60 s. import { onEvent, Bridge, type IntegrationUpdate } from "../core/bridge"; +import { gitHubAlert, type GitHubEvent } from "../core/github"; +import { availablePills } from "../core/pills"; import { Sound } from "../core/sound"; import { State } from "../core/state"; import type { Island } from "./island"; -/** Which Credential Manager key backs each pill. */ -const KEY_FOR: Record = { - integration_stripe: "stripe-api-key", - integration_github: "github-token", - integration_vercel: "vercel-token", - integration_n8n: "n8n-api-key", - integration_resend: "resend-api-key", - integration_notion: "notion-api-key", - integration_calcom: "calcom-api-key", -}; +const GITHUB = "integration_github"; const clearTimers = new Map(); export function registerIntegrationHandlers(island: Island) { void onEvent("integration", (update) => handle(island, update)); + void onEvent("github-alerts", handleGitHubAlerts); void refreshConfigured(); + State.subscribe(refreshGitHubWhenShown); } -/** Asks Rust which keys exist so the idle cards can say so. */ +/** + * AppState.handleGitHubEvents: the loudest event sets the badge — only while the + * GitHub pill isn't the one on screen — and plays its sound. Unlike the other + * integrations it leaves Mochi's state alone and the badge stays until the pill + * is focused, as on macOS. + */ +export function handleGitHubAlerts(events: GitHubEvent[]) { + if (State.paused) return; + const alert = gitHubAlert(events); + if (!alert) return; + const task = State.tasks.find((t) => t.id === GITHUB); + if (!task) return; + if (State.focusId !== GITHUB) task.pillBadge = alert.badge; + Sound.play(alert.sound); + State.notify(); +} + +let gitHubShown = false; + +/** The GitHub card just came on screen (focused, island opened): refresh it if stale. */ +function refreshGitHubWhenShown() { + const shown = State.mode === "expanded" && State.focusTask?.id === GITHUB; + if (shown && !gitHubShown) void Bridge.githubRefresh("pulse"); + gitHubShown = shown; +} + +/** + * Whether each pill is connected, so its idle card can say so: a key in the + * credential store for a service or a chat provider, the hooks in place for a + * pill fed by hook events (Mac #183), nothing at all for Claude Desktop. + */ export async function refreshConfigured() { - for (const [id, key] of Object.entries(KEY_FOR)) { - const present = (await Bridge.secretPresent(key)) ?? false; + const hooks = (await Bridge.agentHooksStatus()) ?? null; + for (const def of availablePills(State.os)) { + let configured: boolean; + switch (def.connect.kind) { + case "key": + configured = (await Bridge.secretPresent(def.connect.key)) ?? false; + break; + case "hooks": + // Without an answer from Rust (a plain browser), the Claude Code pill + // falls back to what the settings say about its hooks. + configured = hooks?.[def.id] ?? + (def.id === "integration_claude" ? State.settings.hooksInstalled : false); + break; + case "server": + // A local model server counts once the chat is connected to it. + configured = State.settings[def.connect.field] !== ""; + break; + case "none": + configured = true; + break; + } + const info = State.integrations[def.id] ?? { data: {}, error: null, loaded: false, configured: false }; + State.integrations[def.id] = { ...info, configured }; + } + State.notify(); +} + +/** Only the hook-driven pills, for when the island opens: a few small file reads. */ +export async function refreshHookPills() { + const hooks = await Bridge.agentHooksStatus(); + if (!hooks) return; + for (const [id, present] of Object.entries(hooks)) { const info = State.integrations[id] ?? { data: {}, error: null, loaded: false, configured: false }; State.integrations[id] = { ...info, configured: present }; } - const hooks = State.settings.hooksInstalled; - const claude = State.integrations.integration_claude ?? { - data: {}, error: null, loaded: false, configured: false, - }; - State.integrations.integration_claude = { ...claude, configured: hooks }; State.notify(); } diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb61802..43279c29e 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -7,20 +7,29 @@ import { EXPANDED_CORNER, EXPANDED_W, NOTCH_W, PANEL_H, PANEL_W, ROUNDED_CORNER, VIEW_LAYOUTS, botGlowColor, botGlowOpacity, botPosition, chatPromptHeight, islandSize, - type IslandMode, type IslandViewName, + QUESTION_PICKER_H, + type BotEmoteName, type IslandMode, type IslandViewName, } from "../core/layout"; import { Sound } from "../core/sound"; import { State } from "../core/state"; import { BotEngine, hexToRGB } from "../mochi/engine"; import { Greeting } from "../mochi/greeting"; import { createMiniBot, pruneMiniBots, syncMiniBotStates, tickMiniBots } from "../mochi/minibots"; +import { SeasonCache, parseOutfit } from "../mochi/wardrobe"; import { UploadCanvas } from "../upload/canvas"; import { USC, UploadSeq } from "../upload/sequence"; +import { closePlanCard, openPlanColor, planCardOpen } from "../views/usage"; import { buildHeader, buildViews, type ViewActions, type ViewHost } from "../views/views"; import { h } from "../views/dom"; import { IslandStateMachine } from "./fsm"; +import { refreshHookPills } from "./integrations"; +import { DesktopLink } from "./desktop"; +import { DRAG_THRESHOLD } from "../mochi/desktop-logic"; const BOT_OVERHANG = 40; +const CLAUDE_DESKTOP_ID = "agent_claude-desktop"; +/** Extra canvas on each side of Mochi, for the witch hat's brim and the Santa hat's tip. */ +const BOT_SIDE = 24; /** Same margin as the Rust hit test (src-tauri/src/island.rs). */ const HIT_MARGIN = 14; @@ -34,6 +43,8 @@ const modeOrder = (m: IslandMode) => (m === "hidden" ? 0 : m === "compact" ? 1 : export class Island { readonly fsm = new IslandStateMachine(); + /** Mochi on the desktop: his life cycle and the drag out of the island. */ + readonly desktop: DesktopLink; private root: HTMLElement; private islandEl!: HTMLElement; @@ -60,6 +71,8 @@ export class Island { private engine = new BotEngine(); private greeting = new Greeting(); + private greetingShown = false; + private seasons = new SeasonCache(); private running = false; private lastFrame = 0; @@ -72,7 +85,6 @@ export class Island { private wasInIsland = false; /** Last shape handed to Rust for the click-through test. */ private pushedRect = { x: -1, y: -1, w: -1, h: -1 }; - private homeCollapseAt: number | null = null; // Bot hover → love (IslandWindowController.botHoverIn) private botHovering = false; @@ -84,36 +96,79 @@ export class Island { private prevViewBeforeConfused: IslandViewName = "overview"; private lastSyncedView: IslandViewName | null = null; + /** The launch greeting ended, or the island came out of hidden — two of the + * moments the Monday recap may open (see src/recap/recap.ts). */ + onGreetingDone: (() => void) | null = null; + onWake: (() => void) | null = null; + + /** Where a press on Mochi started: moving past DRAG_THRESHOLD drags him out. */ + private botPress: { x: number; y: number } | null = null; + /** Drop sequence bookkeeping: last tick played, and whether the ✓ has fired. */ private uploadTens = 0; private uploadDone = false; constructor(root: HTMLElement) { this.root = root; + this.desktop = new DesktopLink({ + reveal: () => this.reveal(), + wardrobeFromDesktop: () => this.wardrobeFromDesktop(), + dizzyFromDesktop: () => this.handleDizzy(), + }); this.build(); this.wireFsm(); this.wireInput(); this.engine.onDizzy = () => this.handleDizzy(); - this.greeting.onComplete = () => this.fsm.greetComplete(); + this.greeting.onComplete = () => { + this.fsm.greetComplete(); + this.onGreetingDone?.(); + }; State.subscribe(() => { this.dirty = true; this.ensureRunning(); }); } + /** The request has its answer: the card goes and the session carries on. */ + private closeApproval() { + State.endApproval(); + this.fsm.pinned = false; + this.setView(State.defaultView()); + } + + /** + * Folds a card that is waiting for an answer down to the compact island, + * without answering it (Mac #290). Nothing is decided: the request keeps + * waiting, the island stays on screen, and opening it shows the card again. + */ + foldApproval() { + if (!State.pendingApproval || State.mode !== "expanded") return; + State.isPinned = true; + this.fsm.pinned = true; + this.fsm.forcePetit(); + } + // ── DOM ───────────────────────────────────────────────────────────────────── private build() { const actions: ViewActions = { setView: (v) => this.setView(v), + cancelDrop: () => this.discardDrop(), collapse: () => this.collapse(), + foldApproval: () => this.foldApproval(), setFocus: (id) => { State.setFocus(id); Sound.play("blip"); + // A pill with a waiting request opens on its card: going back to it + // after looking at another pill brings the card up again. + const req = State.pendingApproval; + if (req?.pillId === id) this.setView(req.questions ? "question" : "approval"); }, openTerminal: () => { - const cwd = State.focusTask?.sessionCwd ?? null; - void Bridge.openInVSCode(cwd); + const task = State.focusTask; + // Sessions from the Claude desktop app live there, not in a terminal. + if (task?.id === CLAUDE_DESKTOP_ID) void Bridge.openClaudeDesktop(); + else void Bridge.openSession(task?.sessionId ?? null, task?.sessionCwd ?? null); }, // The ↗ button — same targets as openAgentTarget() on macOS. openTarget: () => { @@ -122,13 +177,15 @@ export class Island { const urls: Record = { integration_resend: "https://resend.com/emails", integration_vercel: "https://vercel.com/dashboard", - integration_github: "https://github.com", + integration_github: "https://github.com/pulls", integration_stripe: "https://dashboard.stripe.com/payments", integration_notion: "https://notion.so", integration_calcom: "https://app.cal.com/bookings", }; - if (task.id === "integration_claude") void Bridge.openInVSCode(task.sessionCwd ?? null); - else if (task.id === "integration_n8n") void Bridge.openN8n(); + if (task.id === CLAUDE_DESKTOP_ID) void Bridge.openClaudeDesktop(); + else if (task.id === "integration_claude" || task.sessionId) { + void Bridge.openSession(task.sessionId ?? null, task.sessionCwd ?? null); + } else if (task.id === "integration_n8n") void Bridge.openN8n(); else if (urls[task.id]) void Bridge.openUrl(urls[task.id]); }, openUrl: (url) => { @@ -140,12 +197,21 @@ export class Island { if (!req) return; Sound.play(d === "deny" ? "blip" : "approve"); void Bridge.approvalDecision(req.requestId, d); - State.pendingApproval = null; - State.isPinned = false; - this.fsm.pinned = false; - State.updateTask("integration_claude", "working"); - State.setPillBadge("integration_claude", null); - this.setView(State.defaultView()); + this.closeApproval(); + }, + answer: (answers) => { + const req = State.pendingApproval; + if (!req) return; + Sound.play("approve"); + void Bridge.approvalAnswer(req.requestId, answers); + this.closeApproval(); + }, + answerInTerminal: () => { + const req = State.pendingApproval; + if (!req) return; + Sound.play("blip"); + void Bridge.approvalDecline(req.requestId); + this.closeApproval(); }, toggleSound: () => { State.settings.soundEnabled = !State.settings.soundEnabled; @@ -167,6 +233,18 @@ export class Island { }, openSettingsWindow: () => void Bridge.openSettingsWindow(), blip: () => Sound.play("blip"), + chooseOutfit: (selection) => { + if (parseOutfit(State.settings.mochiOutfit) === selection) return; + State.settings.mochiOutfit = selection; + void Bridge.saveSettings(State.settings); + Sound.play("pop"); + this.engine.triggerEmote("proud"); + State.notify(); + }, + previewOutfit: (outfit) => { + State.wardrobePreview = outfit; + State.notify(); + }, }; this.wakeStrip = h("div", { id: "wake-strip" }); @@ -191,7 +269,7 @@ export class Island { : null; this.setView("prompt"); }, - cancel: () => this.setView(State.defaultView()), + cancel: () => this.discardDrop(), }); this.clipEl = h( @@ -226,6 +304,8 @@ export class Island { private wireFsm() { this.fsm.homeToPetitDelay = State.settings.autoCloseInterval; this.fsm.onTransition = (from, to) => { + // The greeting is over, however it ended: back to his desktop spot. + if (from === "coucou" && to !== "coucou") this.desktop.launch(); switch (to) { case "hidden": this.setMode("hidden"); @@ -240,6 +320,9 @@ export class Island { case "home": this.expand(State.defaultView()); if (!this.wasInIsland) this.fsm.mouseLeft(); + // Hooks may have been installed in a terminal since: the idle cards + // say so on the next open, without polling while the island is shut. + void refreshHookPills(); break; case "coucou": this.expand("greeting"); @@ -247,6 +330,7 @@ export class Island { break; } State.notify(); + if (from === "hidden") this.onWake?.(); }; } @@ -263,10 +347,12 @@ export class Island { if (mode === "expanded") Sound.play("open"); if (prev === "expanded") { Sound.play("close"); - State.isPinned = false; + // A folded card is still waiting: it keeps the island pinned. + if (!State.pendingApproval) State.isPinned = false; void Bridge.focusWindow(false); } if (mode !== "expanded") { + closePlanCard(); this.engine.resetMorph(); // Nothing can be seen of the sequence once the island is shut, and leaving // it running would keep the frame loop awake — the island must cost @@ -290,16 +376,17 @@ export class Island { expand(view: IslandViewName) { this.stopSequenceIfLeaving(view); + if (view !== "overview") closePlanCard(); State.view = view; if (State.mode !== "expanded") this.setMode("expanded"); else this.animateGeometry(false); State.lastActivity = performance.now(); - this.homeCollapseAt = null; State.notify(); } setView(view: IslandViewName) { this.stopSequenceIfLeaving(view); + if (view !== "overview") closePlanCard(); if (State.mode !== "expanded") { this.fsm.forceHome(); State.view = view; @@ -315,6 +402,11 @@ export class Island { } collapse() { + // A waiting card is only ever folded, never dropped by a close. + if (State.pendingApproval) { + this.foldApproval(); + return; + } State.isPinned = false; this.fsm.pinned = false; // Drive the state machine rather than the mode: setting the mode behind its @@ -334,9 +426,68 @@ export class Island { this.fsm.reveal(); } + /** Right-click on Mochi: wardrobe open ↔ back to the usual view. */ + toggleWardrobe() { + if (State.paused || State.mode === "hidden") return; + // The greeting and the drop sequence draw a Mochi of their own. + if (State.mode === "expanded" && (State.view === "greeting" || this.uploadActive)) return; + if (State.mode === "expanded" && State.view === "wardrobe") this.setView(State.defaultView()); + else this.setView("wardrobe"); + } + + /** + * Right-click on the desktop Mochi (macOS openWardrobeFromDesktop): opens the + * wardrobe from any state, or goes back if it is already open. + */ + wardrobeFromDesktop() { + this.wardrobeAnywhere(); + } + + /** + * The wardrobe from any state — compact or hidden island included — or back + * to the usual view if it is already open. The desktop Mochi's right-click + * and the wardrobe shortcut (`open-wardrobe`) both land here. + */ + wardrobeAnywhere() { + if (State.mode === "expanded" && State.view === "wardrobe") { + this.setView(State.defaultView()); + return; + } + if (State.paused) return; + this.alert("wardrobe"); + } + /** An alert stopped waiting for an answer: let the island auto-close again. */ dropPin() { this.fsm.pinned = false; + // The countdown the pin held back starts now, if the mouse is elsewhere. + if (!this.wasInIsland) this.fsm.mouseLeft(); + } + + // ── Keyboard shortcuts (island/shortcuts.ts) ──────────────────────────────── + + emote(name: BotEmoteName) { + this.engine.triggerEmote(name); + this.ensureRunning(); + } + + /** Ctrl+P: keep the open island from folding away, or let it fold again. */ + setPinned(on: boolean) { + State.isPinned = on; + this.fsm.pinned = on; + if (on) { + // The countdown bar reads the state machine's deadline, cleared with it. + this.fsm.cancelTimers(); + } else if (!this.wasInIsland && this.fsm.state === "home") { + this.fsm.mouseLeft(); + } + State.notify(); + } + + /** The island takes the keyboard, so its own shortcuts work (Mac: makeKey). + * It gives it back when it closes, or when the chat is left. */ + takeKeyboard() { + void Bridge.focusWindow(true); } // ── File drop ─────────────────────────────────────────────────────────────── @@ -406,11 +557,14 @@ export class Island { void Bridge.ingestFile(path) .then((file) => { + // Cancelled (or replaced by another drop) while the copy was running. + if (State.droppedFile?.path !== path) return; State.droppedFile = { name: file.name, path: file.path }; State.promptContext = { kind: "file", name: file.name, path: file.path }; State.notify(); }) .catch((err) => { + if (State.droppedFile?.path !== path) return; UploadSeq.deactivate(); State.noteMessage = String(err).replace(/^Error:\s*/, ""); this.engine.animateMorph(0); @@ -420,6 +574,13 @@ export class Island { }); } + /** "Cancel" on the dropped file: forget it, so the chat does not pick it up. */ + private discardDrop() { + State.droppedFile = null; + State.promptContext = null; + this.setView(State.defaultView()); + } + /** * Sounds and view changes hung off the canvas timeline: a `tick` every 10 %, * the ✓ chime when the bar completes, then `choose` once Mochi has grown back. @@ -450,7 +611,10 @@ export class Island { // ── Geometry ──────────────────────────────────────────────────────────────── private targetSize(): { w: number; h: number; r: number } { - const { w, h } = islandSize(State.mode, State.view, State.chatHistory.length); + let { w, h } = islandSize(State.mode, State.view, State.chatHistory.length); + if (State.mode === "expanded" && State.view === "question" && State.pendingApproval?.questions) { + h = QUESTION_PICKER_H; + } const r = State.mode === "expanded" ? EXPANDED_CORNER : ROUNDED_CORNER; return { w, h, r }; } @@ -534,6 +698,16 @@ export class Island { this.islandEl.addEventListener("mousedown", (e) => { Sound.resume(); State.lastActivity = performance.now(); + // A press on Mochi may become a drag out to the desktop. + if (e.button === 0 && this.isBotHit(e.clientX, e.clientY)) { + this.botPress = { x: e.clientX, y: e.clientY }; + } + // Right-click on Mochi opens the wardrobe, and closes it again. + if (e.button === 2 && this.isBotHit(e.clientX, e.clientY)) { + this.cancelBotHover(); + this.toggleWardrobe(); + return; + } if (State.mode !== "expanded") { this.fsm.click(); return; @@ -544,8 +718,43 @@ export class Island { } }); + // No browser menu over Mochi: his right-click is the wardrobe. Everywhere + // else (the chat field) the webview keeps its own menu. + this.islandEl.addEventListener("contextmenu", (e) => { + if (this.isBotHit(e.clientX, e.clientY)) e.preventDefault(); + }); + + // Dragging Mochi out of the island puts him on the desktop. + window.addEventListener("mousemove", (e) => { + if (this.desktop.carrying) { + this.desktop.carry(e.clientX, e.clientY); + return; + } + const press = this.botPress; + if (!press) return; + if (!(e.buttons & 1)) { + this.botPress = null; + return; + } + if (Math.hypot(e.clientX - press.x, e.clientY - press.y) <= DRAG_THRESHOLD) return; + this.botPress = null; + if (!this.canDragOut()) return; + this.cancelBotHover(); + this.desktop.pickUp(e.clientX, e.clientY); + }); + window.addEventListener("mouseup", (e) => { + this.botPress = null; + if (this.desktop.carrying) this.desktop.carryEnd(e.clientX, e.clientY); + }); + + // Only keys typed into the island itself land here, never Escape typed in + // a terminal — so it may fold a waiting card away, as Escape in the notch + // does on macOS. window.addEventListener("keydown", (e) => { - if (e.key === "Escape" && State.mode === "expanded" && !State.isPinned) this.collapse(); + if (e.key === "Escape" && State.mode === "expanded") { + if (State.pendingApproval) this.foldApproval(); + else if (!State.isPinned) this.collapse(); + } State.lastActivity = performance.now(); }); @@ -569,8 +778,24 @@ export class Island { }); } + /** + * Pointer on/off the island as the compositor sees it (Linux only). Null + * until the first report, so a platform that never sends it is not gated. + */ + private pointerInside: boolean | null = null; + + setPointerInside(inside: boolean) { + this.pointerInside = inside; + } + /** Cursor in window-logical coordinates. */ onCursor(x: number, y: number) { + // WebKitGTK can deliver a mousemove after the pointer has left the layer + // surface; trusting it re-enters the island and the auto-close never runs. + if (this.pointerInside === false) { + x = -10_000; + y = -10_000; + } State.mouse = { x, y }; const rect = this.islandRect(); State.mouseInIsland = { x: x - rect.x, y: y - rect.y }; @@ -588,13 +813,9 @@ export class Island { if (inIsland && !this.wasInIsland) { if (this.fsm.state === "coucou") this.greeting.hover(); this.fsm.mouseEntered(); - this.homeCollapseAt = null; } if (!inIsland && this.wasInIsland) { this.fsm.mouseLeft(); - if (this.fsm.state === "home" && !State.isPinned) { - this.homeCollapseAt = performance.now() + State.settings.autoCloseInterval * 1000; - } } this.wasInIsland = inIsland; @@ -614,7 +835,15 @@ export class Island { this.ensureRunning(); } + /** The greeting and the drop sequence draw a Mochi of their own: not that one. */ + private canDragOut(): boolean { + if (State.mode === "hidden" || !this.desktop.canPickUp()) return false; + return !(State.mode === "expanded" && (State.view === "greeting" || this.uploadActive)); + } + private isBotHit(x: number, y: number): boolean { + // Out on the desktop, the island's Mochi is invisible: nothing to hit. + if (State.mochiOnDesktop) return false; const rect = this.islandRect(); const cx = rect.x + this.botCx.value; const cy = rect.y + this.botCy.value; @@ -650,7 +879,7 @@ export class Island { } /** Three slaps → dizzy + confused view for 3.3 s, then back. */ - private handleDizzy() { + handleDizzy() { this.prevViewBeforeConfused = State.view; State.stateOverride = "dizzy"; this.engine.setState("dizzy"); @@ -717,7 +946,8 @@ export class Island { this.viewsEl.classList.toggle("hidden-by-upload", uploadActive); tickMiniBots(dt); - this.views.get(State.view)?.tick?.(nowMs); + // A ticker scroll that loses its frames freezes mid-way, rows overlapping. + const viewAnimating = this.views.get(State.view)?.tick?.(nowMs) === true; if (UploadSeq.isActive) this.stepSequence(); this.updateCountdown(nowMs); @@ -733,7 +963,7 @@ export class Island { ? settling : settling || !this.botCx.settled || !this.botCy.settled || !this.botSize.settled || - greetingActive || this.engine.busy || UploadSeq.isActive; + greetingActive || this.engine.busy || UploadSeq.isActive || viewAnimating; if (busy) { requestAnimationFrame(this.frame); @@ -750,11 +980,13 @@ export class Island { this.botSize.target = p.diameter / 0.6; const greetingActive = State.mode === "expanded" && State.view === "greeting"; - // The drop canvas draws its own Mochi; two of them would overlap. - const visible = p.opacity > 0 && !greetingActive && !this.uploadActive; + // The drop canvas draws its own Mochi; two of them would overlap. Out on the + // desktop, he isn't here at all. + const away = State.mochiOnDesktop; + const visible = p.opacity > 0 && !greetingActive && !this.uploadActive && !away; this.botCanvas.style.opacity = visible ? "1" : "0"; - if (State.mode === "expanded" && State.view !== "uploading" && !greetingActive && !this.uploadActive) { + if (State.mode === "expanded" && State.view !== "uploading" && !greetingActive && !this.uploadActive && !away) { const d = p.diameter; const color = botGlowColor(State.effectiveState); this.botGlow.style.display = "block"; @@ -773,22 +1005,28 @@ export class Island { const size = this.botSize.value; const w = Math.max(1, Math.round(size)); const hCss = w + BOT_OVERHANG; + const wCss = w + BOT_SIDE * 2; const dpr = Math.min(2, window.devicePixelRatio || 1); if (this.canvasPx !== w) { this.canvasPx = w; - this.botCanvas.width = Math.round(w * dpr); + this.botCanvas.width = Math.round(wCss * dpr); this.botCanvas.height = Math.round(hCss * dpr); - this.botCanvas.style.width = `${w}px`; + this.botCanvas.style.width = `${wCss}px`; this.botCanvas.style.height = `${hCss}px`; } - this.botCanvas.style.left = `${this.botCx.value - w / 2}px`; + this.botCanvas.style.left = `${this.botCx.value - wCss / 2}px`; this.botCanvas.style.top = `${this.botCy.value - BOT_OVERHANG / 2 - hCss / 2}px`; const ctx = this.botCanvas.getContext("2d"); if (!ctx) return; const focus = State.focusTask; - this.engine.bodyColor = focus?.isIntegration ? hexToRGB(focus.color) : null; + // While a plan card is open Mochi wears the plan's colour, like its pill. + this.engine.bodyColor = planCardOpen() + ? hexToRGB(openPlanColor()) + : focus?.isIntegration + ? hexToRGB(focus.color) + : null; this.engine.particleOverhang = BOT_OVERHANG; this.engine.lookX = this.lookX(); this.engine.lookY = this.lookY(); @@ -801,9 +1039,19 @@ export class Island { this.engine.slotHVel = 0; } } + // Only the main Mochi is dressed — the one of the main tool's pill (Settings → + // Active pills): a focused integration pill shows its own colours, unless + // the wardrobe is open (BotCanvasView.showOutfit, macOS). + // In the wardrobe the hovered outfit swaps in at once, without the drop-in. + const inWardrobe = State.mode === "expanded" && State.view === "wardrobe"; + const mainFocused = State.focusId == null || State.focusId === State.mainPillId; + const showOutfit = mainFocused || State.mode !== "expanded" || inWardrobe; + const outfit = State.wardrobePreview ?? this.seasons.get(parseOutfit(State.settings.mochiOutfit)); + this.engine.setOutfit(showOutfit ? outfit : "none", !inWardrobe); + this.engine.update(dt); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, w, hCss); + ctx.setTransform(dpr, 0, 0, dpr, BOT_SIDE * dpr, 0); + ctx.clearRect(-BOT_SIDE, 0, wCss, hCss); this.engine.draw(ctx, w, hCss); } @@ -819,13 +1067,16 @@ export class Island { } private updateCountdown(nowMs: number) { - if (State.mode !== "expanded" || State.isPinned || this.homeCollapseAt == null) { + // The state machine's own deadline, so the bar follows an auto-close delay + // edited while the countdown runs. + const dueAt = this.fsm.homeCollapseDueAt; + if (State.mode !== "expanded" || State.isPinned || dueAt == null) { this.countdown.style.width = "0px"; return; } - const autoClose = State.settings.autoCloseInterval; + const autoClose = this.fsm.homeToPetitDelay; const windowS = Math.min(10, autoClose * 0.6); - const remaining = (this.homeCollapseAt - nowMs) / 1000; + const remaining = (dueAt - nowMs) / 1000; this.countdown.style.width = remaining < windowS ? `${Math.max(0, clamp(remaining / windowS, 0, 1) * 160)}px` : "0px"; } @@ -836,10 +1087,20 @@ export class Island { const expanded = State.mode === "expanded"; const greetingActive = expanded && State.view === "greeting"; - this.contentEl.style.opacity = expanded && !greetingActive ? "1" : "0"; - this.contentEl.style.pointerEvents = expanded && !greetingActive ? "auto" : "none"; + const live = expanded && !greetingActive; + this.contentEl.style.opacity = live ? "1" : "0"; + // While the drop sequence owns the body its buttons are painted on the canvas + // underneath, so only the header may keep taking clicks up here. + this.contentEl.style.pointerEvents = live && !this.uploadActive ? "auto" : "none"; + this.header.el.style.pointerEvents = live ? "auto" : "none"; this.greetingCanvas.style.display = greetingActive ? "block" : "none"; + // Leaving the greeting, however it ends, lets its sound fade out. + if (this.greetingShown && !greetingActive) this.greeting.leave(); + this.greetingShown = greetingActive; + // A wardrobe try-on never outlives the wardrobe. + if (State.wardrobePreview && !(expanded && State.view === "wardrobe")) State.wardrobePreview = null; + this.header.sync(); for (const [name, view] of this.views) { const on = name === State.view; diff --git a/windows/src/island/shortcuts.ts b/windows/src/island/shortcuts.ts new file mode 100644 index 000000000..6d77a2245 --- /dev/null +++ b/windows/src/island/shortcuts.ts @@ -0,0 +1,178 @@ +// Keyboard shortcuts → island. Port of IslandWindowController.handleHotKey and +// handleIslandKey from the macOS app. +// +// Global shortcuts are caught by Rust and arrive as a `shortcut` event with +// the action id (the wardrobe goes out as `open-wardrobe` instead, for the +// wardrobe view to pick up). The in-island keys are read here, while the +// island has the keyboard: it takes it for the chat, and when a global +// shortcut opens it. + +import { Bridge, onEvent } from "../core/bridge"; +import type { BotEmoteName, IslandViewName } from "../core/layout"; +import { cyclePill, islandKeyAction, pillByNumber, type IslandKeyAction } from "../core/shortcuts"; +import { Sound } from "../core/sound"; +import { State } from "../core/state"; + +const CLAUDE_DESKTOP_ID = "agent_claude-desktop"; + +/** What the shortcuts need from the island. */ +export interface ShortcutHost { + alert(view: IslandViewName): void; + setView(view: IslandViewName): void; + collapse(): void; + emote(name: BotEmoteName): void; + setPinned(on: boolean): void; + /** Gives the island the keyboard, so the in-island keys work (Mac: makeKey). */ + takeKeyboard(): void; + /** The wardrobe from any state, or back if it is open (Island.wardrobeAnywhere). */ + wardrobeAnywhere(): void; +} + +function focusPill(host: ShortcutHost, id: string | null, open: boolean) { + if (!id) return; + State.setFocus(id); + Sound.play("blip"); + if (open) host.alert("overview"); + else host.setView("overview"); +} + +/** A global shortcut was pressed. `resume` lifts Pause, as the tray's Open does. */ +export function runGlobalShortcut(host: ShortcutHost, action: string, resume: () => void) { + switch (action) { + case "toggleIsland": + if (State.mode === "expanded") { + host.collapse(); + } else { + resume(); + // A waiting card is what the island opens on (State.defaultView). + host.alert(State.defaultView()); + host.takeKeyboard(); + } + break; + + case "openChat": + resume(); + host.alert("prompt"); + break; + + case "goToAlert": { + const asking = State.tasks.find((t) => t.state === "question"); + const pending = State.pendingApproval; + if (pending) { + // The one card every agent's request uses: its own pill to the front, + // its card (permission or question) up. Nothing is answered from here. + resume(); + State.setFocus(pending.pillId); + host.alert(pending.questions ? "question" : "approval"); + host.takeKeyboard(); + } else if (asking) { + resume(); + State.setFocus(asking.id); + host.alert("question"); + host.takeKeyboard(); + } else { + // Nothing is waiting: Mochi says so. + host.emote("annoyed"); + Sound.play("error"); + } + break; + } + + // The Mac brings the terminal app forward; here it is the existing "Open + // terminal": the session's own window when it was found, else its folder + // in VS Code; the Claude app for a Claude Desktop session. + case "jumpToTerminal": { + const task = State.focusTask; + if (task?.id === CLAUDE_DESKTOP_ID) void Bridge.openClaudeDesktop(); + else void Bridge.openSession(task?.sessionId ?? null, task?.sessionCwd ?? null); + if (State.mode === "expanded") host.collapse(); + break; + } + + case "nextPill": + case "prevPill": + resume(); + focusPill( + host, + cyclePill(State.tasks.map((t) => t.id), State.focusTask?.id ?? null, action === "nextPill" ? 1 : -1), + true, + ); + break; + + case "muteToggle": { + const on = !State.settings.soundEnabled; + State.settings.soundEnabled = on; + Sound.setEnabled(on); + void Bridge.saveSettings(State.settings); + if (on) Sound.play("tick"); + host.emote(on ? "happy" : "annoyed"); + State.notify(); + break; + } + + // wardrobeToggle never comes this way (Rust sends `open-wardrobe`), and + // attachFrontWindow / desktopToggle aren't in this version. + default: + break; + } +} + +/** A key the island acts on while it has the keyboard. */ +export function runIslandKey(host: ShortcutHost, action: IslandKeyAction) { + const ids = State.tasks.map((t) => t.id); + switch (action.kind) { + case "cycle": + focusPill(host, cyclePill(ids, State.focusTask?.id ?? null, action.delta), false); + break; + case "pill": + focusPill(host, pillByNumber(ids, action.number), false); + break; + case "newChat": + // Not while an answer is on its way: it would land in the new chat. + if (State.stateOverride === "thinking") return; + State.chatHistory = []; + State.droppedFile = null; + State.promptContext = null; + void Bridge.chatReset(); + Sound.play("blip"); + host.setView("prompt"); + break; + case "settings": + void Bridge.openSettingsWindow(); + break; + case "pin": + // A permission card keeps the island pinned until it is answered. + if (State.pendingApproval) return; + host.setPinned(!State.isPinned); + break; + } +} + +function inTextField(target: EventTarget | null): boolean { + const el = target as { tagName?: string; isContentEditable?: boolean } | null; + return el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable === true; +} + +export function registerShortcutHandlers(host: ShortcutHost, resume: () => void) { + void onEvent("shortcut", (action) => runGlobalShortcut(host, action, resume)); + // The wardrobe shortcut comes as its own event (shortcuts.rs): it opens the + // wardrobe (mochi/wardrobe.ts, views/wardrobe.ts), or closes it again. + void onEvent("open-wardrobe", () => { + resume(); + host.wardrobeAnywhere(); + }); + + // Capture phase: the chat field stops its own key events from bubbling. + window.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (State.mode !== "expanded") return; + const action = islandKeyAction(e, { view: State.view, inTextField: inTextField(e.target) }); + if (!action) return; + e.preventDefault(); + e.stopPropagation(); + runIslandKey(host, action); + }, + true, + ); +} diff --git a/windows/src/main.ts b/windows/src/main.ts index f65d5e757..36d446dc8 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -7,6 +7,14 @@ import { State, type Settings } from "./core/state"; import { Island } from "./island/island"; import { registerHookHandlers } from "./island/hooks"; import { registerIntegrationHandlers, refreshConfigured } from "./island/integrations"; +import { registerShortcutHandlers } from "./island/shortcuts"; +import { Recap } from "./recap/recap"; +import { onLanguageChange, resolveLanguage, setLanguage, systemLanguages } from "./i18n/i18n"; + +/** Shows the language Settings asks for ("" = the system's, when Coucou has it). */ +function applyLanguage() { + setLanguage(resolveLanguage(State.settings.language, systemLanguages())); +} async function main() { const root = document.getElementById("root"); @@ -20,11 +28,20 @@ async function main() { if (boot) { State.settings = { ...State.settings, ...boot.settings }; } + // A language change redraws the island in place: the texts given as tl(…) + // relabel themselves (views/dom.ts) and the views redraw the rest on this + // sync. Nothing is rebuilt, so tasks, steps and the chat stay as they are. + onLanguageChange(() => State.notify()); + applyLanguage(); + // Rust shows the tray and its errors in the same language as the webview. + void Bridge.setSystemLanguages(systemLanguages()); island.applySettings(); State.loadIntegrationTasks(); if (boot && !boot.cursorPoll) island.followPageCursor(); + await island.desktop.init(); await onEvent<{ x: number; y: number }>("cursor", ({ x, y }) => island.onCursor(x, y)); + await onEvent("pointer-inside", (inside) => island.setPointerInside(inside)); /** Pause has to reach Rust too, or the pollers keep calling out. */ const setPaused = (on: boolean) => { @@ -43,6 +60,14 @@ async function main() { setPaused(false); island.alert(State.defaultView()); break; + case "recap": + setPaused(false); + void Recap.open(island); + break; + case "wardrobe": + setPaused(false); + island.alert("wardrobe"); + break; case "pause": setPaused(!State.paused); if (State.paused) island.fsm.forceHidden(); @@ -55,14 +80,25 @@ async function main() { // The settings window writes preferences; apply them here without a restart. await onEvent("settings-changed", (s) => { + const previousMain = State.mainPillId; State.settings = { ...State.settings, ...s }; + applyLanguage(); island.applySettings(); State.loadIntegrationTasks(); + // A new main tool comes to the front, as on macOS. + if (State.mainPillId !== previousMain) State.setFocus(State.mainPillId); void refreshConfigured(); }); registerHookHandlers(island); registerIntegrationHandlers(island); + registerShortcutHandlers(island, () => setPaused(false)); + + // Monday recap: app start (greeting over), an agent starting work, waking up. + const checkRecap = () => void Recap.check(island); + island.onGreetingDone = checkRecap; + island.onWake = checkRecap; + await onEvent("recap-check", checkRecap); island.launch(); diff --git a/windows/src/mochi/desktop-logic.ts b/windows/src/mochi/desktop-logic.ts new file mode 100644 index 000000000..0bd6c91ab --- /dev/null +++ b/windows/src/mochi/desktop-logic.ts @@ -0,0 +1,310 @@ +// Mochi on the desktop — the pure logic, port of +// NotchBuddy/Sources/App/DesktopMochiLogic.swift and of the life cycle in +// DesktopMochiController (DesktopMochi.swift). No DOM, no Tauri: the island +// wires it up in src/island/desktop.ts, the desktop window draws him in +// src/desktop/main.ts, and Rust owns the window (src-tauri/src/desktop.rs). +// +// Coordinates are y-down everywhere on a PC, so the Mac's DesktopSpace flip is +// not needed: a point on the desktop is used as the OS reports it. + +import type { BotEmoteName, BotStateName } from "../core/layout"; +import type { Outfit } from "./wardrobe"; + +/** What the island tells the desktop window, whenever it changes. */ +export interface DesktopSnapshot { + state: BotStateName; + /** He is always the main Mochi, so always dressed (try-ons included). */ + outfit: Outfit; + soundEnabled: boolean; + soundVolume: number; + /** Tray → Pause: he dozes off and stays asleep. */ + paused: boolean; +} + +/** Events between the two windows. Rust adds `desktop-mochi-dropped`. */ +export const DESKTOP_EVENTS = { + /** island → desktop window */ + state: "desktop-mochi-state", + emote: "desktop-mochi-emote", + /** desktop window → island */ + ready: "desktop-mochi-ready", + home: "desktop-mochi-home", + wardrobe: "desktop-mochi-wardrobe", + dizzy: "desktop-mochi-dizzy", + /** Rust → island: a drag ended, `{ from: "island" | "desktop", home }`. */ + dropped: "desktop-mochi-dropped", + /** Rust → desktop window */ + cursor: "desktop-cursor", + visible: "desktop-visible", + flight: "desktop-flight", +} as const; + +// ── Constants (DesktopMochiLogic) ──────────────────────────────────────────── + +/** Side of the desktop window, logical pixels. */ +export const PANEL_SIZE = 120; +/** Seconds without agent activity before he may fall asleep. */ +export const SLEEP_TIMEOUT = 120; +/** He only dozes off once the pointer is at least this far from him. */ +export const SLEEP_MOUSE_DISTANCE = 150; +/** Clickable body radius, as a fraction of the window side. */ +export const BODY_RADIUS_FRACTION = 0.24; + +/** Surprised, then off to the island (DesktopMochi.swift: 0.45 s). */ +export const RETRACT_DELAY_MS = 450; +/** Pause in the island after the alert is answered, before flying back (0.6 s). */ +export const RETURN_DELAY_MS = 600; +/** Single click → poke, unless it turns out to be a double click. */ +export const DOUBLE_CLICK_MS = 300; +/** A press that moves farther than this is a drag, not a click. */ +export const DRAG_THRESHOLD = 3; + +// ── Pure helpers ───────────────────────────────────────────────────────────── + +export interface Point { + x: number; + y: number; +} + +/** + * Whether Mochi should be asleep: no agent activity for longer than + * SLEEP_TIMEOUT (strictly), and the pointer not close to him. + */ +export function shouldSleep(sinceAgentActive: number, mouseDistanceToCenter: number): boolean { + return sinceAgentActive > SLEEP_TIMEOUT && mouseDistanceToCenter >= SLEEP_MOUSE_DISTANCE; +} + +/** Hit test of the round body inside the square window (window-local). */ +export function isOverBody(local: Point, panelSize = PANEL_SIZE): boolean { + const c = panelSize / 2; + const r = panelSize * BODY_RADIUS_FRACTION; + const dx = local.x - c; + const dy = local.y - c; + return dx * dx + dy * dy <= r * r; +} + +/** Eye-tracking origin: the window centre, in the same space as the cursor. */ +export function lookOrigin(panelMinX: number, panelMinY: number, panelSize = PANEL_SIZE): Point { + return { x: panelMinX + panelSize / 2, y: panelMinY + panelSize / 2 }; +} + +/** BotCanvasView.lookX / lookY: x right +, y up +. */ +export function gaze(bot: Point, mouse: Point): { lookX: number; lookY: number } { + return { + lookX: Math.tanh((mouse.x - bot.x) / 260), + lookY: -Math.tanh((mouse.y - bot.y) / 200), + }; +} + +/** An alert went up during a flight: once landed, he turns right back. */ +export function shouldRetractOnLanding(alertActive: boolean): boolean { + return alertActive; +} + +/** Agent work keeps him awake; idle and sleeping don't. */ +export function agentActive(state: BotStateName): boolean { + return state !== "idle" && state !== "sleeping"; +} + +/** + * What sends him back to the island (the Mac's pendingApproval / + * pendingQuestion): the island's card waiting for an answer — a permission + * from any agent, or a question Claude Code asked (#216, the same card with + * `questions`), folded or not — or an agent asking a question by notification. + */ +export function alertActive(s: { + pendingApproval: { questions?: unknown } | null; + tasks: ReadonlyArray<{ state: BotStateName }>; +}): boolean { + if (s.pendingApproval != null) return true; + return s.tasks.some((t) => t.state === "question"); +} + +/** + * Linux has no global cursor (Wayland), so his window only hears the pointer + * while it is over his body. That counts as "near" for this long afterwards. + */ +export const POINTER_MEMORY_MS = 4000; + +/** + * Distance from the pointer to his centre, for shouldSleep. With a cursor + * poll (Windows) it is measured; without one, the pointer is near while it was + * over him a moment ago, and far otherwise. + */ +export function pointerDistance( + cursorPoll: boolean, + cursor: Point | null, + sincePointerMs: number, + panelSize = PANEL_SIZE, +): number { + if (cursorPoll) { + return cursor ? Math.hypot(cursor.x - panelSize / 2, cursor.y - panelSize / 2) : Infinity; + } + return sincePointerMs < POINTER_MEMORY_MS ? 0 : Infinity; +} + +/** + * Layer-shell drag: the surface is stretched over the display, so the pointer + * reads in display coordinates — once the compositor has applied it. Until + * then the pointer is still relative to the small surface at `origin`. + */ +export function layerDragTopLeft(origin: Point, client: Point, grab: Point, overlayApplied: boolean): Point { + const base = overlayApplied ? { x: 0, y: 0 } : origin; + return { x: base.x + client.x - grab.x, y: base.y + client.y - grab.y }; +} + +/** X11 drag: screen coordinates are global there; the window moves in physical pixels. */ +export function windowDragTopLeft(origin: Point, screen: Point, screenAtPress: Point, dpr: number): Point { + return { + x: origin.x + (screen.x - screenAtPress.x) * dpr, + y: origin.y + (screen.y - screenAtPress.y) * dpr, + }; +} + +// ── Life cycle (DesktopMochiController) ────────────────────────────────────── + +export type DesktopPhase = + /** No window on screen. */ + | "home" + /** Window flying from the island to his spot. */ + | "flyingOut" + /** On the desktop: the normal state. */ + | "onDesktop" + /** An alert just went up: surprised, about to fly to the island. */ + | "retracting" + /** The alert was answered while he was flying to the island. */ + | "alertResolvedDuringRetract" + /** In the island, showing the alert; back out once it is answered. */ + | "atNotchForAlert"; + +/** Everything the life cycle does to the world, injected so it can be tested. */ +export interface DesktopPorts { + /** Shows the window at the island and flies it to his spot. False: no spot. */ + flyOut(): Promise; + /** Flies the window to the island and hides it. `forget`: he lives there again. */ + flyHome(forget: boolean): Promise; + /** The island's own Mochi hides while he is out. */ + setAway(away: boolean): void; + /** An emote on the desktop Mochi. */ + emote(emote: BotEmoteName, duration?: number): void; + play(sound: string): void; + alertActive(): boolean; + /** The island must be on screen for him to show the alert there. */ + revealIsland(): void; + later(fn: () => void, ms: number): void; +} + +export class DesktopMochiController { + phase: DesktopPhase = "home"; + /** He lives on the desktop (the `onDesktop` preference). */ + enabled = false; + + private lastState: BotStateName | null = null; + private port: DesktopPorts; + + constructor(port: DesktopPorts) { + this.port = port; + } + + /** Launch, and back from an alert: fly out to his spot if he lives there. */ + async launchFlyIfNeeded(): Promise { + if (!this.enabled || this.phase !== "home") return; + // An alert is up: wait in the island, the alert's end flies him out. + if (this.port.alertActive()) { + this.phase = "atNotchForAlert"; + return; + } + this.phase = "flyingOut"; + this.port.setAway(true); + const ok = await this.port.flyOut(); + if (this.phase !== "flyingOut") return; + if (!ok) { + // His spot is on a display that is gone: he stays home. + this.phase = "home"; + this.enabled = false; + this.port.setAway(false); + return; + } + this.landed(); + } + + /** Dropped on the desktop after a drag out of the island (Rust placed him). */ + installed() { + if (this.phase !== "home") return; + this.enabled = true; + this.port.setAway(true); + this.port.emote("happy", 0.6); + this.port.play("pop"); + this.landed(); + } + + /** Double click, or dropped on the island: home for good. */ + async flyHome(): Promise { + // Also in the instant between an alert and his flight to the island: the + // user's "home" wins, and the pending retract finds nothing left to do. + const out = ["onDesktop", "retracting", "alertResolvedDuringRetract"]; + if (!out.includes(this.phase)) return; + this.phase = "home"; + this.enabled = false; + await this.port.flyHome(true); + this.port.play("peek"); + this.port.setAway(false); + } + + /** Call whenever the alert condition may have changed; only edges count. */ + private alertWas = false; + updateAlert(active: boolean) { + if (active === this.alertWas) return; + this.alertWas = active; + if (active) { + if (this.phase === "onDesktop") this.beginRetract(); + return; + } + if (this.phase === "atNotchForAlert") { + this.phase = "home"; + this.port.later(() => void this.launchFlyIfNeeded(), RETURN_DELAY_MS); + } else if (this.phase === "retracting") { + this.phase = "alertResolvedDuringRetract"; + } + } + + /** Call with the effective state; a task finishing gets a happy jump. */ + updateState(state: BotStateName) { + const prev = this.lastState; + this.lastState = state; + if (prev == null || prev === state) return; + if (state === "finished" && this.phase === "onDesktop") this.port.emote("happy", 1.2); + } + + private landed() { + this.phase = "onDesktop"; + // An alert may have gone up during the flight, when nothing reacts to it. + this.alertWas = this.port.alertActive(); + if (shouldRetractOnLanding(this.alertWas)) this.beginRetract(); + } + + private beginRetract() { + this.port.emote("surprised"); + this.phase = "retracting"; + this.port.later(() => { + if (this.phase === "retracting") { + void this.retractForAlert(); + } else if (this.phase === "alertResolvedDuringRetract") { + // Answered before he even took off: he never left his spot. + this.phase = "onDesktop"; + } + }, RETRACT_DELAY_MS); + } + + private async retractForAlert() { + await this.port.flyHome(false); + this.port.setAway(false); + if (this.phase === "alertResolvedDuringRetract") { + this.phase = "home"; + await this.launchFlyIfNeeded(); + } else if (this.phase === "retracting") { + this.phase = "atNotchForAlert"; + this.port.revealIsland(); + } + } +} diff --git a/windows/src/mochi/engine.ts b/windows/src/mochi/engine.ts index f1856b28d..02a47612b 100644 --- a/windows/src/mochi/engine.ts +++ b/windows/src/mochi/engine.ts @@ -7,6 +7,8 @@ import { Ease, lerp, type EaseFn } from "../core/anim"; import { Sound } from "../core/sound"; import type { BotEmoteName, BotStateName } from "../core/layout"; +import { PUMPKIN_BODY, drawOutfitBehind, drawOutfitFront, makeHead } from "./outfits"; +import type { Outfit } from "./wardrobe"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -36,7 +38,8 @@ interface Tween { type PropKey = | "yaw" | "pitch" | "roll" | "tilt" | "open" | "sx" | "sy" - | "oy" | "ox" | "tint" | "morph" | "hands" | "blush" | "es" | "badgeS"; + | "oy" | "ox" | "tint" | "morph" | "hands" | "blush" | "es" | "badgeS" + | "outfitPresence"; interface BotStateCfg { color: RGB; @@ -174,6 +177,22 @@ export class BotEngine { sx = 1; sy = 1; oy = 0; ox = 0; tint = 0; morph = 0; hands = 0; blush = 0; es = 1; badgeS = 0; + // Outfit (the main Mochi only — minis never wear one). `outfit` is what is + // drawn; it changes only once the previous one has left. + outfit: Outfit = "none"; + /** 0 = gone, 1 = fully on. */ + outfitPresence = 0; + private outfitTarget: Outfit = "none"; + + // Spring lag of the soft parts (pompoms, hat tips, scarf end), −1…1. + physDx = 0; + physDy = 0; + private physVx = 0; + private physVy = 0; + private prevYaw = 0; + private prevOy = 0; + private prevRoll = 0; + // Targets tgYaw = 0; tgPitch = 0; tgTilt = 0; tgSy = 1; tgSx = 1; tgEs = 1; @@ -279,6 +298,7 @@ export class BotEngine { } squash() { + this.physVy += 0.6; this.anim("sy", [[0.78, 70, Ease.out], [1.1, 130, Ease.out], [1, 170, Ease.inOut]]); this.anim("sx", [[1.16, 70, Ease.out], [0.95, 130, Ease.out], [1, 170, Ease.inOut]]); } @@ -304,6 +324,8 @@ export class BotEngine { this.slapTimes.push(t); Sound.play("slap"); this.squash(); + this.physVy -= 1.2; + this.physVx += Math.random() < 0.5 ? 0.7 : -0.7; if (this.slapTimes.length >= 3) { this.slapTimes = []; this.onDizzy?.(); @@ -325,6 +347,7 @@ export class BotEngine { const tok = ++this.greetToken; this.waveStart = t + 0.45; this.waveUntil = t + 1.55; + this.physVx += 0.2; this.eyeOverride = "happy"; this.eyeOverrideUntil = t + 2.0; @@ -452,6 +475,37 @@ export class BotEngine { this.morph = 0; } + /** + * Dresses Mochi. Animated: the old outfit leaves (180 ms), the new one drops + * in (350 ms) and Mochi does a little squash — BotEngine.setOutfit on macOS. + */ + setOutfit(next: Outfit, animated = true) { + if (next === this.outfitTarget) return; + this.outfitTarget = next; + this.tweens.delete("outfitPresence"); + this.locks.delete("outfitPresence"); + const enter = () => { + this.outfit = next; + this.anim("outfitPresence", [[1, 350, Ease.inOut]], () => this.squash()); + }; + if (!animated) { + this.outfit = next; + this.outfitPresence = next !== "none" ? 1 : 0; + } else if (next === "none") { + this.anim("outfitPresence", [[0, 180, Ease.inOut]], () => { this.outfit = "none"; }); + } else if (this.outfit === "none") { + this.outfitPresence = 0; + enter(); + } else { + this.anim("outfitPresence", [[0, 180, Ease.inOut]], enter); + } + } + + /** Wearing something visible: the body then turns as one piece when it rolls. */ + private get rigidRoll(): boolean { + return !this.isMini && this.outfit !== "none" && this.outfitPresence > 0.05; + } + /** True while anything is still moving — lets the island stop its RAF loop. */ get busy(): boolean { return ( @@ -468,7 +522,8 @@ export class BotEngine { this.slotH > 0.001 || Math.abs(this.slotHVel) > 0.001 || Math.abs(this.col[0] - this.colT[0]) > 0.003 || Math.abs(this.col[1] - this.colT[1]) > 0.003 || - Math.abs(this.col[2] - this.colT[2]) > 0.003 + Math.abs(this.col[2] - this.colT[2]) > 0.003 || + (this.outfit !== "none" && (Math.abs(this.physVx) > 0.01 || Math.abs(this.physVy) > 0.01)) ); } @@ -597,6 +652,25 @@ export class BotEngine { this.slotHVel += acc * dt; this.slotH = Math.max(0, this.slotH + this.slotHVel * dt); + // Soft-part spring: lags behind head turns, hops and rolls (stiffness 60, damping 9). + if (dt > 0) { + const yawVel = (this.yaw - this.prevYaw) / dt; + const oyVel = (this.oy - this.prevOy) / dt; + // A finished roll snaps from 2π·turns back to 0: that jump is not motion. + const dRoll = this.roll - this.prevRoll; + const rollVel = Math.abs(dRoll) > Math.PI ? 0 : dRoll / dt; + const centrifugal = this.rigidRoll ? rollVel * 0.18 : 0; + const tDx = Math.max(-1, Math.min(1, -yawVel * 0.35 - this.tilt * 2 + centrifugal)); + const tDy = Math.max(-1, Math.min(1, oyVel * 0.5)); + this.physVx += (60 * (tDx - this.physDx) - 9 * this.physVx) * dt; + this.physVy += (60 * (tDy - this.physDy) - 9 * this.physVy) * dt; + this.physDx += this.physVx * dt; + this.physDy += this.physVy * dt; + } + this.prevYaw = this.yaw; + this.prevOy = this.oy; + this.prevRoll = this.roll; + this.lastTime = n; } @@ -647,6 +721,15 @@ export class BotEngine { const cx = W / 2 + this.ox * R; const cy = H / 2 + this.particleOverhang / 2 + this.oy * R + R * 0.06; + // With an outfit on, a roll turns the whole character — hat included — as + // one piece instead of rolling the eyes over the body (BotCanvasView, macOS). + x.save(); + if (this.rigidRoll && Math.abs(this.roll) > 0.001) { + x.translate(cx, cy); + x.rotate(this.roll); + x.translate(-cx, -cy); + } + this.drawHandsBehind(x, R, rx, ry, cx, cy); x.save(); @@ -654,6 +737,11 @@ export class BotEngine { if (this.tilt !== 0) x.rotate(this.tilt); x.scale(this.sx, this.sy); + const dressed = !this.isMini && this.outfit !== "none"; + const head = dressed ? makeHead(R, this.yaw, this.pitch, this.physDx, this.physDy) : null; + const outfitState = { presence: this.outfitPresence, morph: this.morph }; + if (head) drawOutfitBehind(x, this.outfit, head, outfitState); + const body = this.bodyPath(rx, ry, R); this.drawBody(x, body, R, rx, ry); @@ -674,6 +762,9 @@ export class BotEngine { this.drawEyes(x, body, R, rx, ry); if (this.morph > 0.05) this.drawMouth(x, body, R); + if (head) drawOutfitFront(x, this.outfit, head, outfitState); + + x.restore(); x.restore(); if (this.badge && this.badgeS > 0.01 && this.morph < 0.25) { @@ -710,18 +801,37 @@ export class BotEngine { return p; } + /** How much of the pumpkin's orange shows on the body (it comes and goes with the outfit). */ + private get pumpkinAlpha(): number { + if (this.isMini || this.outfit !== "pumpkin") return 0; + return Math.min(1, this.outfitPresence * 2.5) * (1 - this.morph); + } + private drawBody(x: CanvasRenderingContext2D, body: Path2D, R: number, rx: number, ry: number) { + const pumpkin = this.pumpkinAlpha; if (this.bodyColor) { // Mini bots: flat solid fill — no gradient, no reflection, no highlight x.fillStyle = rgba(this.bodyColor, 1); x.fill(body); - return; + if (pumpkin <= 0.001) return; + } else { + const g = x.createLinearGradient(rx * 0.7, -ry * 0.85, -rx * 0.8, ry * 0.9); + g.addColorStop(0, rgba(BASE_TOP)); + g.addColorStop(1, rgba(BASE_BOTTOM)); + x.fillStyle = g; + x.fill(body); + } + x.save(); + if (pumpkin > 0.001) { + const pg = x.createLinearGradient(rx * 0.7, -ry * 0.85, -rx * 0.8, ry * 0.9); + pg.addColorStop(0, PUMPKIN_BODY[0]); + pg.addColorStop(1, PUMPKIN_BODY[1]); + x.globalAlpha = pumpkin; + x.fillStyle = pg; + x.fill(body); + // A flat-coloured body only gets the shading while it is a pumpkin. + x.globalAlpha = this.bodyColor ? pumpkin : 1; } - const g = x.createLinearGradient(rx * 0.7, -ry * 0.85, -rx * 0.8, ry * 0.9); - g.addColorStop(0, rgba(BASE_TOP)); - g.addColorStop(1, rgba(BASE_BOTTOM)); - x.fillStyle = g; - x.fill(body); const effectiveTint = this.tint * (1 - this.morph); if (effectiveTint > 0.01) { @@ -744,6 +854,7 @@ export class BotEngine { hl.addColorStop(1, "rgba(255,255,255,0)"); x.fillStyle = hl; x.fill(body); + x.restore(); } private drawEyes(x: CanvasRenderingContext2D, body: Path2D, R: number, rx: number, ry: number) { @@ -761,7 +872,8 @@ export class BotEngine { for (const sd of [-1, 1]) { const eyeYaw = sd * EYE_SP + this.yaw; - let eyePitch = EYE_P + this.pitch + this.roll; + // Rolling with an outfit on, the whole body turns: the eyes must not roll again. + let eyePitch = EYE_P + this.pitch + (this.rigidRoll ? 0 : this.roll); eyePitch = (((eyePitch + Math.PI) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2) - Math.PI; const cp = Math.cos(eyePitch); if (Math.cos(eyeYaw) * cp <= 0.04) continue; diff --git a/windows/src/mochi/greeting.ts b/windows/src/mochi/greeting.ts index 682f60a2c..c0f74a82b 100644 --- a/windows/src/mochi/greeting.ts +++ b/windows/src/mochi/greeting.ts @@ -1,27 +1,24 @@ -// The launch "coucou" — port of GreetingCanvasView.swift. +// The launch "coucou" (greeting v2) — port of GreetingCanvasView.swift. +// Mochi drops into the island, bounces, slides to the side and waves hello with +// a quick little hand, then comes back and settles in the compact island. // Everything is laid out in the same 640×150 reference space as on macOS. +import { closeCurve } from "../core/anim"; import { Sound } from "../core/sound"; import { COMPACT_W, NOTCH_H, NOTCH_W } from "../core/layout"; -// ── Timing (mirrors greeting-v2.html `T`) ───────────────────────────────────── +// ── Timing (GT in the Swift file) ───────────────────────────────────────────── const T = { - grow: 0.45, - squint0: 0.6, - squint1: 0.82, - dip0: 1.25, - dip1: 1.4, - pop0: 1.36, - pop1: 1.52, - content0: 2.45, - content1: 2.58, - tuck0: 2.58, - tuck1: 2.8, + pop0: 1.3, + pop1: 1.45, + content0: 2.4, + tuck0: 2.45, + tuck1: 2.7, badge: 2.72, down0: 2.85, - down1: 3.2, - blink2: 3.8, + down1: 3.45, + blink2: 3.7, tint0: 3.85, tint1: 4.15, end: 4.6, @@ -31,18 +28,30 @@ const T = { export const GREETING_END = T.end; +/** The greeting's own sound, played from the start (greeting.wav, the Mac's file). */ +const GREETING_SOUND = "greeting"; + // ── Geometry (640×150) ──────────────────────────────────────────────────────── +export const GREETING_W = 640; +export const GREETING_H = 150; const C0 = { x: 320, y: 90 }; const HB = 58; -const ASP = 1.34; +export const ASP = 1.34; const EAR_X = 40; -const EAR_Y = 16; const EAR_HB = 17; const CARD = { x: 10, y: 36, w: 620, h: 104 }; const CARD_R = 20; -const SMALL_W = COMPACT_W; -const SMALL_H = NOTCH_H; + +/** IslandRestingLayout for the compact island the greeting lands in. */ +const COMPACT = { + width: COMPACT_W, + height: NOTCH_H, + botDiameter: Math.min(20, Math.max(0, NOTCH_H - 6)), + botCenterY: NOTCH_H / 2, + miniGridScale: Math.min(1, Math.max(0, NOTCH_H - 4) / 28), + miniGridCenterX: COMPACT_W - 40, +}; // ── Easing ──────────────────────────────────────────────────────────────────── @@ -65,7 +74,7 @@ const seg = (t: number, a: number, b: number) => clamp((t - a) / (b - a), 0, 1); type EyeType = "dot" | "happy" | "content"; -interface Pose { +export interface Pose { hb: number; x: number; y: number; sx: number; sy: number; tilt: number; eye: EyeType; open: number; eyeRoll: number; lookX: number; lookY: number; @@ -76,59 +85,100 @@ interface Pose { } function greetPose(t: number): Pose { + // Island size — only the spread of the warp streaks uses it. const gx = seg(t, 0, 0.5); const g = Math.sin((Math.PI * gx) / 2) + 0.04 * Math.sin(Math.PI * gx) * gx; - const iw = lerp(NOTCH_W, 640, g); - const ih = lerp(NOTCH_H, 150, g); - - const gg = E.back(seg(t, 0.02, T.grow)); - const hb = lerp(3, HB, gg); - let x = C0.x; - let y = lerp(16, C0.y, E.out(seg(t, 0.02, T.grow))); - let sx = 1; - let sy = 1; - let tilt = 0; - - if (t >= T.dip0 && t < T.pop1) { - const k = Math.sin(Math.PI * seg(t, T.dip0, T.pop1)); - y += hb * 0.22 * k; - sy = 1 - 0.06 * k; - sx = 1 + 0.04 * k; - } - if (t >= T.pop1 && t < T.tuck1) { + const iw = lerp(NOTCH_W, GREETING_W, g); + const ih = lerp(COMPACT.height, GREETING_H, g); + + const cx = C0.x; + const cy = C0.y; + // Body height: invisible before 0.20, grows 0.15 → 1 with a back ease. + const hb = t < 0.2 ? 0 : lerp(HB * 0.15, HB, E.back(seg(t, 0.2, 0.6))); + + // Landmarks, in multiples of the body height + const landY = cy + 0.12 * HB; + const peakY = cy - 0.15 * HB; + const dipY = cy + 0.36 * HB; + const springY = cy - 0.1 * HB; + const sinkY = cy + 0.3 * HB; + const drift1 = cx - 0.16 * HB; + const drift2 = cx - 0.45 * HB; + const drift3 = cx - 0.57 * HB; + const drift4 = cx - 0.85 * HB; + + // Lateral travel: slides to the side, waves there, comes back. + let x: number; + if (t < 0.85) x = cx; + else if (t < 1.2) x = lerp(cx, drift1, E.inOut(seg(t, 0.85, 1.2))); + else if (t < 1.3) x = lerp(drift1, drift2, E.easeIn(seg(t, 1.2, 1.3))); + else if (t < 1.45) x = lerp(drift2, drift3, E.inOut(seg(t, 1.3, 1.45))); + else if (t < 2.4) x = lerp(drift3, drift4, E.inOut(seg(t, 1.45, 2.4))); + else if (t < 2.85) x = drift4; + else x = lerp(drift4, cx, E.inOut(seg(t, 2.85, 3.45))); + + // Vertical travel: drop in, bounce, plunge and spring for the wave, sink to tuck. + let y: number; + if (t < 0.2) y = COMPACT.botCenterY; + else if (t < 0.6) y = lerp(COMPACT.botCenterY, landY, E.easeIn(seg(t, 0.2, 0.6))); + else if (t < 0.73) y = lerp(landY, peakY, E.out(seg(t, 0.6, 0.73))); + else if (t < 0.9) y = lerp(peakY, cy, E.inOut(seg(t, 0.73, 0.9))); + else if (t < 1.2) y = cy; + else if (t < 1.3) y = lerp(cy, dipY, E.easeIn(seg(t, 1.2, 1.3))); + else if (t < 1.45) y = lerp(dipY, springY, E.out(seg(t, 1.3, 1.45))); + else if (t < 1.6) y = lerp(springY, cy, E.inOut(seg(t, 1.45, 1.6))); + else if (t < 2.4) y = cy; + else if (t < 2.7) y = lerp(cy, sinkY, E.inOut(seg(t, 2.4, 2.7))); + else if (t < 2.85) y = sinkY; + else y = lerp(sinkY, cy, E.inOut(seg(t, 2.85, 3.45))); + + // Body bob in phase with the waving hand + if (t >= T.pop1 && t < T.tuck0) { const w = t - T.pop1; - const fade = 1 - seg(t, T.tuck0, T.tuck1); - x += Math.sin(w * 2 * Math.PI * 0.9) * hb * ASP * 0.05 * fade; - tilt = Math.sin(w * 2 * Math.PI * 0.9 + 0.6) * 0.05 * fade; - y += Math.sin(w * 2 * Math.PI * 1.8) * 0.8 * fade; - } - if (t >= T.tuck0 && t < T.down1) { - y += hb * 0.12 * Math.sin(Math.PI * seg(t, T.tuck0, T.down1)); + y += Math.sin(w * 2 * Math.PI * 5) * 0.02 * HB * clamp(w / 0.08, 0, 1); } + // Squash and stretch + const landSqK = t >= 0.52 && t < 0.68 ? Math.sin(Math.PI * seg(t, 0.52, 0.68)) : 0; + const bounceK = t >= 0.62 && t < 0.84 ? Math.sin(Math.PI * seg(t, 0.62, 0.84)) : 0; + let sx = 1 + 0.14 * landSqK - 0.1 * bounceK; + let sy = 1 - 0.14 * landSqK + 0.18 * bounceK; + const plungeK = t >= 1.18 && t < 1.42 ? Math.sin(Math.PI * seg(t, 1.18, 1.42)) : 0; + const springK = t >= 1.3 && t < 1.46 ? Math.sin(Math.PI * seg(t, 1.3, 1.46)) : 0; + sx += 0.12 * plungeK - 0.18 * springK; + sy -= 0.12 * plungeK - 0.25 * springK; + let sinkK = 0; + if (t >= 2.38 && t < 2.7) sinkK = E.inOut(seg(t, 2.38, 2.7)); + else if (t >= 2.7 && t < 2.85) sinkK = 1 - E.inOut(seg(t, 2.7, 2.85)); + sx += 0.18 * sinkK; + sy -= 0.14 * sinkK; + const microK = t >= 3.7 && t < 3.82 ? Math.sin(Math.PI * seg(t, 3.7, 3.82)) : 0; + sx += 0.08 * microK; + sy -= 0.07 * microK; + + // Eyes let eye: EyeType = "dot"; - if (t >= T.squint0 && t < T.squint1) eye = "happy"; - if (t >= T.content0 && t < T.content1) eye = "content"; - if (t >= T.down0 && t < T.down1) eye = "content"; - let eyeRoll = 0; - if (t >= T.dip0 && t < T.pop1) eyeRoll = Math.sin(Math.PI * seg(t, T.dip0, T.pop1)); + if (t >= 0.55 && t < 0.8) eye = "happy"; + if (t >= T.content0 && t < T.tuck1) eye = "content"; const blink = (tb: number) => { const k = seg(t, tb, tb + 0.12); return k > 0 && k < 1 ? 1 - Math.sin(Math.PI * k) * 0.94 : 1; }; - const open = Math.min(blink(1.95), blink(T.blink2)); + const open = Math.min(blink(1.95), blink(3.05), blink(T.blink2)); + // Look let lookX = 0; let lookY = 0; - if (t >= T.squint1 && t < T.dip0) lookY = -0.2; if (t >= T.pop1 && t < T.content0) { lookX = 0.55; lookY = -0.45; } - if (t >= T.content0 && t < T.down1) { lookX = -0.3; lookY = 0.6; } - if (t >= T.down1) { + else if (t >= T.content0 && t < T.down0) { lookX = -0.3; lookY = 0.6; } + else if (t >= T.down0 && t < T.down1) { lookX = 0.3; lookY = 0.6; } + else if (t >= T.down1) { const k = E.inOut(seg(t, T.down1, T.down1 + 0.35)); - lookX = lerp(-0.3, 0, k); + lookX = lerp(0.3, 0, k); lookY = lerp(0.6, 0, k); } + // Hands const handL = t < T.tuck0 ? E.back(seg(t, T.pop0, T.pop0 + 0.14)) : 1 - E.easeIn(seg(t, T.tuck0, T.tuck1 - 0.03)); @@ -138,8 +188,8 @@ function greetPose(t: number): Pose { const wave = t >= T.pop1 && t < T.tuck0 ? t - T.pop1 : -1; return { - hb, x, y, sx, sy, tilt, - eye, open, eyeRoll, + hb, x, y, sx, sy, tilt: 0, + eye, open, eyeRoll: 0, lookX, lookY, handL, handR, wave, badge: E.back(seg(t, T.badge, T.badge + 0.28)), @@ -156,9 +206,9 @@ function greetPose(t: number): Pose { function smallPose(): Pose { return { - hb: EAR_HB, - x: 320 - SMALL_W / 2 + EAR_X, - y: EAR_Y, + hb: (EAR_HB * COMPACT.botDiameter) / 20, + x: GREETING_W / 2 - COMPACT.width / 2 + EAR_X, + y: COMPACT.botCenterY, sx: 1, sy: 1, tilt: 0, eye: "dot", open: 1, eyeRoll: 0, lookX: 0, lookY: 0, @@ -166,15 +216,20 @@ function smallPose(): Pose { badge: 1, tint: 0.6, halo: 0.6, haloBlue: 1, minis: 1, fx: 1, header: 0, card: 0, - iw: SMALL_W, ih: SMALL_H, + iw: COMPACT.width, ih: COMPACT.height, }; } -function pose(t: number, tc: number): Pose { +/** Mochi's pose `t` seconds in; `tc` is when the collapse began (∞ if not yet). */ +export function greetingPose(t: number, tc = Number.POSITIVE_INFINITY): Pose { if (t < tc) return greetPose(Math.min(t, T.end + 10)); const a = greetPose(tc); const b = smallPose(); - const e = E.inOut(seg(t, tc, tc + T.COLLAPSE)); + // Same curve and duration (340 ms) as the island shrinking around him: with + // the Mac's inOut ease the island got ahead of Mochi and cut his body off + // when the greeting was left early. + const k = seg(t, tc, tc + T.COLLAPSE); + const e = k >= 1 ? 1 : closeCurve(k); const p: Pose = { ...a }; p.iw = lerp(a.iw, b.iw, e); p.ih = lerp(a.ih, b.ih, e); @@ -203,11 +258,74 @@ function pose(t: number, tc: number): Pose { return p; } -// ── Particles (seeded LCG, seed = 7, identical sequence to the Swift version) ── +/** Where the waving (left, round) hand is, in body space before the body's scale. */ +function handLPos(hw: number, hh: number, p: Pose) { + const k = p.handL; + const hb = hh * 2; + const r = hb * 0.15 * k; + const x = lerp(-hw * 0.35, -hw - hb * 0.22, k); + let y = lerp(hh * 0.85, hh * 0.62, k); + if (p.wave >= 0) { + // ramp in 0.08 s after pop1, ramp out over the tuck + const w = p.wave; + const rampIn = clamp(w / 0.08, 0, 1); + const rampOut = 1 - clamp((w - (T.tuck0 - T.pop1)) / (T.tuck1 - T.tuck0), 0, 1); + y += Math.sin(w * 2 * Math.PI * 5) * hb * 0.14 * rampIn * rampOut; + } + return { x, y, r }; +} + +/** The resting (right, capsule) hand. */ +function handRPos(hw: number, hh: number, p: Pose) { + const k = p.handR; + const hb = hh * 2; + return { + x: lerp(hw * 0.35, hw + hb * 0.2, k), + y: lerp(hh * 0.85, hh * 0.2, k), + L: hb * 0.4 * k, + T: hb * 0.22 * k, + // breathing rotation ±0.04 rad at 2.5 Hz while the other hand waves + ang: p.wave >= 0 ? -0.61 + Math.sin(p.wave * 2 * Math.PI * 2.5) * 0.04 : -0.61, + }; +} + +/** + * Bounding box of Mochi's body and hands in the 640×150 space — what must stay + * inside the island so nothing of him is cut off. + */ +export function mochiBounds(p: Pose): { left: number; right: number; top: number; bottom: number } | null { + const hh = p.hb / 2; + const hw = hh * ASP; + if (hh <= 0.4) return null; + let left = -hw; + let right = hw; + let top = -hh; + let bottom = hh; + if (p.handL > 0.01) { + const l = handLPos(hw, hh, p); + left = Math.min(left, l.x - l.r); + top = Math.min(top, l.y - l.r); + bottom = Math.max(bottom, l.y + l.r); + } + if (p.handR > 0.01) { + const r = handRPos(hw, hh, p); + const ext = r.L / 2 + r.T / 2; + right = Math.max(right, r.x + ext); + top = Math.min(top, r.y - ext); + bottom = Math.max(bottom, r.y + ext); + } + return { + left: p.x + left * p.sx, + right: p.x + right * p.sx, + top: p.y + top * p.sy, + bottom: p.y + bottom * p.sy, + }; +} + +// ── Particles (seeded LCG, seed = 7, same sequence as the Swift version) ────── +interface WarpStreak { xNorm: number; speed: number; len: number; thick: number; alpha: number; t0: number } interface RingDot { a: number; j: number; s: number; al: number } -interface Ring { t0: number; dots: RingDot[] } -interface Streak { a: number; sp: number; len: number; t0: number; col: string } const PARTICLES = (() => { let seed = 7; @@ -215,24 +333,23 @@ const PARTICLES = (() => { seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff; return seed / 0x7fffffff; }; - const rings: Ring[] = [0.1, 0.2, 0.3, 0.45, 0.6].map((t0) => ({ - t0, - dots: Array.from({ length: 170 }, () => ({ - a: rnd() * Math.PI * 2, - j: (rnd() - 0.5) * 0.22, - s: 0.7 + rnd() * 0.9, - al: 0.45 + rnd() * 0.55, - })), + // ~70 white warp streaks for the fall-in (0 → 0.55 s) + const warps: WarpStreak[] = Array.from({ length: 70 }, () => ({ + xNorm: rnd(), + speed: 400 + rnd() * 300, + len: 6 + rnd() * 16, + thick: 1 + rnd() * 0.5, + alpha: 0.25 + rnd() * 0.55, + t0: rnd() * 0.35, })); - const cols = ["#3B9EFF", "#F29B38", "#FF5A4E", "#2EC4A0", "#A78BFA"]; - const streaks: Streak[] = Array.from({ length: 16 }, (_, i) => ({ - a: (i / 16) * Math.PI * 2 + (rnd() - 0.5) * 0.3, - sp: 230 + rnd() * 260, - len: 6 + rnd() * 9, - t0: 0.08 + rnd() * 0.14, - col: cols[i % 5], + // one white burst ring at 0.45 s + const ring: RingDot[] = Array.from({ length: 90 }, () => ({ + a: rnd() * Math.PI * 2, + j: (rnd() - 0.5) * 0.22, + s: 0.7 + rnd() * 0.9, + al: 0.45 + rnd() * 0.55, })); - return { rings, streaks }; + return { warps, ring }; })(); // ── Drawing ─────────────────────────────────────────────────────────────────── @@ -265,6 +382,7 @@ function mochiPath(hw: number, hh: number): Path2D { return p; } +/** White body gradient. A canvas gradient pads past its ends, so the corners are never left unpainted. */ function whiteFill( x: CanvasRenderingContext2D, path: Path2D, x0: number, y0: number, x1: number, y1: number, @@ -272,22 +390,15 @@ function whiteFill( const g = x.createLinearGradient(x0, y0, x1, y1); g.addColorStop(0, "rgb(251,251,252)"); g.addColorStop(1, "rgb(231,233,236)"); - x.save(); x.fillStyle = g; x.fill(path); - x.restore(); } function drawHandL(x: CanvasRenderingContext2D, hw: number, hh: number, p: Pose) { - const k = p.handL; - if (k <= 0.01) return; - const hb = hh * 2; - const r = hb * 0.15 * k; - const rx = lerp(-hw * 0.35, -hw - hb * 0.22, k); - let ry = lerp(hh * 0.85, hh * 0.62, k); - if (p.wave >= 0) ry += Math.sin(p.wave * 6) * hb * 0.02; + if (p.handL <= 0.01) return; + const { x: hx, y: hy, r } = handLPos(hw, hh, p); x.save(); - x.translate(rx, ry); + x.translate(hx, hy); const circ = new Path2D(); circ.ellipse(0, 0, r, r, 0, 0, Math.PI * 2); whiteFill(x, circ, r, -r, -r, r); @@ -298,27 +409,15 @@ function drawHandL(x: CanvasRenderingContext2D, hw: number, hh: number, p: Pose) } function drawHandR(x: CanvasRenderingContext2D, hw: number, hh: number, p: Pose) { - const k = p.handR; - if (k <= 0.01) return; - const hb = hh * 2; - const L = hb * 0.4 * k; - const T2 = hb * 0.22 * k; - let rx = lerp(hw * 0.35, hw + hb * 0.2, k); - let ry = lerp(hh * 0.85, hh * 0.2, k); - let ang = -0.61; - if (p.wave >= 0) { - const w = p.wave * 2 * Math.PI * 2.5; - ang += Math.sin(w) * 0.21; - ry += Math.sin(w + 0.8) * hb * 0.04; - rx += Math.cos(w) * hb * 0.015; - } + if (p.handR <= 0.01) return; + const h = handRPos(hw, hh, p); x.save(); - x.translate(rx, ry); - x.rotate(ang); - const g = x.createLinearGradient(L / 2, -T2 / 2, -L / 2, T2 / 2); + x.translate(h.x, h.y); + x.rotate(h.ang); + rr(x, -h.L / 2, -h.T / 2, h.L, h.T, h.T / 2); + const g = x.createLinearGradient(h.L / 2, -h.T / 2, -h.L / 2, h.T / 2); g.addColorStop(0, "rgb(251,251,252)"); g.addColorStop(1, "rgb(231,233,236)"); - rr(x, -L / 2, -T2 / 2, L, T2, T2 / 2); x.fillStyle = g; x.fill(); x.strokeStyle = "rgba(0,0,0,0.08)"; @@ -364,11 +463,8 @@ function drawMochi(x: CanvasRenderingContext2D, p: Pose) { const g = x.createLinearGradient(0, hh, 0, -hh * 0.1); g.addColorStop(0, `rgba(127,180,234,${p.tint})`); g.addColorStop(1, "rgba(127,180,234,0)"); - x.save(); - x.clip(body); x.fillStyle = g; x.fill(body); - x.restore(); } // Eyes @@ -433,45 +529,53 @@ function drawMochi(x: CanvasRenderingContext2D, p: Pose) { function drawParticles(x: CanvasRenderingContext2D, t: number, p: Pose) { if (!(p.card > 0 || p.fx < 1)) return; - for (const ring of PARTICLES.rings) { - const k = seg(t, ring.t0, ring.t0 + 1.35); - if (k <= 0 || k >= 1) continue; + + // Warp streaks: white vertical lines while Mochi falls in + if (t < 0.55) { + const fadeOut = 1 - seg(t, 0.4, 0.55); + x.lineCap = "butt"; + for (const s of PARTICLES.warps) { + if (t < s.t0) continue; + const yBot = (t - s.t0) * s.speed; + const yTop = yBot - s.len; + if (yBot <= 0) continue; + const sx = GREETING_W / 2 - p.iw / 2 + s.xNorm * p.iw; + x.strokeStyle = `rgba(255,255,255,${s.alpha * p.fx * fadeOut})`; + x.lineWidth = s.thick; + x.beginPath(); + x.moveTo(sx, Math.max(0, yTop)); + x.lineTo(sx, Math.min(GREETING_H, yBot)); + x.stroke(); + } + } + + // Ring: one white burst at 0.45 s + const k = seg(t, 0.45, 0.45 + 1.35); + if (k > 0 && k < 1) { const rx = lerp(14, 380, E.out(k)); const ry = rx * 0.34; const fade = (1 - k) * (k < 0.08 ? k / 0.08 : 1) * p.fx * p.card; - for (const dot of ring.dots) { + for (const dot of PARTICLES.ring) { const r = 1 + dot.j; x.fillStyle = `rgba(255,255,255,${dot.al * fade})`; x.fillRect(C0.x + Math.cos(dot.a) * rx * r, C0.y + Math.sin(dot.a) * ry * r, dot.s, dot.s); } } - for (const s of PARTICLES.streaks) { - const k = seg(t, s.t0, s.t0 + 0.6); - if (k <= 0 || k >= 1) continue; - const dist = s.sp * E.out(k) * 0.9 + 10; - const alpha = (1 - k) * p.fx; - x.strokeStyle = s.col + Math.round(alpha * 255).toString(16).padStart(2, "0"); - x.lineWidth = 1.6; - x.lineCap = "round"; - x.beginPath(); - x.moveTo(C0.x + Math.cos(s.a) * (dist - s.len), C0.y + Math.sin(s.a) * (dist - s.len) * 0.42); - x.lineTo(C0.x + Math.cos(s.a) * dist, C0.y + Math.sin(s.a) * dist * 0.42); - x.stroke(); - } } const MINI_COLORS = ["#E86A6A", "#3E86E0", "#EFAE5A", "#8C73F2"]; function drawMinis(x: CanvasRenderingContext2D, alpha: number) { if (alpha <= 0.01) return; - const cx = 320 + SMALL_W / 2 - 27; - const cy = 16; - const sp = 6; + const cx = GREETING_W / 2 - COMPACT.width / 2 + COMPACT.miniGridCenterX; + const cy = COMPACT.botCenterY; + const sp = 6 * COMPACT.miniGridScale; const offsets: [number, number][] = [[-sp, -sp], [sp, -sp], [-sp, sp], [sp, sp]]; offsets.forEach(([dx, dy], i) => { x.save(); x.translate(cx + dx, cy + dy); - x.scale(alpha, alpha); + const scale = alpha * COMPACT.miniGridScale; + x.scale(scale, scale); x.fillStyle = MINI_COLORS[i]; x.fill(mochiPath(5.3, 4)); x.restore(); @@ -497,11 +601,10 @@ export class Greeting { this.tc = Number.POSITIVE_INFINITY; this.fired = false; this.cancelTimers(); - this.timers.push( - window.setTimeout(() => Sound.play("greet"), T.pop0 * 1000), - window.setTimeout(() => Sound.play("blip"), T.badge * 1000), - window.setTimeout(() => this.fire(), (T.end + 0.05) * 1000), - ); + // The score plays from the start; it outlives greetComplete a little on + // purpose and fades when the greeting view goes away (see leave()). + Sound.play(GREETING_SOUND); + this.timers.push(window.setTimeout(() => this.fire(), (T.end + 0.05) * 1000)); } /** Mouse entered the island during the greeting — hold it open. */ @@ -514,6 +617,13 @@ export class Greeting { const t = (performance.now() - this.startMs) / 1000; if (!Number.isFinite(this.tc) || this.tc > t) this.tc = t; this.cancelTimers(); + Sound.fadeOut(GREETING_SOUND, 0.25); + } + + /** The greeting view is gone: let what is left of its sound fade away. */ + leave() { + this.cancelTimers(); + Sound.fadeOut(GREETING_SOUND, 0.2); } get elapsed(): number { @@ -540,8 +650,8 @@ export class Greeting { const t = this.elapsed; if (!this.fired && t >= T.end && this.tc >= T.autoLeave) this.fire(); - const p = pose(t, this.tc); - x.clearRect(0, 0, 640, 150); + const p = greetingPose(t, this.tc); + x.clearRect(0, 0, GREETING_W, GREETING_H); if (p.card > 0) { x.save(); diff --git a/windows/src/mochi/outfits.ts b/windows/src/mochi/outfits.ts new file mode 100644 index 000000000..87acf9725 --- /dev/null +++ b/windows/src/mochi/outfits.ts @@ -0,0 +1,1158 @@ +// Mochi's outfits, drawn in code — port of design/outfits/mochi-outfits.js (the +// Canvas reference the Mac version was built from) and of the Mac's +// NotchBuddy/Sources/CoucouKit/MochiOutfitDrawing.swift, whose fixes win where +// the two differ (front arcs found by silhouette, simplified drawing below +// R = 16, the enter/leave transitions, bunny ears). +// +// Coordinates are BotEngine's body space: origin at the body centre, y down, +// R = W × 0.3, rx = 1.14 R, ry = 0.88 R. The head is a superellipsoid whose +// horizontal radius at height y (y up, −1…1) is (1 − |y|^2.7)^(1/2.7), so its +// silhouette matches Mochi's body at yaw = pitch = 0. Accessories are seen +// slightly from above and follow the head pitch only partly, so a hat never +// flips to a top-down view. + +import { Ease } from "../core/anim"; +import type { Outfit, OutfitSelection } from "./wardrobe"; +import { SCRIPT_FONTS } from "../core/fonts"; + +const EXP = 2.7; +const VIEW_TILT = -0.3; +const ACC_PITCH = 0.4; +const EYE_W = 0.25; +const EYE_H = 0.27; +const EYE_SP = 0.37; +const EYE_P = -0.12; + +/** Below this radius the small details (ribs, dots, gems) are left out. */ +const SIMPLIFY_BELOW_R = 16; + +type Ctx = CanvasRenderingContext2D; +type Vec3 = readonly [number, number, number]; +interface P3 { x: number; y: number; z: number } +interface Pt { x: number; y: number } + +/** Head geometry + the spring lag of the soft parts (−1…1, in head units). */ +export interface Head { + R: number; + rx: number; + ry: number; + yaw: number; + pitch: number; + physDx: number; + physDy: number; +} + +export function makeHead(R: number, yaw = 0, pitch = 0, physDx = 0, physDy = 0): Head { + return { R, rx: R * 1.14, ry: R * 0.88, yaw, pitch, physDx, physDy }; +} + +// ── 3D helpers ──────────────────────────────────────────────────────────────── + +/** Radius of the horizontal ring of the head at height y. */ +function ringR(y: number): number { + const a = Math.min(1, Math.abs(y)); + return Math.pow(1 - Math.pow(a, EXP), 1 / EXP); +} + +/** Head-local point (x right, y up, z toward the viewer) → body space, with depth. */ +function proj(H: Head, p: Vec3): P3 { + const [x, y, z] = p; + const cy = Math.cos(H.yaw); + const sy = Math.sin(H.yaw); + const x1 = x * cy + z * sy; + const z1 = -x * sy + z * cy; + const pitch = VIEW_TILT + H.pitch * ACC_PITCH; + const cp = Math.cos(pitch); + const sp = Math.sin(pitch); + const y2 = y * cp + z1 * sp; + const z2 = -y * sp + z1 * cp; + return { x: x1 * H.rx, y: -y2 * H.ry, z: z2 }; +} + +/** Point on the head surface at height y, longitude lon (0 = facing the viewer), scaled by s. */ +function surf(y: number, lon: number, s = 1): Vec3 { + const r = ringR(y) * s; + return [r * Math.sin(lon), y, r * Math.cos(lon)]; +} + +/** The visible half of a projected closed ring, left → right, cut at its silhouette. */ +function frontSilhouette(pts: P3[]): P3[] { + const n = pts.length; + if (n < 2) return pts; + let minI = 0; + let maxI = 0; + for (let i = 1; i < n; i++) { + if (pts[i].x < pts[minI].x) minI = i; + if (pts[i].x > pts[maxI].x) maxI = i; + } + if (minI === maxI) return [pts[minI]]; + const walk = (step: number) => { + const out: P3[] = []; + let i = minI; + for (;;) { + out.push(pts[i]); + if (i === maxI || out.length > n) break; + i = (i + step + n) % n; + } + return out; + }; + const a = walk(1); + const b = walk(-1); + const za = a.reduce((s, q) => s + q.z, 0) / a.length; + const zb = b.reduce((s, q) => s + q.z, 0) / b.length; + return za >= zb ? a : b; +} + +function ring(H: Head, y: number, s: number, n = 120): P3[] { + const pts: P3[] = []; + for (let i = 0; i < n; i++) pts.push(proj(H, surf(y, -Math.PI + (i / n) * 2 * Math.PI, s))); + return pts; +} + +/** Front arc of the ring at height y, ordered left → right. */ +function frontArc(H: Head, y: number, s: number): P3[] { + return frontSilhouette(ring(H, y, s)); +} + +/** The part of the head above the front arc of ring y — what a cap covers. */ +function capClip(H: Head, y: number, s: number, extraTop = 3): Path2D { + const arc = frontArc(H, y, s); + const p = new Path2D(); + if (arc.length === 0) return p; + p.moveTo(arc[0].x - H.rx, arc[0].y); + for (const q of arc) p.lineTo(q.x, q.y); + const last = arc[arc.length - 1]; + p.lineTo(last.x + H.rx, last.y); + p.lineTo(H.rx * 2, -H.ry * extraTop); + p.lineTo(-H.rx * 2, -H.ry * extraTop); + p.closePath(); + return p; +} + +/** Everything but `p`, for an even-odd clip. */ +function invert(p: Path2D, H: Head): Path2D { + const q = new Path2D(); + q.rect(-H.rx * 4, -H.ry * 4, H.rx * 8, H.ry * 8); + q.addPath(p); + return q; +} + +/** A big rectangle: fills whatever the current clip lets through. */ +function fillAll(ctx: Ctx, H: Head) { + ctx.fillRect(-H.rx * 4, -H.ry * 4, H.rx * 8, H.ry * 8); +} + +/** Mochi's body outline, same superellipse as the engine. */ +export function bodyOutline(rx: number, ry: number): Path2D { + const p = new Path2D(); + const n = 96; + const e = 2 / EXP; + for (let i = 0; i <= n; i++) { + const a = (i / n) * Math.PI * 2; + const ca = Math.cos(a); + const sa = Math.sin(a); + const x = rx * Math.sign(ca) * Math.pow(Math.abs(ca), e); + const y = ry * Math.sign(sa) * Math.pow(Math.abs(sa), e); + if (i === 0) p.moveTo(x, y); + else p.lineTo(x, y); + } + p.closePath(); + return p; +} + +function lin(ctx: Ctx, x0: number, y0: number, x1: number, y1: number, stops: [number, string][]) { + const g = ctx.createLinearGradient(x0, y0, x1, y1); + for (const [o, c] of stops) g.addColorStop(o, c); + return g; +} + +function rad(ctx: Ctx, x: number, y: number, r0: number, r1: number, stops: [number, string][]) { + const g = ctx.createRadialGradient(x, y, r0, x, y, Math.max(0.001, r1)); + for (const [o, c] of stops) g.addColorStop(o, c); + return g; +} + +function polyline(ctx: Ctx, pts: Pt[]) { + ctx.beginPath(); + pts.forEach((q, i) => (i ? ctx.lineTo(q.x, q.y) : ctx.moveTo(q.x, q.y))); +} + +function roundRect(ctx: Ctx, x: number, y: number, w: number, h: number, r: number) { + const rr = Math.max(0, Math.min(r, w / 2, h / 2)); + ctx.beginPath(); + ctx.moveTo(x + rr, y); + ctx.arcTo(x + w, y, x + w, y + h, rr); + ctx.arcTo(x + w, y + h, x, y + h, rr); + ctx.arcTo(x, y + h, x, y, rr); + ctx.arcTo(x, y, x + w, y, rr); + ctx.closePath(); +} + +// ── Eyes ────────────────────────────────────────────────────────────────────── + +export interface EyeFrame { + sd: number; + x: number; + y: number; + fx: number; + fy: number; + visible: boolean; + w: number; + h: number; +} + +/** Where the engine draws the eyes — the glasses sit on these. */ +export function eyeFrames(H: Head): EyeFrame[] { + return [-1, 1].map((sd) => { + const eyeYaw = sd * EYE_SP + H.yaw; + const eyePitch = EYE_P + H.pitch; + const cp = Math.cos(eyePitch); + return { + sd, + visible: Math.cos(eyeYaw) * cp > 0.04, + x: Math.sin(eyeYaw) * cp * H.rx, + y: -Math.sin(eyePitch) * H.ry, + fx: Math.max(0.18, Math.cos(eyeYaw)), + fy: Math.max(0.18, cp), + w: H.R * EYE_W, + h: H.R * EYE_H, + }; + }); +} + +// ── Soft bits ───────────────────────────────────────────────────────────────── + +function pompom(ctx: Ctx, x: number, y: number, r: number, base = "#FFFFFF", shade = "#D5D9E2") { + ctx.save(); + ctx.translate(x, y); + const n = 11; + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2; + const br = r * (0.34 + 0.06 * Math.sin(i * 2.3)); + const bx = Math.cos(a) * r * 0.78; + const by = Math.sin(a) * r * 0.78; + ctx.fillStyle = rad(ctx, bx - br * 0.4, by - br * 0.5, 0, br * 1.3, [[0, base], [1, shade]]); + ctx.beginPath(); + ctx.arc(bx, by, br, 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = rad(ctx, -r * 0.3, -r * 0.35, 0, r * 1.05, [[0, base], [0.7, base], [1, shade]]); + ctx.beginPath(); + ctx.arc(0, 0, r * 0.86, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +/** Fuzzy band along a polyline (the Santa hat's trim). */ +function fuzzyBand(ctx: Ctx, arc: Pt[], thick: number, base = "#FFFFFF", shade = "#DADDE4") { + if (arc.length < 2) return; + ctx.save(); + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + polyline(ctx, arc); + ctx.strokeStyle = shade; + ctx.lineWidth = thick; + ctx.stroke(); + polyline(ctx, arc); + ctx.strokeStyle = base; + ctx.lineWidth = thick * 0.78; + ctx.stroke(); + const step = Math.max(2, Math.floor(arc.length / 16)); + for (let i = 0; i < arc.length; i += step) { + const q = arc[i]; + const r = thick * (0.32 + 0.1 * Math.sin(i * 1.7)); + ctx.fillStyle = rad(ctx, q.x - r * 0.3, q.y - thick * 0.35 - r * 0.3, 0, r * 1.2, [[0, base], [1, shade]]); + ctx.beginPath(); + ctx.arc(q.x, q.y - thick * 0.32, r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); +} + +// ── Beanie ──────────────────────────────────────────────────────────────────── + +function beanie(ctx: Ctx, H: Head, body: Path2D, simple: boolean) { + const s = 1.035; + const yEdge = 0.42; + const yCuff = 0.58; + const head = bodyOutline(H.rx * s, H.ry * s); + + // shadow on the head under the cuff + ctx.save(); + ctx.clip(body); + ctx.clip(capClip(H, yEdge - 0.12, 1)); + ctx.fillStyle = "rgba(30,40,70,0.10)"; + fillAll(ctx, H); + ctx.restore(); + + // knit body + ctx.save(); + ctx.clip(capClip(H, yCuff, s)); + ctx.fillStyle = lin(ctx, H.rx * 0.5, -H.ry * 1.1, -H.rx * 0.6, H.ry * 0.2, [[0, "#7DB6FF"], [1, "#2F6FE0"]]); + ctx.fill(head); + if (!simple) { + ctx.clip(head); + ctx.strokeStyle = "rgba(20,50,140,0.16)"; + ctx.lineWidth = H.R * 0.045; + ctx.lineCap = "round"; + for (let k = -6; k <= 6; k++) { + const lon = k * 0.24; + const pts: P3[] = []; + for (let i = 0; i <= 16; i++) { + const q = proj(H, surf(yCuff + ((1.05 - yCuff) * i) / 16, lon, s)); + if (q.z > 0) pts.push(q); + } + if (pts.length < 2) continue; + polyline(ctx, pts); + ctx.stroke(); + } + } + ctx.restore(); + + // cuff — the band between yEdge and yCuff + const cuffHead = bodyOutline(H.rx * s * 1.04, H.ry * s * 1.04); + ctx.save(); + ctx.clip(capClip(H, yEdge, s * 1.04)); + ctx.clip(invert(capClip(H, yCuff, s * 1.04), H), "evenodd"); + ctx.fillStyle = lin(ctx, 0, -H.ry * 0.6, 0, -H.ry * 0.2, [[0, "#3C7BEA"], [1, "#2257C4"]]); + ctx.fill(cuffHead); + ctx.clip(cuffHead); + ctx.strokeStyle = "rgba(10,30,100,0.22)"; + ctx.lineWidth = H.R * 0.035; + for (let k = -14; k <= 14; k++) { + const lon = k * 0.115; + const a = proj(H, surf(yEdge, lon, s * 1.04)); + const b = proj(H, surf(yCuff, lon, s * 1.04)); + if (a.z < 0) continue; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + ctx.restore(); + + // top highlight + ctx.save(); + ctx.clip(capClip(H, yCuff, s)); + ctx.clip(head); + ctx.fillStyle = rad(ctx, H.rx * 0.3, -H.ry * 0.85, 0, H.R * 0.45, [[0, "rgba(255,255,255,0.35)"], [1, "rgba(255,255,255,0)"]]); + ctx.fill(head); + ctx.restore(); + + // pompom on a short spring + const top = proj(H, [0, 1.08 * s, 0]); + pompom(ctx, top.x + H.physDx * H.rx * 0.25, top.y - H.R * 0.12 + H.physDy * H.ry * 0.15, H.R * 0.24); +} + +// ── Santa hat ───────────────────────────────────────────────────────────────── + +function santaHat(ctx: Ctx, H: Head, body: Path2D) { + const s = 1.05; + const yEdge = 0.52; + const arc = frontArc(H, yEdge, s); + if (arc.length === 0) return; + const L = arc[0]; + const Rt = arc[arc.length - 1]; + const crown = proj(H, [0, 1.05, 0]); + // the tip flops to the right and down, plus the spring lag + const tip = { x: crown.x + H.rx * (0.95 + H.physDx * 0.35), y: crown.y + H.ry * (0.05 + H.physDy * 0.2) }; + const peak = { x: crown.x + H.rx * 0.25, y: crown.y - H.ry * 0.62 }; + const bag = new Path2D(); + bag.moveTo(L.x, L.y); + bag.bezierCurveTo(L.x - H.rx * 0.05, L.y - H.ry * 0.7, peak.x - H.rx * 0.55, peak.y - H.ry * 0.05, peak.x, peak.y); + bag.quadraticCurveTo(tip.x - H.rx * 0.05, peak.y - H.ry * 0.02, tip.x, tip.y); + bag.quadraticCurveTo(tip.x - H.rx * 0.12, tip.y - H.ry * 0.22, peak.x + H.rx * 0.18, peak.y + H.ry * 0.32); + bag.bezierCurveTo(Rt.x + H.rx * 0.05, peak.y + H.ry * 0.45, Rt.x + H.rx * 0.08, Rt.y - H.ry * 0.35, Rt.x, Rt.y); + for (let i = arc.length - 1; i >= 0; i--) bag.lineTo(arc[i].x, arc[i].y); + bag.closePath(); + + ctx.save(); + ctx.clip(body); + ctx.clip(capClip(H, yEdge - 0.14, 1)); + ctx.fillStyle = "rgba(120,10,10,0.10)"; + fillAll(ctx, H); + ctx.restore(); + + ctx.fillStyle = lin(ctx, -H.rx * 0.6, -H.ry * 1.6, H.rx * 0.7, -H.ry * 0.3, [[0, "#FF6B6B"], [0.55, "#E53935"], [1, "#B71C1C"]]); + ctx.fill(bag); + + // folds following the flop + ctx.save(); + ctx.clip(bag); + ctx.lineCap = "round"; + ctx.strokeStyle = "rgba(90,0,0,0.20)"; + for (const [a, b, w] of [[0.15, 0.55, 0.1], [0.45, 0.85, 0.08]]) { + ctx.beginPath(); + ctx.moveTo(peak.x - H.rx * 0.1 + (Rt.x - L.x) * a * 0.3, peak.y + H.ry * 0.15); + ctx.quadraticCurveTo(peak.x + H.rx * 0.35, peak.y + H.ry * (0.05 + a * 0.3), tip.x - H.rx * (0.45 - b * 0.3), tip.y - H.ry * 0.12); + ctx.lineWidth = H.R * w; + ctx.stroke(); + } + ctx.fillStyle = rad(ctx, peak.x - H.rx * 0.25, peak.y + H.ry * 0.05, 0, H.R * 0.5, [[0, "rgba(255,255,255,0.32)"], [1, "rgba(255,255,255,0)"]]); + ctx.fill(bag); + ctx.restore(); + + fuzzyBand(ctx, arc, H.R * 0.3); + pompom(ctx, tip.x, tip.y + H.R * 0.04, H.R * 0.22); +} + +// ── Party hat ───────────────────────────────────────────────────────────────── + +function partyHat(ctx: Ctx, H: Head, simple: boolean) { + const baseY = 0.82; + const baseR = 0.42; + const lean = -0.24 + H.physDx * 0.12; + const c = proj(H, [0.16, baseY + 0.06, 0]); + const rim: P3[] = []; + for (let i = 0; i <= 48; i++) { + const a = (i / 48) * Math.PI * 2; + rim.push(proj(H, [0.16 + baseR * Math.sin(a), baseY + 0.06, baseR * Math.cos(a)])); + } + const left = rim.reduce((m, q) => (q.x < m.x ? q : m)); + const right = rim.reduce((m, q) => (q.x > m.x ? q : m)); + const h = H.ry * 1.6; + const apex = { x: c.x + Math.sin(lean) * h, y: c.y - Math.cos(lean) * h }; + const front = frontSilhouette(rim); + + const cone = new Path2D(); + cone.moveTo(left.x, left.y); + cone.quadraticCurveTo((left.x + apex.x) / 2 - H.rx * 0.06, (left.y + apex.y) / 2, apex.x - H.R * 0.05, apex.y + H.R * 0.06); + cone.quadraticCurveTo(apex.x, apex.y - H.R * 0.03, apex.x + H.R * 0.05, apex.y + H.R * 0.06); + cone.quadraticCurveTo((right.x + apex.x) / 2 + H.rx * 0.06, (right.y + apex.y) / 2, right.x, right.y); + for (let i = front.length - 1; i >= 0; i--) cone.lineTo(front[i].x, front[i].y); + cone.closePath(); + ctx.fillStyle = lin(ctx, left.x, apex.y, right.x, left.y, [[0, "#FF9BD0"], [0.5, "#F15BAE"], [1, "#C2187A"]]); + ctx.fill(cone); + + ctx.save(); + ctx.clip(cone); + if (!simple) { + ctx.fillStyle = "rgba(255,255,255,0.92)"; + const dots = [[0.25, -0.35], [0.3, 0.3], [0.55, -0.05], [0.72, 0.28], [0.8, -0.3], [0.45, 0.6], [0.48, -0.65]]; + for (const [t, u] of dots) { + const bx = left.x + (right.x - left.x) * (0.5 + u * 0.5); + const by = left.y + (right.y - left.y) * (0.5 + u * 0.5); + const x = bx + (apex.x - bx) * (1 - t); + const y = by + (apex.y - by) * (1 - t); + const r = H.R * 0.075 * (0.6 + t * 0.5); + ctx.beginPath(); + ctx.ellipse(x, y, r, r * 0.9, 0, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.fillStyle = lin(ctx, left.x, 0, right.x, 0, [[0, "rgba(255,255,255,0.28)"], [0.35, "rgba(255,255,255,0)"], [1, "rgba(80,0,40,0.18)"]]); + ctx.fill(cone); + ctx.restore(); + + if (front.length > 1) { + polyline(ctx, front); + ctx.strokeStyle = "#FFD84D"; + ctx.lineWidth = H.R * 0.07; + ctx.lineCap = "round"; + ctx.stroke(); + } + pompom(ctx, apex.x, apex.y - H.R * 0.04, H.R * 0.16, "#FFE27A", "#F2B705"); +} + +// ── Crown ───────────────────────────────────────────────────────────────────── + +const CROWN_YB = 0.46; + +/** side −1: the back half, behind the head; +1: the front half. */ +function crownPart(ctx: Ctx, H: Head, side: number, simple: boolean) { + const s = 1.06; + const yb = CROWN_YB; + const yt = 0.66; + const n = 8; + const spikeH = 0.42; + const N = 120; + const seg: { b: P3; tt: P3; z: number }[] = []; + for (let i = 0; i <= N; i++) { + const lon = -Math.PI + (i / N) * 2 * Math.PI; + const b = proj(H, surf(yb, lon, s)); + const phase = ((lon + Math.PI) / (2 * Math.PI)) * n; + const f = phase - Math.floor(phase); + const spike = Math.pow(Math.max(0, 1 - Math.abs(f - 0.5) * 2), 1.6); + const sp = surf(yt, lon, s); + const tt = proj(H, [sp[0] * (1 - 0.08 * spike), yt + spikeH * spike, sp[2] * (1 - 0.08 * spike)]); + seg.push({ b, tt, z: b.z }); + } + const keep = seg.filter((q) => (side > 0 ? q.z >= 0 : q.z < 0.02)); + if (keep.length < 2) return; + keep.sort((a, b) => a.b.x - b.b.x); + const shape = new Path2D(); + keep.forEach((q, i) => (i ? shape.lineTo(q.tt.x, q.tt.y) : shape.moveTo(q.tt.x, q.tt.y))); + for (let i = keep.length - 1; i >= 0; i--) shape.lineTo(keep[i].b.x, keep[i].b.y); + shape.closePath(); + + const dark = side < 0; + ctx.fillStyle = lin(ctx, 0, -H.ry * 1.05, 0, -H.ry * 0.45, dark + ? [[0, "#C98A12"], [1, "#8A5A06"]] + : [[0, "#FFE58A"], [0.5, "#FBBF24"], [1, "#D08A0B"]]); + ctx.fill(shape); + if (dark) return; + + ctx.save(); + ctx.clip(shape); + ctx.fillStyle = lin(ctx, -H.rx, 0, H.rx, 0, [ + [0, "rgba(120,70,0,0.25)"], [0.45, "rgba(255,255,255,0)"], + [0.62, "rgba(255,255,255,0.35)"], [1, "rgba(120,70,0,0.25)"], + ]); + ctx.fill(shape); + ctx.restore(); + if (simple) return; + + const gems = ["#EF4444", "#3B82F6", "#22C55E", "#A855F7"]; + for (let k = 0; k < n; k++) { + const lon = -Math.PI + ((k + 0.5) / n) * 2 * Math.PI; + const sp = surf(yt, lon, s); + const tipP = proj(H, [sp[0] * 0.92, yt + spikeH, sp[2] * 0.92]); + const mid = proj(H, surf((yb + yt) / 2, lon, s * 1.01)); + if (mid.z <= 0.12) continue; + const r = H.R * 0.055; + ctx.beginPath(); + ctx.arc(tipP.x, tipP.y - r * 0.5, r, 0, Math.PI * 2); + ctx.fillStyle = rad(ctx, tipP.x - r * 0.3, tipP.y - r, 0, r * 1.2, [[0, "#FFF6CC"], [1, "#E0A21A"]]); + ctx.fill(); + const gr = H.R * 0.075; + ctx.beginPath(); + ctx.ellipse(mid.x, mid.y, gr * Math.max(0.35, mid.z), gr, 0, 0, Math.PI * 2); + ctx.fillStyle = gems[k % gems.length]; + ctx.fill(); + ctx.beginPath(); + ctx.arc(mid.x - gr * 0.25 * mid.z, mid.y - gr * 0.35, gr * 0.28, 0, Math.PI * 2); + ctx.fillStyle = "rgba(255,255,255,0.75)"; + ctx.fill(); + } +} + +function crownFront(ctx: Ctx, H: Head, body: Path2D, simple: boolean) { + // shadow of the band on the head + ctx.save(); + ctx.clip(body); + ctx.clip(capClip(H, CROWN_YB - 0.1, 1)); + ctx.clip(invert(capClip(H, CROWN_YB, 1), H), "evenodd"); + ctx.fillStyle = "rgba(80,50,0,0.12)"; + fillAll(ctx, H); + ctx.restore(); + crownPart(ctx, H, 1, simple); +} + +// ── Witch hat ───────────────────────────────────────────────────────────────── + +function witchBrim(H: Head): P3[] { + const y = 0.7; + const rr = 1.42; + const pts: P3[] = []; + for (let i = 0; i <= 120; i++) { + const a = -Math.PI + (i / 120) * 2 * Math.PI; + const wob = 1 + 0.035 * Math.sin(a * 3 + 0.6); + const droop = -0.1 * Math.pow(Math.abs(Math.sin(a)), 2); // the edges droop a little + pts.push(proj(H, [rr * wob * Math.sin(a), y + droop, rr * wob * Math.cos(a)])); + } + return pts; +} + +function closedPath(pts: Pt[]): Path2D { + const p = new Path2D(); + pts.forEach((q, i) => (i ? p.lineTo(q.x, q.y) : p.moveTo(q.x, q.y))); + p.closePath(); + return p; +} + +/** The whole brim, behind the head; front() paints its front half again. */ +function witchHatBack(ctx: Ctx, H: Head) { + ctx.fillStyle = lin(ctx, 0, -H.ry, 0, -H.ry * 0.4, [[0, "#2A0A4F"], [1, "#3B0F6B"]]); + ctx.fill(closedPath(witchBrim(H))); +} + +function witchHatFront(ctx: Ctx, H: Head, body: Path2D) { + const all = witchBrim(H); + const brim = closedPath(all); + const fr = all.filter((p) => p.z >= 0).sort((a, b) => a.x - b.x); + + ctx.save(); + ctx.clip(body); + ctx.clip(capClip(H, 0.5, 1)); + ctx.fillStyle = "rgba(40,0,70,0.10)"; + fillAll(ctx, H); + ctx.restore(); + + ctx.fillStyle = lin(ctx, 0, -H.ry * 0.9, 0, -H.ry * 0.3, [[0, "#5B21B6"], [1, "#3B0764"]]); + ctx.fill(brim); + if (fr.length > 1) { + polyline(ctx, fr); + ctx.strokeStyle = "rgba(190,150,255,0.35)"; + ctx.lineWidth = H.R * 0.035; + ctx.lineCap = "round"; + ctx.stroke(); + } + + // cone: base ring r = 0.62 at y = 0.74, tall apex, the tip bends over + const baseR = 0.62; + const by = 0.74; + const bl = proj(H, [-baseR, by, 0]); + const br = proj(H, [baseR, by, 0]); + const c = proj(H, [0, by, 0]); + const lean = 0.1 + H.physDx * 0.15; + const top = { x: c.x + H.rx * 0.18 + Math.sin(lean) * H.ry * 0.3, y: c.y - H.ry * 1.25 }; + const tip = { x: top.x + H.rx * (0.45 + H.physDx * 0.25), y: top.y + H.ry * (0.22 + H.physDy * 0.1) }; + const capFront = frontArc(H, by, baseR / ringR(by)).filter((q) => q.x >= bl.x - 1 && q.x <= br.x + 1); + const cone = new Path2D(); + cone.moveTo(bl.x, bl.y); + cone.bezierCurveTo(bl.x + H.rx * 0.12, bl.y - H.ry * 0.5, top.x - H.rx * 0.28, top.y + H.ry * 0.25, top.x - H.rx * 0.02, top.y - H.ry * 0.02); + cone.quadraticCurveTo(top.x + H.rx * 0.25, top.y - H.ry * 0.08, tip.x, tip.y); + cone.quadraticCurveTo(top.x + H.rx * 0.22, top.y + H.ry * 0.08, top.x + H.rx * 0.14, top.y + H.ry * 0.22); + cone.bezierCurveTo(br.x - H.rx * 0.18, c.y - H.ry * 0.45, br.x - H.rx * 0.02, br.y - H.ry * 0.2, br.x, br.y); + for (let i = capFront.length - 1; i >= 0; i--) cone.lineTo(capFront[i].x, capFront[i].y); + cone.closePath(); + ctx.fillStyle = lin(ctx, bl.x, top.y, br.x, bl.y, [[0, "#7C3AED"], [0.55, "#4C1D95"], [1, "#2E1065"]]); + ctx.fill(cone); + + ctx.save(); + ctx.clip(cone); + ctx.fillStyle = lin(ctx, bl.x, 0, br.x, 0, [[0, "rgba(255,255,255,0.22)"], [0.4, "rgba(255,255,255,0)"], [1, "rgba(0,0,0,0.15)"]]); + ctx.fill(cone); + // crease where the tip bends + ctx.beginPath(); + ctx.moveTo(top.x - H.rx * 0.05, top.y + H.ry * 0.05); + ctx.quadraticCurveTo(top.x + H.rx * 0.1, top.y + H.ry * 0.12, top.x + H.rx * 0.2, top.y + H.ry * 0.06); + ctx.strokeStyle = "rgba(20,0,40,0.35)"; + ctx.lineWidth = H.R * 0.05; + ctx.lineCap = "round"; + ctx.stroke(); + // orange band, just above the base + const fc = proj(H, [0, by, baseR]); + const lift = H.ry * 0.11; + ctx.beginPath(); + ctx.moveTo(bl.x - 2, bl.y - lift); + ctx.quadraticCurveTo(fc.x, 2 * (fc.y - lift) - (bl.y + br.y) / 2, br.x + 2, br.y - lift); + ctx.strokeStyle = "#F97316"; + ctx.lineWidth = H.ry * 0.17; + ctx.lineCap = "butt"; + ctx.stroke(); + ctx.restore(); + + // buckle + const bw = H.R * 0.2; + const bh = H.R * 0.16; + ctx.save(); + ctx.translate(fc.x, fc.y - H.ry * 0.11); + roundRect(ctx, -bw / 2, -bh / 2, bw, bh, bh * 0.25); + ctx.fillStyle = "#FCD34D"; + ctx.fill(); + roundRect(ctx, -bw / 2 + bw * 0.24, -bh / 2 + bh * 0.28, bw * 0.52, bh * 0.44, bh * 0.1); + ctx.fillStyle = "#C2410C"; + ctx.fill(); + ctx.restore(); +} + +// ── Glasses (pinned to the real eye positions) ──────────────────────────────── + +function sunglasses(ctx: Ctx, H: Head, body: Path2D) { + const eyes = eyeFrames(H); + const w = H.R * 0.62; + const h = H.R * 0.46; + ctx.save(); + ctx.clip(body); + ctx.strokeStyle = "#111317"; + ctx.lineCap = "round"; + const [l, r] = eyes; + if (l.visible && r.visible) { + ctx.beginPath(); + ctx.moveTo(l.x + (w / 2) * l.fx * 0.9, l.y - h * 0.18); + ctx.quadraticCurveTo((l.x + r.x) / 2, (l.y + r.y) / 2 - h * 0.42, r.x - (w / 2) * r.fx * 0.9, r.y - h * 0.18); + ctx.lineWidth = H.R * 0.07; + ctx.stroke(); + } + ctx.lineWidth = H.R * 0.06; + for (const e of eyes) { + if (!e.visible) continue; + ctx.beginPath(); + ctx.moveTo(e.x + (e.sd * w) / 2 * e.fx, e.y - h * 0.2); + ctx.lineTo(e.sd * H.rx * 1.05, e.y - h * 0.35); + ctx.stroke(); + } + for (const e of eyes) { + if (!e.visible) continue; + ctx.save(); + ctx.translate(e.x, e.y); + ctx.scale(e.fx, e.fy); + roundRect(ctx, -w / 2, -h / 2, w, h, h * 0.42); + ctx.fillStyle = "rgba(17,19,23,0.82)"; + ctx.fill(); + ctx.lineWidth = H.R * 0.05; + ctx.strokeStyle = "#0B0C0F"; + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(-w * 0.28, -h * 0.05); + ctx.lineTo(-w * 0.05, -h * 0.3); + ctx.strokeStyle = "rgba(255,255,255,0.45)"; + ctx.stroke(); + ctx.restore(); + } + ctx.restore(); +} + +function roundGlasses(ctx: Ctx, H: Head, body: Path2D) { + const eyes = eyeFrames(H); + const d = H.R * 0.56; + ctx.save(); + ctx.clip(body); + ctx.strokeStyle = "#8A4B12"; + ctx.lineCap = "round"; + const [l, r] = eyes; + if (l.visible && r.visible) { + ctx.beginPath(); + ctx.moveTo(l.x + (d / 2) * l.fx, l.y - d * 0.08); + ctx.quadraticCurveTo((l.x + r.x) / 2, (l.y + r.y) / 2 - d * 0.3, r.x - (d / 2) * r.fx, r.y - d * 0.08); + ctx.lineWidth = H.R * 0.055; + ctx.stroke(); + } + ctx.lineWidth = H.R * 0.05; + for (const e of eyes) { + if (!e.visible) continue; + ctx.beginPath(); + ctx.moveTo(e.x + (e.sd * d) / 2 * e.fx, e.y - d * 0.1); + ctx.lineTo(e.sd * H.rx * 1.05, e.y - d * 0.25); + ctx.stroke(); + } + for (const e of eyes) { + if (!e.visible) continue; + ctx.save(); + ctx.translate(e.x, e.y); + ctx.scale(e.fx, e.fy); + ctx.beginPath(); + ctx.arc(0, 0, d / 2, 0, Math.PI * 2); + ctx.fillStyle = "rgba(190,225,255,0.18)"; + ctx.fill(); + ctx.lineWidth = H.R * 0.065; + ctx.strokeStyle = "#9A5A1A"; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(0, 0, d / 2 - H.R * 0.03, Math.PI * 1.1, Math.PI * 1.45); + ctx.strokeStyle = "rgba(255,255,255,0.55)"; + ctx.lineWidth = H.R * 0.03; + ctx.stroke(); + ctx.restore(); + } + ctx.restore(); +} + +// ── Scarf ───────────────────────────────────────────────────────────────────── + +function scarf(ctx: Ctx, H: Head) { + const s = 1.05; + const y0 = -0.34; + const y1 = -0.66; + const top = frontArc(H, y0, s); + const bot = frontArc(H, y1, s); + if (top.length === 0 || bot.length === 0) return; + const band = new Path2D(); + top.forEach((q, i) => (i ? band.lineTo(q.x, q.y) : band.moveTo(q.x, q.y))); + for (let i = bot.length - 1; i >= 0; i--) band.lineTo(bot[i].x, bot[i].y); + band.closePath(); + + ctx.save(); + ctx.clip(bodyOutline(H.rx * s, H.ry * s)); + ctx.fillStyle = lin(ctx, 0, -H.ry * 0.2, 0, H.ry * 0.7, [[0, "#F87171"], [1, "#B91C1C"]]); + ctx.fill(band); + ctx.save(); + ctx.clip(band); + ctx.strokeStyle = "rgba(255,255,255,0.85)"; + ctx.lineCap = "round"; + for (const lon of [-1.0, -0.45, 0.1, 0.65, 1.2]) { + const a = proj(H, surf(y0, lon, s)); + const b = proj(H, surf(y1, lon, s)); + if (a.z < 0) continue; + ctx.beginPath(); + ctx.moveTo(a.x, a.y - 4); + ctx.lineTo(b.x, b.y + 4); + ctx.lineWidth = H.R * 0.09 * Math.max(0.3, a.z); + ctx.stroke(); + } + ctx.restore(); + ctx.fillStyle = lin(ctx, 0, -H.ry * 0.5, 0, H.ry * 0.3, [[0, "rgba(255,255,255,0.18)"], [1, "rgba(0,0,0,0.1)"]]); + ctx.fill(band); + ctx.restore(); + + // the hanging end, from the knot + const k = proj(H, surf((y0 + y1) / 2, -0.55, s * 1.03)); + if (k.z <= 0) return; + const sw = H.physDx * H.rx * 0.12; + const end = new Path2D(); + end.moveTo(k.x - H.R * 0.16, k.y); + end.quadraticCurveTo(k.x - H.R * 0.24 + sw, k.y + H.ry * 0.35, k.x - H.R * 0.2 + sw * 1.4, k.y + H.ry * 0.62); + end.lineTo(k.x + H.R * 0.06 + sw * 1.4, k.y + H.ry * 0.6); + end.quadraticCurveTo(k.x + H.R * 0.02 + sw, k.y + H.ry * 0.3, k.x + H.R * 0.12, k.y); + end.closePath(); + ctx.fillStyle = lin(ctx, 0, k.y, 0, k.y + H.ry * 0.6, [[0, "#EF4444"], [1, "#B91C1C"]]); + ctx.fill(end); + ctx.save(); + ctx.clip(end); + ctx.fillStyle = "rgba(255,255,255,0.85)"; + for (const t of [0.35, 0.7]) ctx.fillRect(k.x - H.R * 0.4 + sw, k.y + H.ry * 0.62 * t, H.R * 0.8, H.R * 0.07); + ctx.restore(); + // fringe + ctx.strokeStyle = "#DC2626"; + ctx.lineWidth = H.R * 0.035; + ctx.lineCap = "round"; + for (let i = 0; i < 4; i++) { + const fx = k.x - H.R * 0.17 + sw * 1.4 + i * H.R * 0.075; + ctx.beginPath(); + ctx.moveTo(fx, k.y + H.ry * 0.6); + ctx.lineTo(fx, k.y + H.ry * 0.72); + ctx.stroke(); + } + // knot + ctx.beginPath(); + ctx.ellipse(k.x, k.y, H.R * 0.17, H.R * 0.14, 0.2, 0, Math.PI * 2); + ctx.fillStyle = rad(ctx, k.x - H.R * 0.05, k.y - H.R * 0.05, 0, H.R * 0.2, [[0, "#F87171"], [1, "#B91C1C"]]); + ctx.fill(); +} + +// ── Pumpkin (the body colours come from the engine) ─────────────────────────── + +export const PUMPKIN_BODY: readonly [string, string] = ["#FFA94D", "#E8590C"]; + +function pumpkin(ctx: Ctx, H: Head, body: Path2D, simple: boolean) { + if (!simple) { + ctx.save(); + ctx.clip(body); + ctx.lineCap = "round"; + for (const lon of [-1.15, -0.55, 0.0, 0.55, 1.15]) { + const pts: P3[] = []; + for (let i = 0; i <= 30; i++) { + const q = proj(H, surf(-0.98 + (1.96 * i) / 30, lon, 1)); + if (q.z > 0) pts.push(q); + } + if (pts.length < 2) continue; + const zz = pts[Math.floor(pts.length / 2)].z; + polyline(ctx, pts); + ctx.strokeStyle = `rgba(150,50,0,${0.22 * zz})`; + ctx.lineWidth = H.R * 0.12; + ctx.stroke(); + polyline(ctx, pts.map((q) => ({ x: q.x + H.R * 0.07, y: q.y }))); + ctx.strokeStyle = `rgba(255,220,170,${0.18 * zz})`; + ctx.lineWidth = H.R * 0.04; + ctx.stroke(); + } + ctx.restore(); + } + const t = proj(H, [0.02, 1.0, 0]); + // stem + ctx.beginPath(); + ctx.moveTo(t.x - H.R * 0.09, t.y + H.R * 0.04); + ctx.quadraticCurveTo(t.x - H.R * 0.08, t.y - H.R * 0.22, t.x + H.R * 0.08, t.y - H.R * 0.3); + ctx.lineTo(t.x + H.R * 0.13, t.y - H.R * 0.22); + ctx.quadraticCurveTo(t.x + H.R * 0.04, t.y - H.R * 0.15, t.x + H.R * 0.08, t.y + H.R * 0.04); + ctx.closePath(); + ctx.fillStyle = lin(ctx, t.x - H.R * 0.1, 0, t.x + H.R * 0.1, 0, [[0, "#65A30D"], [1, "#3F6212"]]); + ctx.fill(); + // leaf + ctx.save(); + ctx.translate(t.x - H.R * 0.06, t.y - H.R * 0.02); + ctx.rotate(-0.5); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.quadraticCurveTo(-H.R * 0.18, -H.R * 0.2, -H.R * 0.38, -H.R * 0.02); + ctx.quadraticCurveTo(-H.R * 0.18, H.R * 0.1, 0, 0); + ctx.fillStyle = lin(ctx, 0, -H.R * 0.15, -H.R * 0.3, 0, [[0, "#84CC16"], [1, "#4D7C0F"]]); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(-H.R * 0.02, -H.R * 0.01); + ctx.quadraticCurveTo(-H.R * 0.18, -H.R * 0.08, -H.R * 0.32, -H.R * 0.03); + ctx.strokeStyle = "rgba(30,60,0,0.4)"; + ctx.lineWidth = H.R * 0.02; + ctx.lineCap = "round"; + ctx.stroke(); + ctx.restore(); + if (simple) return; + // curly tendril + ctx.beginPath(); + ctx.moveTo(t.x + H.R * 0.1, t.y - H.R * 0.12); + ctx.bezierCurveTo(t.x + H.R * 0.3, t.y - H.R * 0.25, t.x + H.R * 0.35, t.y - H.R * 0.02, t.x + H.R * 0.22, t.y - H.R * 0.06); + ctx.strokeStyle = "#4D7C0F"; + ctx.lineWidth = H.R * 0.03; + ctx.lineCap = "round"; + ctx.stroke(); +} + +// ── Bow (anchored in 3D, turns with the head) ───────────────────────────────── + +function bow(ctx: Ctx, H: Head) { + const a = proj(H, surf(0.86, 0.55, 1.02)); + if (a.z < -0.2) return; + const s = H.R * 0.26; + const sq = Math.max(0.45, Math.cos(0.55 + H.yaw)); + ctx.save(); + ctx.translate(a.x, a.y); + ctx.rotate(0.35 + H.yaw * 0.3); + ctx.scale(sq, 1); + for (const sd of [-1, 1]) { + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.bezierCurveTo(sd * s * 0.6, -s * 0.85, sd * s * 1.35, -s * 0.55, sd * s * 1.15, 0); + ctx.bezierCurveTo(sd * s * 1.35, s * 0.55, sd * s * 0.6, s * 0.85, 0, 0); + ctx.fillStyle = lin(ctx, 0, -s, 0, s, [[0, "#FF8CC6"], [1, "#DB2777"]]); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(sd * s * 0.25, -s * 0.05); + ctx.quadraticCurveTo(sd * s * 0.7, -s * 0.15, sd * s * 0.95, -s * 0.05); + ctx.strokeStyle = "rgba(140,10,70,0.35)"; + ctx.lineWidth = s * 0.08; + ctx.lineCap = "round"; + ctx.stroke(); + } + ctx.beginPath(); + ctx.ellipse(0, 0, s * 0.24, s * 0.3, 0, 0, Math.PI * 2); + ctx.fillStyle = rad(ctx, -s * 0.06, -s * 0.1, 0, s * 0.35, [[0, "#FFB3D9"], [1, "#C2185B"]]); + ctx.fill(); + ctx.restore(); +} + +// ── Bunny ears (always behind the head) ─────────────────────────────────────── + +function bunnyEars(ctx: Ctx, H: Head) { + const R = H.R; + const earH = R * 0.85; + for (const sd of [-1, 1]) { + const root = proj(H, [sd * 0.45, 0.92, 0]); + const rootL = proj(H, [sd * 0.45 - 0.22, 0.92, 0]); + const rootR = proj(H, [sd * 0.45 + 0.22, 0.92, 0]); + const hw = Math.max(R * 0.04, Math.abs(rootR.x - rootL.x) / 2); + ctx.save(); + ctx.translate(root.x, root.y - earH * 0.15); + ctx.beginPath(); + ctx.ellipse(0, 0, hw, earH / 2, 0, 0, Math.PI * 2); + ctx.fillStyle = "#F9F0F0"; + ctx.fill(); + ctx.strokeStyle = "rgba(0,0,0,0.06)"; + ctx.lineWidth = 0.8; + ctx.stroke(); + ctx.beginPath(); + ctx.ellipse(0, -earH / 2 + R * 0.1 + earH * 0.325, hw * 0.5, earH * 0.325, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(252,165,165,0.7)"; + ctx.fill(); + ctx.restore(); + } +} + +// ── Layers and transitions ──────────────────────────────────────────────────── + +let scratch: HTMLCanvasElement | null = null; + +/** + * Draws `fn` at `alpha` as one layer, so overlapping parts don't show through + * each other while an outfit fades (SwiftUI's drawLayer on macOS). + */ +function withLayer(ctx: Ctx, alpha: number, fn: (c: Ctx) => void) { + if (alpha >= 0.999) { + ctx.save(); + fn(ctx); + ctx.restore(); + return; + } + const W = ctx.canvas.width; + const Hh = ctx.canvas.height; + scratch ??= document.createElement("canvas"); + if (scratch.width < W || scratch.height < Hh) { + scratch.width = Math.max(scratch.width, W); + scratch.height = Math.max(scratch.height, Hh); + } + const s = scratch.getContext("2d"); + if (!s) return; + s.setTransform(1, 0, 0, 1, 0, 0); + s.clearRect(0, 0, W, Hh); + s.setTransform(ctx.getTransform()); + s.save(); + fn(s); + s.restore(); + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalAlpha *= alpha; + ctx.drawImage(scratch, 0, 0, W, Hh, 0, 0, W, Hh); + ctx.restore(); +} + +/** Glasses, bow, scarf and pumpkin are on the face: behind the head once it has turned away. */ +const ON_FACE: ReadonlySet = new Set(["sunglasses", "roundGlasses", "bow", "scarf", "pumpkin"]); +const HATS: ReadonlySet = new Set(["beanie", "santaHat", "partyHat", "crown", "witchHat"]); + +export interface OutfitState { + /** 0 = gone, 1 = fully on. Animated by the engine. */ + presence: number; + /** Mochi's mailbox morph: outfits fade away as he turns into a box. */ + morph: number; +} + +function faceTurnedAway(H: Head): boolean { + return proj(H, [0, 0, 1]).z < 0; +} + +function drawFace(ctx: Ctx, outfit: Outfit, H: Head, body: Path2D, simple: boolean) { + switch (outfit) { + case "sunglasses": sunglasses(ctx, H, body); break; + case "roundGlasses": roundGlasses(ctx, H, body); break; + case "scarf": scarf(ctx, H); break; + case "pumpkin": pumpkin(ctx, H, body, simple); break; + case "bow": bow(ctx, H); break; + default: break; + } +} + +function layerAlpha(st: OutfitState): number { + const morphFade = 1 - Math.min(1, Math.max(0, (st.morph - 0.3) / 0.2)); + return morphFade * Math.min(1, st.presence * 2.5); +} + +/** + * The parts behind Mochi's body. `ctx` is in body space (translated to the body + * centre, tilted and squashed exactly like the body). + */ +export function drawOutfitBehind(ctx: Ctx, outfit: Outfit, H: Head, st: OutfitState) { + if (outfit === "none") return; + const alpha = layerAlpha(st); + if (alpha <= 0.005) return; + const simple = H.R < SIMPLIFY_BELOW_R; + const body = bodyOutline(H.rx, H.ry); + + if (ON_FACE.has(outfit)) { + if (faceTurnedAway(H)) withLayer(ctx, alpha, (l) => drawFace(l, outfit, H, body, simple)); + return; + } + const posP = Ease.back(st.presence); + const hatScale = 0.85 + 0.15 * posP; + ctx.save(); + ctx.translate(0, -(1 - posP) * H.ry); + ctx.scale(hatScale, hatScale); + withLayer(ctx, alpha, (l) => { + if (outfit === "bunnyEars") bunnyEars(l, H); + else if (outfit === "crown") crownPart(l, H, -1, simple); + else if (outfit === "witchHat") witchHatBack(l, H); + }); + ctx.restore(); +} + +/** The parts in front of Mochi, drawn after the body and the eyes. */ +export function drawOutfitFront(ctx: Ctx, outfit: Outfit, H: Head, st: OutfitState) { + if (outfit === "none" || outfit === "bunnyEars") return; + if (ON_FACE.has(outfit) && faceTurnedAway(H)) return; + const alpha = layerAlpha(st); + if (alpha <= 0.005) return; + const simple = H.R < SIMPLIFY_BELOW_R; + const body = bodyOutline(H.rx, H.ry); + const p = st.presence; + const posP = Ease.back(p); + + ctx.save(); + if (HATS.has(outfit)) { + // hats drop onto the head and settle + const hatScale = 0.85 + 0.15 * posP; + ctx.translate(0, -(1 - posP) * H.ry); + ctx.scale(hatScale, hatScale); + } else if (outfit === "sunglasses" || outfit === "roundGlasses") { + ctx.translate(0, (1 - p) * 0.25 * H.ry); + } else if (outfit === "scarf") { + ctx.translate(0, (1 - p) * 0.3 * H.ry); + } else if (outfit === "bow") { + ctx.scale(Math.max(0.001, posP), Math.max(0.001, posP)); + } + withLayer(ctx, alpha, (l) => { + switch (outfit) { + case "beanie": beanie(l, H, body, simple); break; + case "santaHat": santaHat(l, H, body); break; + case "partyHat": partyHat(l, H, simple); break; + case "crown": crownFront(l, H, body, simple); break; + case "witchHat": witchHatFront(l, H, body); break; + default: drawFace(l, outfit, H, body, simple); + } + }); + ctx.restore(); +} + +// ── Wardrobe icons ──────────────────────────────────────────────────────────── + +const INK = "rgb(26,20,18)"; + +/** A little Mochi wearing `outfit`, centred in a `size`×`size` icon. */ +function iconMochi(ctx: Ctx, size: number, outfit: Outfit) { + const R = 10; + const H = makeHead(R); + const cx = size / 2; + const cy = size / 2 + R * 0.62; + const st: OutfitState = { presence: 1, morph: 0 }; + ctx.save(); + ctx.translate(cx, cy); + drawOutfitBehind(ctx, outfit, H, st); + const body = bodyOutline(H.rx, H.ry); + const [top, bottom] = outfit === "pumpkin" ? PUMPKIN_BODY : ["rgb(237,237,239)", "rgb(196,197,202)"]; + ctx.fillStyle = lin(ctx, H.rx * 0.7, -H.ry * 0.85, -H.rx * 0.8, H.ry * 0.9, [[0, top], [1, bottom]]); + ctx.fill(body); + ctx.fillStyle = rad(ctx, 0, 0, R * 0.15, R * 1.25, [[0, "rgba(0,0,0,0)"], [0.6, "rgba(0,0,0,0)"], [1, "rgba(0,0,0,0.2)"]]); + ctx.fill(body); + ctx.fillStyle = rad(ctx, H.rx * 0.34, -H.ry * 0.46, 0, R * 0.42, [[0, "rgba(255,255,255,0.55)"], [1, "rgba(255,255,255,0)"]]); + ctx.fill(body); + ctx.save(); + ctx.clip(body); + ctx.fillStyle = INK; + for (const e of eyeFrames(H)) { + if (!e.visible) continue; + ctx.save(); + ctx.translate(e.x, e.y); + ctx.scale(e.fx, e.fy); + const hh = Math.max(e.h, e.w * 0.3); + roundRect(ctx, -e.w / 2, -hh / 2, e.w, hh, Math.min(e.w / 2, hh / 2)); + ctx.fill(); + ctx.restore(); + } + ctx.restore(); + drawOutfitFront(ctx, outfit, H, st); + ctx.restore(); +} + +/** + * One wardrobe button's picture — drawOutfitIcon on macOS. "auto" shows the + * season's outfit with an AUTO tag, "none" a crossed-out circle. + */ +export function drawWardrobeIcon(ctx: Ctx, size: number, selection: OutfitSelection, seasonal: Outfit, autoLabel: string) { + ctx.clearRect(0, 0, size, size); + if (selection === "none") { + const r = 6.5; + ctx.save(); + ctx.translate(size / 2, size / 2); + ctx.strokeStyle = "#454850"; + ctx.lineWidth = 1.4; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.arc(0, 0, r * 0.82, 0, Math.PI * 2); + ctx.moveTo(-r * 0.56, r * 0.56); + ctx.lineTo(r * 0.56, -r * 0.56); + ctx.stroke(); + ctx.restore(); + return; + } + iconMochi(ctx, size, selection === "auto" ? seasonal : selection); + if (selection !== "auto") return; + const R = 10; + const by = size / 2 + R * 0.62 + R * 0.88 * 0.72; + const bw = 14; + const bh = 6.5; + ctx.save(); + ctx.translate(size / 2, by); + roundRect(ctx, -bw / 2, -bh / 2, bw, bh, bh / 2); + ctx.fillStyle = "rgba(0,0,0,0.6)"; + ctx.fill(); + ctx.fillStyle = "#FFFFFF"; + // A longer translation of "AUTO" gets a smaller font rather than overflowing the badge. + let fontPx = 4.2; + ctx.font = `600 ${fontPx}px system-ui, "Segoe UI", ${SCRIPT_FONTS}, sans-serif`; + while (ctx.measureText(autoLabel).width > bw - 2 && fontPx > 2.6) { + fontPx -= 0.2; + ctx.font = `600 ${fontPx}px system-ui, "Segoe UI", ${SCRIPT_FONTS}, sans-serif`; + } + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(autoLabel, 0, 0.2); + ctx.restore(); +} diff --git a/windows/src/mochi/wardrobe.ts b/windows/src/mochi/wardrobe.ts new file mode 100644 index 000000000..28f1923e9 --- /dev/null +++ b/windows/src/mochi/wardrobe.ts @@ -0,0 +1,143 @@ +// Mochi's wardrobe — the pure logic, port of NotchBuddy/Sources/CoucouKit/MochiWardrobe.swift. +// What Mochi wears is picked in the wardrobe view (right-click on Mochi, or the +// tray menu) and stored in the preferences as `mochiOutfit`. The drawing lives +// in ./outfits.ts. + +import { N_, labels, t } from "../i18n/i18n"; + +/** Every outfit, plus "auto" (dress for the season) and "none". Order of the wardrobe. */ +export const OUTFIT_SELECTIONS = [ + "auto", "none", "partyHat", "beanie", "crown", "sunglasses", "roundGlasses", + "bow", "scarf", "witchHat", "pumpkin", "santaHat", "bunnyEars", +] as const; + +/** What can be stored in the preferences. The raw values are the Mac's, keep them stable. */ +export type OutfitSelection = (typeof OUTFIT_SELECTIONS)[number]; + +/** What Mochi actually wears: a selection with "auto" resolved. */ +export type Outfit = Exclude; + +export const DEFAULT_OUTFIT: OutfitSelection = "auto"; + +// ── User-visible strings ────────────────────────────────────────────────────── + +/** The English names, the keys of their translations (src/i18n). */ +export const OUTFIT_KEYS: Record = { + auto: N_("Auto (seasons)"), + none: N_("None"), + partyHat: N_("Party hat"), + beanie: N_("Beanie"), + crown: N_("Crown"), + sunglasses: N_("Sunglasses"), + roundGlasses: N_("Round glasses"), + bow: N_("Bow"), + scarf: N_("Scarf"), + witchHat: N_("Witch hat"), + pumpkin: N_("Pumpkin"), + santaHat: N_("Santa hat"), + bunnyEars: N_("Bunny ears"), +}; + +/** In the current language: every read goes through `t()`. */ +export const OUTFIT_LABELS: Record = labels(OUTFIT_KEYS); + +export const WARDROBE_STRINGS = { + get title() { return t("Wardrobe"); }, + get autoBadge() { return t("AUTO"); }, + autoNow: (current: string) => t("Auto · {outfit}", { outfit: current }), + autoHover: (current: string) => t("Auto · follows the seasons (now: {outfit})", { outfit: current }), +}; + +// ── Logic ───────────────────────────────────────────────────────────────────── + +const SELECTION_SET: ReadonlySet = new Set(OUTFIT_SELECTIONS); + +/** + * A stored preference → a selection. Anything unknown (a value from a newer or + * older build, such as the Mac's removed "topHat", or garbage) means "auto", + * exactly like `Outfit.stored` on macOS. + */ +export function parseOutfit(raw: unknown): OutfitSelection { + return typeof raw === "string" && SELECTION_SET.has(raw) ? (raw as OutfitSelection) : DEFAULT_OUTFIT; +} + +/** Easter Sunday of `year` (Meeus/Jones/Butcher), as [month 1–12, day]. */ +export function easterDate(year: number): [number, number] { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31); + const day = ((h + l - 7 * m + 114) % 31) + 1; + return [month, day]; +} + +const DAY_MS = 86_400_000; + +/** + * The seasonal outfit for `date`, read in the user's local calendar. + * Priority: party hat > Santa hat > witch hat > bunny ears > sunglasses > none. + */ +export function seasonalOutfit(date: Date): Outfit { + const day = date.getDate(); + const month = date.getMonth() + 1; + const year = date.getFullYear(); + + // Dec 31 – Jan 2 + if ((month === 12 && day === 31) || (month === 1 && day <= 2)) return "partyHat"; + // Dec 1–26 + if (month === 12 && day <= 26) return "santaHat"; + // Oct 1 – Nov 1 + if (month === 10 || (month === 11 && day === 1)) return "witchHat"; + // Two days before Easter to the day after. Calendar days counted in UTC so a + // daylight-saving change in between can't shift the count. + const [em, ed] = easterDate(year); + const delta = Math.round((Date.UTC(year, month - 1, day) - Date.UTC(year, em - 1, ed)) / DAY_MS); + if (delta >= -2 && delta <= 1) return "bunnyEars"; + // Jun 21 – Aug 31 + if ((month === 6 && day >= 21) || month === 7 || month === 8) return "sunglasses"; + return "none"; +} + +/** "auto" → the season's outfit; anything else is worn as chosen. */ +export function resolveOutfit(selection: OutfitSelection, date: Date): Outfit { + return selection === "auto" ? seasonalOutfit(date) : selection; +} + +/** + * The grey text at the right of the wardrobe header: the hovered outfit, + * otherwise the current choice — WardrobeView.headerRight on macOS. + */ +export function wardrobeHeader( + hovered: OutfitSelection | null, + selection: OutfitSelection, + date: Date, +): string { + const season = OUTFIT_LABELS[seasonalOutfit(date)]; + if (hovered) return hovered === "auto" ? WARDROBE_STRINGS.autoHover(season) : OUTFIT_LABELS[hovered]; + return selection === "auto" ? WARDROBE_STRINGS.autoNow(season) : OUTFIT_LABELS[selection]; +} + +/** Same as resolveOutfit, remembered for the day — it is asked every frame. */ +export class SeasonCache { + private key = ""; + private value: Outfit = "none"; + + get(selection: OutfitSelection, date = new Date()): Outfit { + if (selection !== "auto") return selection; + const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; + if (key !== this.key) { + this.key = key; + this.value = seasonalOutfit(date); + } + return this.value; + } +} diff --git a/windows/src/recap/recap.ts b/windows/src/recap/recap.ts new file mode 100644 index 000000000..9b38df115 --- /dev/null +++ b/windows/src/recap/recap.ts @@ -0,0 +1,87 @@ +// Weekly recap — when the card opens, and what it shows. Port of +// AppDelegate.checkMondayRecap / openWeeklyRecap. +// +// It opens on its own once a week, on Monday from 8 am, at the first of: +// * the launch greeting ending (the app starting that day), +// * an agent starting work (SessionStart / UserPromptSubmit — Rust sends +// `recap-check` when it sees one), +// * the island waking from hidden (the user coming back to the machine). +// And any time from the tray: "Weekly recap". Nothing here runs on a timer. + +import { Bridge, IS_TAURI } from "../core/bridge"; +import type { IslandViewName } from "../core/layout"; +import { State } from "../core/state"; +import { + DEFAULT_PREFS, dayKey, mondayOf, previousWeekStart, sampleHistory, shouldAutoShow, summarize, + type RecapPrefs, type WeeklySummary, +} from "./summary"; + +/** What the recap needs from the island. */ +export interface RecapHost { + alert(view: IslandViewName): void; +} + +/** Views the Monday card may replace; anything else is the user busy with something. */ +const QUIET_VIEWS: ReadonlySet = new Set(["overview", "empty", "settings", "greeting"]); + +/** Same 1.5 s as the Mac, so it never lands on top of the event that triggered it. */ +const SHOW_DELAY_MS = 1500; + +class RecapController { + summary: WeeklySummary | null = null; + prefs: RecapPrefs = { ...DEFAULT_PREFS }; + /** Bumped on every load, so the view knows to rebuild (and leave share mode). */ + version = 0; + private checking = false; + + /** Reads last week from Rust. A plain browser (`npm run dev`) gets the sample week. */ + async load(now = new Date()): Promise { + const start = previousWeekStart(now); + const history = IS_TAURI + ? await Bridge.recapHistory(start.getTime() / 1000) + : sampleHistory(start); + this.prefs = { ...DEFAULT_PREFS, ...(history?.prefs ?? {}) }; + this.summary = history ? summarize(history, start) : null; + this.version++; + return this.summary; + } + + /** Tray → Weekly recap. Shows the card even for an empty week. */ + async open(host: RecapHost) { + await this.load(); + host.alert("recap"); + } + + /** Monday ≥ 8 am, not shown yet this week, something to show, nobody interrupted. */ + async check(host: RecapHost, now = new Date()) { + // The clock first: six days out of seven this is all that runs. + if (this.checking || !shouldAutoShow(now, "") || !this.quiet()) return; + this.checking = true; + try { + const prefs = IS_TAURI ? await Bridge.recapPrefs() : null; + if (!prefs?.enabled || !shouldAutoShow(now, prefs.lastShownWeek)) return; + if (!(await this.load(now))) return; + const week = dayKey(mondayOf(now)); + window.setTimeout(() => { + if (!this.quiet()) return; + host.alert("recap"); + void Bridge.recapMarkShown(week); + this.prefs.lastShownWeek = week; + }, SHOW_DELAY_MS); + } finally { + this.checking = false; + } + } + + private quiet(): boolean { + if (State.paused || State.pendingApproval) return false; + return State.mode !== "expanded" || QUIET_VIEWS.has(State.view); + } + + async setHideProjects(hide: boolean) { + this.prefs.hideProjects = hide; + await Bridge.recapSetHideProjects(hide); + } +} + +export const Recap = new RecapController(); diff --git a/windows/src/recap/share.ts b/windows/src/recap/share.ts new file mode 100644 index 000000000..0014fd59e --- /dev/null +++ b/windows/src/recap/share.ts @@ -0,0 +1,294 @@ +// The 1080 × 1920 image of the week, drawn once into an offscreen canvas — +// port of RecapShareImageView in WeeklyRecapView.swift. Same colours, sizes +// and order; Mochi comes from the island's own engine. + +import { BotEngine } from "../mochi/engine"; +import { formatCount, formatDuration, weekRangeLabel, type WeeklySummary } from "./summary"; +import { t } from "../i18n/i18n"; +import { SCRIPT_FONTS } from "../core/fonts"; + +export const SHARE_W = 1080; +export const SHARE_H = 1920; + +const FONT = `system-ui, "Segoe UI Variable Display", "Segoe UI", "Cantarell", "Ubuntu", ${SCRIPT_FONTS}, sans-serif`; +const MONO = `"Cascadia Mono", "Consolas", "DejaVu Sans Mono", ui-monospace, monospace`; + +const INK = "#F1F2F4"; +const DIM = "#8E939C"; +const INDIGO_TEXT = "#818CF8"; + +/** Strings drawn into the image, in the current language (src/i18n). */ +const T = { + get title() { return t("Weekly recap"); }, + get timeCoding() { return t("TIME CODING"); }, + get sessions() { return t("SESSIONS"); }, + get files() { return t("FILES"); }, + get commands() { return t("COMMANDS"); }, + get topAgent() { return t("Top agent"); }, + get topProject() { return t("Top project"); }, + get busiestDay() { return t("Busiest day"); }, + get longestSession() { return t("Longest session"); }, + get approved() { return t("Approved"); }, + get denied() { return t("Denied"); }, + footer: "Coucou · github.com/Louis-CFM/coucou", +}; + +/** + * Scripts whose letters join or stack (Arabic, Devanagari, Bengali) must be + * drawn whole, and CJK has no letter spacing to speak of. + */ +const JOINED_SCRIPT = /[\u0590-\u08FF\u0900-\u0DFF\u3000-\u9FFF\uAC00-\uD7AF\uFB1D-\uFEFF]/; + +type Ctx = CanvasRenderingContext2D; + +function font(x: Ctx, weight: number, size: number, family = FONT) { + x.font = `${weight} ${size}px ${family}`; +} + +/** Shrinks the font until `text` fits in `maxW` (minimumScaleFactor). */ +function fitFont(x: Ctx, text: string, weight: number, size: number, maxW: number, minScale: number) { + let s = size; + font(x, weight, s); + while (x.measureText(text).width > maxW && s > size * minScale) { + s -= 2; + font(x, weight, s); + } +} + +/** + * Text with letter spacing, centred on `cx` — `letterSpacing` is not everywhere + * yet. A translation wider than `maxW` gets a smaller font first. + */ +function spaced(x: Ctx, text: string, cx: number, y: number, spacing: number, maxW = Infinity) { + if (JOINED_SCRIPT.test(text)) { + shrinkToFit(x, text, maxW); + x.textAlign = "center"; + x.fillText(text, cx, y); + return; + } + shrinkToFit(x, text, maxW, spacing); + const chars = [...text]; + const widths = chars.map((c) => x.measureText(c).width); + const total = widths.reduce((a, b) => a + b, 0) + spacing * (chars.length - 1); + let at = cx - total / 2; + x.textAlign = "left"; + chars.forEach((c, i) => { + x.fillText(c, at, y); + at += widths[i] + spacing; + }); +} + +/** Lowers the current font's size until `text` (plus its letter spacing) fits `maxW`. */ +function shrinkToFit(x: Ctx, text: string, maxW: number, spacing = 0) { + if (!Number.isFinite(maxW)) return; + const match = /^(\S+) (\d+(?:\.\d+)?)px (.*)$/.exec(x.font); + if (!match) return; + const [, weight, sizeText, family] = match; + const size = Number(sizeText); + const extra = spacing * Math.max(0, [...text].length - 1); + let s = size; + while (x.measureText(text).width + extra > maxW && s > size * 0.6) { + s -= 1; + x.font = `${weight} ${s}px ${family}`; + } +} + +/** Cuts `text` with an ellipsis so it fits `maxW` in the current font. */ +function ellipsize(x: Ctx, text: string, maxW: number): string { + if (x.measureText(text).width <= maxW) return text; + let t = text; + while (t.length > 1 && x.measureText(`${t}…`).width > maxW) t = t.slice(0, -1); + return `${t}…`; +} + +function roundRect(x: Ctx, left: number, top: number, w: number, h: number, r: number) { + x.beginPath(); + x.moveTo(left + r, top); + x.arcTo(left + w, top, left + w, top + h, r); + x.arcTo(left + w, top + h, left, top + h, r); + x.arcTo(left, top + h, left, top, r); + x.arcTo(left, top, left + w, top, r); + x.closePath(); +} + +const BADGE_H = 66; +const BADGE_GAP = 16; + +function badge(x: Ctx, left: number, top: number, w: number, label: string, value: string) { + x.fillStyle = "rgba(255,255,255,0.05)"; + roundRect(x, left, top, w, BADGE_H, 18); + x.fill(); + const mid = top + BADGE_H / 2; + x.textBaseline = "middle"; + font(x, 400, 24); + x.fillStyle = DIM; + x.textAlign = "left"; + x.fillText(label, left + 32, mid); + const labelW = x.measureText(label).width; + font(x, 600, 24); + x.fillStyle = INK; + x.textAlign = "right"; + x.fillText(ellipsize(x, value, w - 64 - labelW - 16), left + w - 32, mid); +} + +/** A still Mochi, drawn by the same engine as the island's. */ +function drawMochi(x: Ctx, cx: number, top: number, size: number) { + const engine = new BotEngine(); + engine.setState("idle", true); + engine.update(1 / 60); + x.save(); + x.translate(cx - size / 2, top); + engine.draw(x, size, size); + x.restore(); +} + +export function renderShareImage(s: WeeklySummary, hideProjects: boolean): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = SHARE_W; + canvas.height = SHARE_H; + const x = canvas.getContext("2d"); + if (!x) return canvas; + const W = SHARE_W; + const cx = W / 2; + + // Background: near-black with an indigo glow low in the frame. + x.fillStyle = "#0B0C0E"; + x.fillRect(0, 0, W, SHARE_H); + const glow = x.createRadialGradient(cx, SHARE_H * 0.75, 0, cx, SHARE_H * 0.75, 900); + glow.addColorStop(0, "rgba(99,102,241,0.30)"); + glow.addColorStop(0.65, "rgba(99,102,241,0)"); + x.fillStyle = glow; + x.fillRect(0, 0, W, SHARE_H); + + // What goes in the badge list. + const badges: [string, string][] = []; + if (s.topAgent) badges.push([T.topAgent, s.topAgent]); + if (!hideProjects && s.topProject) badges.push([T.topProject, s.topProject]); + if (s.busiestDay) badges.push([T.busiestDay, t(s.busiestDay)]); + if (s.longestSessionMinutes > 1) badges.push([T.longestSession, formatDuration(s.longestSessionMinutes)]); + const decisions = s.permissionsAllowed + s.permissionsDenied > 0; + const badgeRows = badges.length + (decisions ? 1 : 0); + const hasLines = s.linesAdded + s.linesRemoved > 0; + + // Heights of each block, so the whole stack can be centred like the VStack. + // Mochi's body fills ~60 % of the square the engine draws into. + const MOCHI = 220; + const MOCHI_DRAW = 320; + const blockH = + MOCHI + 20 + 62 + 6 + 36 + 14 + 29 + 80 + // Mochi, Coucou, title, range + 110 + 6 + 26 + // time + caption + 56 + 94 + // stat row + (hasLines ? 24 + 34 : 0) + + (badgeRows > 0 ? 56 + badgeRows * BADGE_H + (badgeRows - 1) * BADGE_GAP : 0); + const footerTop = SHARE_H - 60 - 24; + let y = Math.max(40, (footerTop - blockH) / 2); + + x.textBaseline = "top"; + + drawMochi(x, cx, y + MOCHI / 2 - MOCHI_DRAW / 2 - MOCHI_DRAW * 0.02, MOCHI_DRAW); + y += MOCHI + 20; + + x.fillStyle = INK; + x.textAlign = "center"; + font(x, 900, 52); + x.fillText("Coucou", cx, y); + y += 62 + 6; + + x.fillStyle = DIM; + fitFont(x, T.title, 500, 30, W - 120, 0.6); + x.fillText(T.title, cx, y); + y += 36 + 14; + + x.fillStyle = INDIGO_TEXT; + font(x, 400, 24); + x.fillText(weekRangeLabel(s), cx, y); + y += 29 + 80; + + // Time — the headline number. + const time = formatDuration(s.totalMinutes); + x.fillStyle = INK; + fitFont(x, time, 900, 100, W - 120, 0.4); + x.textAlign = "center"; + x.fillText(time, cx, y); + y += 110 + 6; + x.fillStyle = DIM; + font(x, 600, 22); + spaced(x, T.timeCoding, cx, y, 3, W - 120); + y += 26 + 56; + + // Sessions | files | commands + const stats: [string, string][] = [ + [formatCount(s.sessionCount), T.sessions], + [formatCount(s.filesChanged), T.files], + ]; + if (s.commandsRun > 0) stats.push([formatCount(s.commandsRun), T.commands]); + const rowLeft = 40; + const colW = (W - rowLeft * 2) / stats.length; + stats.forEach(([value, label], i) => { + const colCx = rowLeft + colW * (i + 0.5); + if (i > 0) { + x.fillStyle = "rgba(255,255,255,0.08)"; + x.fillRect(rowLeft + colW * i, y + 7, 1, 80); + } + x.fillStyle = INK; + fitFont(x, value, 900, 64, colW - 24, 0.5); + x.textAlign = "center"; + x.fillText(value, colCx, y); + x.fillStyle = DIM; + font(x, 600, 18); + spaced(x, label, colCx, y + 72, 2, colW - 24); + }); + y += 94; + + // Lines added / removed + if (hasLines) { + y += 24; + const plus = `+${formatCount(s.linesAdded)}`; + const minus = `−${formatCount(s.linesRemoved)}`; + font(x, 600, 28, MONO); + const pw = x.measureText(plus).width; + const mw = x.measureText(minus).width; + const start = cx - (pw + 24 + mw) / 2; + x.textAlign = "left"; + x.fillStyle = "#4ADE80"; + x.fillText(plus, start, y); + x.fillStyle = "#F87171"; + x.fillText(minus, start + pw + 24, y); + y += 34; + } + + // Badges + if (badgeRows > 0) { + y += 56; + const left = 60; + const w = W - left * 2; + for (const [label, value] of badges) { + badge(x, left, y, w, label, value); + y += BADGE_H + BADGE_GAP; + } + if (decisions) { + const half = (w - BADGE_GAP) / 2; + badge(x, left, y, half, T.approved, formatCount(s.permissionsAllowed)); + badge(x, left + half + BADGE_GAP, y, half, T.denied, formatCount(s.permissionsDenied)); + } + } + + // Footer + x.textBaseline = "top"; + x.textAlign = "center"; + x.fillStyle = "rgba(142,147,156,0.6)"; + font(x, 500, 20, MONO); + x.fillText(T.footer, cx, footerTop); + + return canvas; +} + +/** The canvas as a PNG data URL, for Rust to write to disk. */ +export function toPngDataUrl(canvas: HTMLCanvasElement): string { + return canvas.toDataURL("image/png"); +} + +/** The canvas as a PNG blob, for the clipboard. */ +export function toPngBlob(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve) => canvas.toBlob(resolve, "image/png")); +} diff --git a/windows/src/recap/summary.ts b/windows/src/recap/summary.ts new file mode 100644 index 000000000..424650231 --- /dev/null +++ b/windows/src/recap/summary.ts @@ -0,0 +1,253 @@ +// Weekly recap — pure aggregation, port of RecapStore.weeklySummary. +// Rust (src-tauri/src/recap.rs) records the turns; this file turns them into +// one week's numbers. It lives on the JavaScript side because weeks, days and +// "Monday 8 am" are local time, and the webview knows the user's time zone. +// No DOM, no Tauri: tests/recap.test.mjs runs it as it is. + +import { N_, dayMonth, t } from "../i18n/i18n"; + +/** One finished turn, as recap.rs stores it. Times are Unix seconds. */ +export interface RecapTurn { + agent: string; + project: string; + start: number; + end: number; + filesChanged: number; + linesAdded: number; + linesRemoved: number; + commandsRun: number; + questions: number; +} + +export interface RecapDecision { + agent: string; + date: number; + decision: string; +} + +export interface RecapPrefs { + enabled: boolean; + hideProjects: boolean; + /** Monday (YYYY-MM-DD) of the week the recap last opened on its own. */ + lastShownWeek: string; +} + +export interface RecapHistory { + turns: RecapTurn[]; + decisions: RecapDecision[]; + prefs: RecapPrefs; +} + +export interface WeeklySummary { + weekStart: Date; + /** Last second of the week (Sunday 23:59:59). */ + weekEnd: Date; + totalMinutes: number; + sessionCount: number; + filesChanged: number; + linesAdded: number; + linesRemoved: number; + commandsRun: number; + questions: number; + permissionsAllowed: number; + permissionsDenied: number; + /** Display name. */ + topAgent: string | null; + topProject: string | null; + busiestDay: string | null; + longestSessionMinutes: number; +} + +export const DEFAULT_PREFS: RecapPrefs = { enabled: true, hideProjects: false, lastShownWeek: "" }; + +// ── Weeks (ISO 8601: Monday to Sunday, local time) ──────────────────────────── + +/** Local midnight `n` days after `d`'s date — DST-safe, unlike adding ms. */ +export function addDays(d: Date, n: number): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); +} + +/** Monday 00:00 (local) of the week holding `d`. */ +export function mondayOf(d: Date): Date { + const sinceMonday = (d.getDay() + 6) % 7; + return addDays(d, -sinceMonday); +} + +/** Monday 00:00 of the last completed week. */ +export function previousWeekStart(now: Date): Date { + return addDays(mondayOf(now), -7); +} + +/** YYYY-MM-DD of a local date. */ +export function dayKey(d: Date): string { + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} + +/** The Monday recap opens on its own once per week, from 8 am on. */ +export const RECAP_HOUR = 8; + +export function shouldAutoShow(now: Date, lastShownWeek: string): boolean { + return now.getDay() === 1 && now.getHours() >= RECAP_HOUR && dayKey(mondayOf(now)) !== lastShownWeek; +} + +// ── Aggregation ─────────────────────────────────────────────────────────────── + +/** English names, kept in the summary; shown with `t()` (src/i18n). */ +const DAY_NAMES = [N_("Sunday"), N_("Monday"), N_("Tuesday"), N_("Wednesday"), N_("Thursday"), N_("Friday"), N_("Saturday")]; + +/** Pill IDs → names, from PillCatalog.swift. Claude Code hooks come from any + * terminal here, so its pill reads "Claude Code" rather than "VS Code". */ +const AGENT_NAMES: Record = { + integration_claude: "Claude Code", + agent_cursor: "Cursor", + agent_antigravity: "Antigravity", + agent_codex: "Codex", + agent_gemini: "Gemini CLI", + agent_copilot: "Copilot CLI", + agent_muse: "Muse Code", + agent_opencode: "OpenCode", + agent_amp: "Amp", + agent_hermes: "Hermes", + "agent_claude-desktop": "Claude Desktop", +}; + +export function agentName(id: string): string { + const known = AGENT_NAMES[id]; + if (known) return known; + const raw = id.replace(/^agent_/, ""); + return raw ? raw.charAt(0).toUpperCase() + raw.slice(1) : id; +} + +/** The most frequent key; ties go to whatever `order` ranks first. */ +function topKey(counts: Map, order: (a: K, b: K) => number): K | null { + let best: K | null = null; + let bestCount = 0; + for (const [key, n] of counts) { + if (n > bestCount || (n === bestCount && best !== null && order(key, best) < 0)) { + best = key; + bestCount = n; + } + } + return best; +} + +/** Wall time covered by the turns, overlapping ones counted once. Seconds. */ +export function mergedSeconds(turns: RecapTurn[]): number { + const sorted = turns.filter((t) => t.end > t.start).sort((a, b) => a.start - b.start); + let total = 0; + let segStart = 0; + let segEnd = -Infinity; + for (const t of sorted) { + if (t.start <= segEnd) { + segEnd = Math.max(segEnd, t.end); + } else { + if (segEnd > segStart) total += segEnd - segStart; + segStart = t.start; + segEnd = t.end; + } + } + if (segEnd > segStart) total += segEnd - segStart; + return total; +} + +/** The week starting at `weekStart` (a local Monday 00:00), or null when nothing ran. */ +export function summarize(history: RecapHistory, weekStart: Date): WeeklySummary | null { + const end = addDays(weekStart, 7); + const from = weekStart.getTime() / 1000; + const to = end.getTime() / 1000; + const turns = history.turns.filter((t) => t.start >= from && t.start < to); + if (turns.length === 0) return null; + const decisions = history.decisions.filter((d) => d.date >= from && d.date < to); + const sum = (f: (t: RecapTurn) => number) => turns.reduce((n, t) => n + f(t), 0); + + const byAgent = new Map(); + const byProject = new Map(); + const byDay = new Map(); + for (const t of turns) { + byAgent.set(t.agent, (byAgent.get(t.agent) ?? 0) + 1); + if (t.project) byProject.set(t.project, (byProject.get(t.project) ?? 0) + 1); + const day = new Date(t.start * 1000).getDay(); + byDay.set(day, (byDay.get(day) ?? 0) + 1); + } + const byName = (a: string, b: string) => a.localeCompare(b); + // Ties go to the earlier day of the (Monday-first) week. + const byWeekOrder = (a: number, b: number) => ((a + 6) % 7) - ((b + 6) % 7); + const topAgent = topKey(byAgent, byName); + const busiest = topKey(byDay, byWeekOrder); + const longest = Math.max(0, ...turns.map((t) => t.end - t.start)); + + return { + weekStart, + weekEnd: new Date(end.getTime() - 1000), + totalMinutes: Math.floor(mergedSeconds(turns) / 60), + sessionCount: turns.length, + filesChanged: sum((t) => t.filesChanged), + linesAdded: sum((t) => t.linesAdded), + linesRemoved: sum((t) => t.linesRemoved), + commandsRun: sum((t) => t.commandsRun), + questions: sum((t) => t.questions), + permissionsAllowed: decisions.filter((d) => d.decision === "allow" || d.decision === "always").length, + permissionsDenied: decisions.filter((d) => d.decision === "deny").length, + topAgent: topAgent === null ? null : agentName(topAgent), + topProject: topKey(byProject, byName), + busiestDay: busiest === null ? null : DAY_NAMES[busiest], + longestSessionMinutes: Math.floor(longest / 60), + }; +} + +// ── Formatting ──────────────────────────────────────────────────────────────── + +export function formatDuration(minutes: number): string { + if (minutes < 60) return t("{n}m", { n: minutes }); + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m === 0 ? t("{n}h", { n: h }) : t("{h}h {m}m", { h, m }); +} + +/** 1234 → "1,234"; 12345 → "12.3k" — keeps a stat chip narrow. */ +export function formatCount(n: number): string { + if (n >= 10_000) return `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k`; + return n.toLocaleString("en-US"); +} + +/** "Sep 28 – Oct 4". */ +export function weekRangeLabel(s: Pick): string { + const f = (d: Date) => dayMonth(d.getMonth(), d.getDate()); + return `${f(s.weekStart)} – ${f(s.weekEnd)}`; +} + +// ── Sample week ─────────────────────────────────────────────────────────────── + +/** + * The fake week of scripts/test-weekly-recap.swift, starting on `monday`. + * Used by the tests and by `npm run dev` in a plain browser, where there is + * no Rust side to read a history from. + */ +export function sampleHistory(monday: Date): RecapHistory { + const at = (day: number, hour: number, minute = 0) => + Math.floor(new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + day, hour, minute).getTime() / 1000); + const turn = ( + agent: string, project: string, start: number, end: number, + filesChanged: number, linesAdded: number, linesRemoved: number, commandsRun: number, questions: number, + ): RecapTurn => ({ agent, project, start, end, filesChanged, linesAdded, linesRemoved, commandsRun, questions }); + const claude = "integration_claude"; + return { + turns: [ + turn(claude, "coucou", at(0, 9), at(0, 11, 30), 8, 312, 87, 14, 2), + turn(claude, "coucou", at(0, 14), at(0, 15, 45), 3, 95, 20, 5, 0), + turn("agent_gemini", "side-project", at(1, 10), at(1, 11), 2, 50, 10, 3, 1), + turn(claude, "coucou", at(2, 9, 30), at(2, 12), 5, 180, 60, 8, 3), + turn(claude, "coucou", at(3, 16), at(3, 17), 1, 40, 5, 2, 0), + turn(claude, "coucou", at(4, 8), at(4, 13), 12, 540, 130, 22, 5), + ], + decisions: [ + { agent: claude, date: at(0, 9, 30), decision: "allow" }, + { agent: claude, date: at(0, 10), decision: "allow" }, + { agent: claude, date: at(2, 10), decision: "deny" }, + { agent: claude, date: at(4, 9), decision: "always" }, + { agent: claude, date: at(4, 10), decision: "allow" }, + ], + prefs: { ...DEFAULT_PREFS }, + }; +} diff --git a/windows/src/settings/agents.ts b/windows/src/settings/agents.ts new file mode 100644 index 000000000..6d646ba53 --- /dev/null +++ b/windows/src/settings/agents.ts @@ -0,0 +1,143 @@ +// Settings → Agents: one section for every agent other than Claude Code, all +// going through the same steps — preview the exact diff, click to confirm, and +// only then does agents.rs back the file up and write it. + +import { Bridge, type AgentHookStatus } from "../core/bridge"; +import { clear, h } from "../views/dom"; +import { renderDiff, statusDot } from "./parts"; +import { t } from "../i18n/i18n"; + +/** In the current language (src/i18n); the settings window redraws on a change. */ +const TEXT = { + get title() { return t("Agents"); }, + get intro() { + return t("Show other coding agents in the island. Coucou adds its entries to each agent's own config: you see the exact change and where the backup goes before anything is written, and uninstalling removes only what Coucou added."); + }, + get none() { return t("Coucou could not list the agents."); }, + get approvals() { return t("Sessions, and Allow / Deny from the island"); }, + get displayOnly() { return t("Sessions — approvals stay in the agent"); }, + get install() { return t("Install…"); }, + get reinstall() { return t("Reinstall…"); }, + get uninstall() { return t("Uninstall…"); }, + get relayMissing() { return t("The relay isn't installed yet. Restart Coucou."); }, + previewInstall: (name: string) => t("This is exactly what changes for {name}. Nothing else is touched.", { name }), + get previewRemove() { return t("This removes Coucou's entries only. Everything else stays."); }, + backup: (to: string) => (to ? t("Backup → {path}", { path: to }) : t("No existing file — nothing to back up.")), + get confirmInstall() { return t("Back up and write"); }, + get confirmRemove() { return t("Back up and remove"); }, + get cancel() { return t("Cancel"); }, + get back() { return t("Back"); }, + done: (backups: string, note: string) => { + const saved = backups + ? t("Done. Previous version saved as {paths}.", { paths: backups.split("\n").join(", ") }) + : t("Done."); + return `${saved} ${note}`; + }, + failed: (err: unknown) => t("Could not write: {error}", { error: errorText(err) }), +}; + +function errorText(err: unknown): string { + return String(err).replace(/^Error:\s*/, ""); +} + +export function agentsSection(list: AgentHookStatus[] | null): HTMLElement { + const blocks = h("div", { style: "display:flex;flex-direction:column;gap:14px" }); + if (!list || list.length === 0) { + blocks.append(h("div", { class: "hint", text: TEXT.none })); + } + for (const status of list ?? []) blocks.append(agentBlock(status)); + return h( + "section", + {}, + h("h2", {}, h("span", { text: TEXT.title })), + h("div", { class: "hint", text: TEXT.intro }), + blocks, + ); +} + +function agentBlock(initial: AgentHookStatus): HTMLElement { + let status = initial; + const head = h("div", { class: "row" }); + const body = h("div", { style: "display:flex;flex-direction:column;gap:10px" }); + const block = h("div", { style: "display:flex;flex-direction:column;gap:8px" }, head, body); + + async function refresh() { + const fresh = (await Bridge.agentHooksList())?.find((a) => a.id === status.id); + if (fresh) status = fresh; + draw(); + } + + function draw() { + clear(head); + clear(body); + const install = h("button", { + class: "primary", + text: status.installed ? TEXT.reinstall : TEXT.install, + onclick: () => void showPreview(true), + }); + // A hook pointing at a relay that isn't there would break the agent's hooks. + if (!status.hookReady) { + install.disabled = true; + install.title = TEXT.relayMissing; + } + head.append( + h("div", { style: "display:flex;align-items:center;gap:8px;min-width:132px" }, + statusDot(status.installed), + h("span", { style: "font-size:12.5px", text: status.name }), + ), + h("span", { class: "hint", text: status.approvals ? TEXT.approvals : TEXT.displayOnly }), + h("span", { class: "spacer" }), + install, + ); + if (status.installed) { + head.append(h("button", { + class: "danger", + text: TEXT.uninstall, + onclick: () => void showPreview(false), + })); + } + body.append(h("span", { class: "path", text: status.path })); + } + + async function showPreview(install: boolean) { + let plan; + try { + plan = await Bridge.agentHooksPreview(status.id, install); + } catch (err) { + // An unreadable or unexpected config stops here rather than being + // treated as empty and written over. + clear(body); + body.append( + h("div", { class: "notice err", text: errorText(err) }), + h("div", { class: "row" }, h("button", { text: TEXT.back, onclick: draw })), + ); + return; + } + clear(body); + body.append( + h("div", { class: "hint", text: install ? TEXT.previewInstall(status.name) : TEXT.previewRemove }), + renderDiff(plan.diff), + h("div", { class: "row" }, h("span", { class: "path", text: TEXT.backup(plan.backup) })), + ); + const confirm = h("button", { + class: install ? "primary" : "danger", + text: install ? TEXT.confirmInstall : TEXT.confirmRemove, + }); + confirm.addEventListener("click", async () => { + confirm.disabled = true; + try { + const backups = await Bridge.agentHooksApply(status.id, install, plan.fingerprint); + clear(body); + body.append(h("div", { class: "notice ok", text: TEXT.done(backups, install ? status.note : "") })); + window.setTimeout(() => void refresh(), 2600); + } catch (err) { + confirm.disabled = false; + body.append(h("div", { class: "notice err", text: TEXT.failed(err) })); + } + }); + body.append(h("div", { class: "row" }, confirm, h("button", { text: TEXT.cancel, onclick: draw }))); + } + + draw(); + return block; +} diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..4282bac49 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -3,9 +3,28 @@ // integrations land here too in a later stage. import "./settings.css"; -import { Bridge, onEvent, type HookStatus } from "../core/bridge"; +import { Bridge, onEvent, type HookPreview, type HookStatus, type ShortcutsReport } from "../core/bridge"; +import { CUSTOM_SERVER_KEY, providerDef, urlExposure } from "../core/providers"; +import { + ISLAND_SHORTCUTS, SHORTCUTS, SHORTCUT_TEXT, activeKeys, displayKeys, duplicates, effective, + recordPress, type Binding, +} from "../core/shortcuts"; import { DEFAULT_SETTINGS, type Settings } from "../core/state"; +import { + MAX_DECLARED, PILL_CATEGORIES, availablePills, chooseMainPill, isComingSoon, mainPillChoices, + sanitizeDeclared, toggleDeclared, type PillDefinition, +} from "../core/pills"; import { h, clear } from "../views/dom"; +import { agentsSection } from "./agents"; +import { renderDiff, statusDot } from "./parts"; +import { + LANGUAGES, N_, isRtl, onLanguageChange, resolveLanguage, setLanguage, systemLanguages, t, tn, +} from "../i18n/i18n"; + +/** Where secrets.rs keeps the keys on this OS. */ +const KEY_STORE = navigator.userAgent.includes("Windows") + ? "Windows Credential Manager" + : "Secret Service (GNOME Keyring, KWallet)"; let settings: Settings = { ...DEFAULT_SETTINGS }; let version = ""; @@ -28,17 +47,100 @@ function toggle(on: boolean, onChange: (v: boolean) => void): HTMLElement { return el; } -function statusDot(ok: boolean): HTMLElement { - return h("i", { class: "dot", style: `background:${ok ? "#22c55e" : "#f4505e"}` }); +// ── Changes to Claude Code's settings.json ──────────────────────────────────── + +/** One kind of change to ~/.claude/settings.json, with the words that go with it. */ +interface Change { + preview: (install: boolean) => Promise; + apply: (install: boolean, fingerprint: string) => Promise; + installText: string; + removeText: string; + installButton: string; + removeButton: string; + /** The note once written; `backup` is "" when there was no file to back up. */ + done: (backup: string) => string; } -function renderDiff(text: string): HTMLElement { - const box = h("div", { class: "diff" }); - for (const line of text.split("\n")) { - const cls = line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : "ctx"; - box.append(h("div", { class: cls, text: line })); +const HOOKS_CHANGE: Change = { + preview: Bridge.hooksPreview, + apply: Bridge.hooksApply, + get installText() { return t("This is exactly what will change in your settings.json. Your own hooks are left untouched."); }, + get removeText() { return t("This removes Coucou's entries only. Your own hooks are left untouched."); }, + get installButton() { return t("Back up and write"); }, + get removeButton() { return t("Back up and remove"); }, + done: (backup) => backup + ? t("Done. Previous settings saved as {backup}. Open a new Claude Code session to pick the hooks up.", { backup }) + : t("Done. Open a new Claude Code session to pick the hooks up."), +}; + +const STATUS_LINE_CHANGE: Change = { + preview: Bridge.statusLinePreview, + apply: Bridge.statusLineApply, + get installText() { return t("This is exactly what will change: only the status line. If you already have one it keeps working, Coucou's relay runs it for you."); }, + get removeText() { return t("This puts your previous status line back, or removes the entry if there was none."); }, + get installButton() { return t("Back up and write"); }, + get removeButton() { return t("Back up and remove"); }, + done: (backup) => backup + ? t("Done. Previous settings saved as {backup}. The numbers appear after the next reply of a Claude Code session.", { backup }) + : t("Done. The numbers appear after the next reply of a Claude Code session."), +}; + +/** + * Shows the diff of a change in `body` and writes it only after an explicit + * click, and only if settings.json still matches the diff that was shown. + * `back` redraws the section; `applied` runs a moment after a successful write. + */ +async function reviewChange( + body: HTMLElement, + change: Change, + install: boolean, + back: () => void, + applied: () => void, +) { + let preview; + try { + preview = await change.preview(install); + } catch (err) { + // An unreadable or invalid settings.json stops here rather than being + // treated as empty and written over. + clear(body); + body.append( + h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") }), + h("div", { class: "row" }, h("button", { text: t("Back"), onclick: back })), + ); + return; } - return box; + if (!preview) return; + clear(body); + body.append( + h("div", { class: "hint", text: install ? change.installText : change.removeText }), + renderDiff(preview.diff), + h("div", { class: "row" }, + h("span", { + class: "path", + text: preview.backup + ? t("Backup → {path}", { path: preview.backup }) + : t("No settings.json yet — nothing to back up."), + }), + ), + ); + const confirm = h("button", { + class: install ? "primary" : "danger", + text: install ? change.installButton : change.removeButton, + }); + confirm.addEventListener("click", async () => { + confirm.disabled = true; + try { + const backup = await change.apply(install, preview.fingerprint); + clear(body); + body.append(h("div", { class: "notice ok", text: change.done(backup ?? "") })); + window.setTimeout(applied, 2600); + } catch (err) { + confirm.disabled = false; + body.append(h("div", { class: "notice err", text: t("Could not write: {error}", { error: String(err) }) })); + } + }); + body.append(h("div", { class: "row" }, confirm, h("button", { text: t("Cancel"), onclick: back }))); } // ── Claude Code section ─────────────────────────────────────────────────────── @@ -52,11 +154,14 @@ function claudeSection(status: HookStatus): HTMLElement { body, ); + const redraw = () => { + clear(body); + draw(); + }; const rebuild = async () => { const fresh = await Bridge.hooksStatus(); if (fresh) Object.assign(status, fresh); - clear(body); - draw(); + redraw(); const head = section.querySelector("h2")!; clear(head); head.append(statusDot(status.installed), h("span", { text: "Claude Code" })); @@ -67,15 +172,15 @@ function claudeSection(status: HookStatus): HTMLElement { h("div", { class: "hint", text: status.installed - ? "Coucou is hooked into your Claude Code sessions. Tool calls, questions and permission requests show up in the island, and you can answer them there." - : "Install the hooks to see your Claude Code sessions in the island and approve permissions without leaving what you are doing.", + ? t("Coucou is hooked into your Claude Code sessions. Tool calls, questions and permission requests show up in the island, and you can answer them there.") + : t("Install the hooks to see your Claude Code sessions in the island and approve permissions without leaving what you are doing."), }), h("div", { class: "row" }, h("label", { text: "settings.json" }), h("span", { class: "path", text: status.settingsPath }), ), h("div", { class: "row" }, - h("label", { text: "Relay" }), + h("label", { text: t("Relay") }), h("span", { class: "path", text: status.hookPath }), statusDot(status.hookReady), ), @@ -84,87 +189,119 @@ function claudeSection(status: HookStatus): HTMLElement { if (!status.hookReady) { body.append(h("div", { class: "notice warn", - text: "coucou-hook.exe is not in place yet. Restart Coucou; if it still fails, build it with `cargo build -p coucou-hook`.", + text: t("coucou-hook.exe is not in place yet. Restart Coucou; if it still fails, build it with `cargo build -p coucou-hook`."), })); } const actions = h("div", { class: "row" }); const install = h("button", { class: "primary", - text: status.installed ? "Reinstall hooks…" : "Install hooks…", - onclick: () => showPreview(true), + text: status.installed ? t("Reinstall hooks…") : t("Install hooks…"), + onclick: () => void reviewChange(body, HOOKS_CHANGE, true, redraw, () => void rebuild()), }); // Writing hook commands that point at a relay which isn't there would give // every Claude Code session a broken hook and nothing to show for it. if (!status.hookReady) { install.disabled = true; - install.title = "The relay isn't installed yet."; + install.title = t("The relay isn't installed yet."); } actions.append(install); if (status.installed) { actions.append(h("button", { class: "danger", - text: "Uninstall hooks…", - onclick: () => showPreview(false), + text: t("Uninstall hooks…"), + onclick: () => void reviewChange(body, HOOKS_CHANGE, false, redraw, () => void rebuild()), })); } body.append(actions); } - async function showPreview(install: boolean) { - let preview; - try { - preview = await Bridge.hooksPreview(install); - } catch (err) { - // An unreadable or invalid settings.json stops here rather than being - // treated as empty and written over. - clear(body); - body.append( - h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") }), - h("div", { class: "row" }, h("button", { - text: "Back", - onclick: () => { clear(body); draw(); }, - })), - ); - return; - } - if (!preview) return; + draw(); + return section; +} + +// ── Plan usage section ──────────────────────────────────────────────────────── + +/** + * The 5-hour and weekly limits in the island's header. They come from Claude + * Code's status line, so the relay has to be the status line first: turning the + * switch on without it starts the install, and the switch only stays on once + * that has been confirmed. A status line the user had keeps working. + */ +const PLAN_SETTINGS_TEXT = { + get claude() { return t("Shows your Claude plan usage (5-hour and weekly limits) in the island's header. Coucou adds a status line relay in ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only."); }, + get showClaude() { return t("Show in notch"); }, + get codex() { return t("Shows your Codex plan usage (weekly limit and free resets left) in the island's header. Coucou asks the Codex CLI (codex app-server) when the pill shows; nothing is installed. Codex must be signed in with ChatGPT."); }, + get showCodex() { return t("Show Codex plan in the notch"); }, +}; + +function planSection(status: HookStatus): HTMLElement { + const body = h("div", { style: "display:flex;flex-direction:column;gap:12px" }); + const section = h("section", {}, h("h2", {}, h("span", { text: t("Plan usage") })), body); + + const redraw = () => { clear(body); + draw(); + }; + const rebuild = async () => { + const fresh = await Bridge.hooksStatus(); + if (fresh) Object.assign(status, fresh); + settings.planRelayInstalled = status.planRelayInstalled; + // Cancelled or failed: a switch that was waiting for the install falls back. + if (!status.planRelayInstalled) settings.showPlanInNotch = false; + redraw(); + }; + + function draw() { + // The switch shows "on" while the install it asked for is being reviewed. + const sw = toggle(settings.showPlanInNotch, (on) => { + if (!on) { + settings.showPlanInNotch = false; + void save(); + } else if (status.planRelayInstalled) { + settings.showPlanInNotch = true; + void save(); + } else { + // Turned on before the relay is in: install it first; it stays on once confirmed. + void reviewChange(body, STATUS_LINE_CHANGE, true, () => void rebuild(), () => { + settings.planRelayInstalled = true; + settings.showPlanInNotch = true; + void save().then(rebuild); + }); + } + }); body.append( h("div", { class: "hint", - text: install - ? "This is exactly what will change in your settings.json. Your own hooks are left untouched." - : "This removes Coucou's entries only. Your own hooks are left untouched.", + text: PLAN_SETTINGS_TEXT.claude, }), - renderDiff(preview.diff), + h("div", { class: "row" }, h("label", { text: PLAN_SETTINGS_TEXT.showClaude }), sw), h("div", { class: "row" }, - h("span", { class: "path", text: `Backup → ${preview.backup}` }), + h("label", { text: t("Relay") }), + statusDot(status.planRelayInstalled), + h("span", { class: "hint", text: status.planRelayInstalled ? t("installed") : t("not installed") }), + status.planRelayInstalled + ? h("button", { + class: "danger", + text: t("Uninstall relay…"), + onclick: () => void reviewChange(body, STATUS_LINE_CHANGE, false, redraw, () => void rebuild()), + }) + : h("button", { + class: "primary", + text: t("Install relay…"), + onclick: () => void reviewChange(body, STATUS_LINE_CHANGE, true, redraw, () => void rebuild()), + }), + ), + // Codex: nothing to install, Coucou asks the Codex CLI when the pill shows. + h("div", { class: "hint", text: PLAN_SETTINGS_TEXT.codex }), + h("div", { class: "row" }, + h("label", { text: PLAN_SETTINGS_TEXT.showCodex }), + toggle(settings.showCodexPlanInNotch, (on) => { + settings.showCodexPlanInNotch = on; + void save(); + }), ), ); - const confirm = h("button", { - class: install ? "primary" : "danger", - text: install ? "Back up and write" : "Back up and remove", - }); - confirm.addEventListener("click", async () => { - confirm.disabled = true; - try { - const backup = await Bridge.hooksApply(install, preview.fingerprint); - clear(body); - body.append(h("div", { - class: "notice ok", - text: `Done. Previous settings saved as ${backup}. Open a new Claude Code session to pick the hooks up.`, - })); - window.setTimeout(() => void rebuild(), 2600); - } catch (err) { - confirm.disabled = false; - body.append(h("div", { class: "notice err", text: `Could not write: ${String(err)}` })); - } - }); - body.append(h("div", { class: "row" }, confirm, h("button", { - text: "Cancel", - onclick: () => { clear(body); draw(); }, - }))); } draw(); @@ -181,27 +318,27 @@ const MODELS: [string, string][] = [ function apiSection(hasKey: boolean): HTMLElement { const dot = statusDot(hasKey); - const state = h("span", { class: "hint", text: hasKey ? "Key saved in the Windows Credential Manager." : "No key yet — the chat needs one." }); + const state = h("span", { class: "hint", text: hasKey ? t("Key saved in the {store}.", { store: KEY_STORE }) : t("No key yet — the chat needs one.") }); const field = h("input", { type: "password", - placeholder: hasKey ? "•••••••••••• (stored)" : "sk-ant-...", + placeholder: hasKey ? `•••••••••••• ${t("(stored)")}` : "sk-ant-...", style: "flex:1 1 auto;min-width:0", autocomplete: "off", spellcheck: "false", }) as HTMLInputElement; - const saveBtn = h("button", { class: "primary", text: "Save key" }); - const clearBtn = h("button", { class: "danger", text: "Remove" }); + const saveBtn = h("button", { class: "primary", text: t("Save key") }); + const clearBtn = h("button", { class: "danger", text: t("Remove") }); const feedback = h("div", {}); async function refresh() { const present = (await Bridge.secretPresent("anthropic-api-key")) ?? false; dot.style.background = present ? "#22c55e" : "#f4505e"; state.textContent = present - ? "Key saved in the Windows Credential Manager." - : "No key yet — the chat needs one."; - field.placeholder = present ? "•••••••••••• (stored)" : "sk-ant-..."; + ? t("Key saved in the {store}.", { store: KEY_STORE }) + : t("No key yet — the chat needs one."); + field.placeholder = present ? `•••••••••••• ${t("(stored)")}` : "sk-ant-..."; clearBtn.style.display = present ? "" : "none"; } @@ -212,10 +349,10 @@ function apiSection(hasKey: boolean): HTMLElement { try { await Bridge.secretSet("anthropic-api-key", value); field.value = ""; - feedback.append(h("div", { class: "notice ok", text: "Saved. It never touches disk." })); + feedback.append(h("div", { class: "notice ok", text: t("Saved. It never touches disk.") })); await refresh(); } catch (err) { - feedback.append(h("div", { class: "notice err", text: `Could not save: ${String(err)}` })); + feedback.append(h("div", { class: "notice err", text: t("Could not save: {error}", { error: String(err) }) })); } }); @@ -223,10 +360,10 @@ function apiSection(hasKey: boolean): HTMLElement { clear(feedback); try { await Bridge.secretClear("anthropic-api-key"); - feedback.append(h("div", { class: "notice ok", text: "Key removed." })); + feedback.append(h("div", { class: "notice ok", text: t("Key removed.") })); await refresh(); } catch (err) { - feedback.append(h("div", { class: "notice err", text: `Could not remove: ${String(err)}` })); + feedback.append(h("div", { class: "notice err", text: t("Could not remove: {error}", { error: String(err) }) })); } }); @@ -248,12 +385,355 @@ function apiSection(hasKey: boolean): HTMLElement { {}, h("h2", {}, dot, h("span", { text: "Claude" })), state, - h("div", { class: "row" }, h("label", { text: "API key" }), field, saveBtn, clearBtn), - h("div", { class: "row" }, h("label", { text: "Model" }), model), + h("div", { class: "row" }, h("label", { text: t("API key") }), field, saveBtn, clearBtn), + h("div", { class: "row" }, h("label", { text: t("Model") }), model), feedback, ); } +// ── Active pills section ────────────────────────────────────────────────────── + +/** + * The tools you use (Mac 0.1.1–0.1.2): pick the main workspace tool, which is + * always on and takes no slot, and declare the agents and chat providers you + * want as pills. Services are declared in Integrations below, next to their keys. + */ +function activePillsSection(connected: Record): HTMLElement { + const slots = h("div", { class: "hint" }); + const main = h("select", {}) as HTMLSelectElement; + for (const def of mainPillChoices()) main.append(h("option", { value: def.id, text: def.name })); + main.addEventListener("change", () => { + const next = chooseMainPill(settings, main.value); + if (!next) return; + settings.mainPill = next.mainPill; + settings.activeIntegrations = next.activeIntegrations; + declaredChanged(); + }); + const groups = h("div", { style: "display:flex;flex-direction:column;gap:12px" }); + + /** Why a pill would show nothing yet, as on the Mac's row. */ + function hint(def: PillDefinition): string | null { + if (isComingSoon(def.id)) return t("Coming soon"); + if (def.connect.kind === "hooks" && !connected[def.id]) return t("Hooks not installed"); + if (def.connect.kind === "key" && !connected[def.id]) return t("Key not configured"); + if (def.connect.kind === "server" && !settings[def.connect.field]) return t("Not connected"); + return null; + } + + function row(def: PillDefinition): HTMLElement { + const isMain = def.id === settings.mainPill; + const on = settings.activeIntegrations.includes(def.id); + const full = !isMain && !on && settings.activeIntegrations.length >= MAX_ACTIVE; + const el = h("div", { class: full ? "pill-row full" : "pill-row" }, + h("i", { class: "dot", style: `background:${def.color};width:10px;height:10px` }), + h("span", { class: "name", text: def.name }), + ); + if (isMain) { + el.append(h("span", { class: "state", text: t("Main") })); + return el; + } + const why = hint(def); + el.append(h("span", { class: "state", text: why ?? "" })); + const sw = h("button", { class: on ? "switch on" : "switch" }) as HTMLButtonElement; + sw.disabled = full; + sw.addEventListener("click", () => { + const next = toggleDeclared(settings, def.id); + if (!next) return; + settings.activeIntegrations = next; + declaredChanged(); + }); + el.append(sw); + return el; + } + + function draw() { + const used = settings.activeIntegrations.length; + slots.textContent = t("{used}/{max} slots in use — the main tool doesn't take one.", { used, max: MAX_ACTIVE }); + slots.classList.toggle("full", used >= MAX_ACTIVE); + main.value = settings.mainPill; + clear(groups); + for (const cat of PILL_CATEGORIES) { + if (cat.id === "service") continue; + const pills = availablePills().filter((p) => p.category === cat.id); + if (pills.length === 0) continue; + groups.append(h("div", { class: "pill-group" }, h("h3", { text: t(cat.title) }), ...pills.map(row))); + } + } + declaredViews.push(draw); + draw(); + + return h( + "section", + {}, + h("h2", {}, h("span", { text: t("Active pills") })), + h("div", { class: "hint", text: t("Choose the tools you use. Coucou only shows what you declare here.") }), + slots, + h("div", { class: "row" }, h("label", { text: t("Main tool") }), main), + groups, + ); +} + +// ── Chat providers section ──────────────────────────────────────────────────── + +const CHAT_STRINGS = { + get providersTitle() { return t("Chat providers"); }, + get providersHint() { return t("Chat with Google AI, OpenAI or OpenRouter instead of Claude: add a key here, then click the model name above the chat box to switch provider and model. Keys stay in the system keychain. These providers get no web search and no tools: they can answer, never act on this computer."); }, + get stored() { return `•••••••• ${t("(stored)")}`; }, + get save() { return t("Save"); }, + get remove() { return t("Remove"); }, + get localTitle() { return t("Local models"); }, + get localHint() { return t("Chat with a model you run yourself: Ollama or LM Studio (leave the address empty for the usual one on this computer), or any server that speaks the OpenAI API, such as vLLM or llama.cpp. Once connected, pick it above the chat box."); }, + get connect() { return t("Connect"); }, + get connecting() { return t("Connecting…"); }, + get disconnect() { return t("Disconnect"); }, + get useInChat() { return t("Use in chat"); }, + get inUse() { return t("In use"); }, + get keyOptional() { return t("API key (optional)"); }, + get localOnly() { return t("Nothing leaves your PC: the server runs on this computer."); }, + get remote() { return t("This address is another machine: what you ask is sent to it."); }, + get remoteHttp() { return t("This address is another machine, over plain http: what you ask travels unencrypted."); }, + get keyOverHttp() { return t("Warning: the key would be sent unencrypted (http://) to another machine. Use https://, or a server on this computer."); }, + get invalid() { return t("Not a valid http:// or https:// address."); }, + noModels: (name: string) => t("No models yet. Download one in {name} first.", { name }), + models: (n: number) => tn("{count} model", "{count} models", n), +}; + +interface CloudDef { + id: "google" | "openai" | "openrouter"; + name: string; + placeholder: string; + where: string; +} + +const CLOUD: CloudDef[] = [ + { id: "google", name: "Google AI", placeholder: "AIza…", where: "aistudio.google.com" }, + { id: "openai", name: "OpenAI", placeholder: "sk-…", where: "platform.openai.com" }, + { id: "openrouter", name: "OpenRouter", placeholder: "sk-or-…", where: "openrouter.ai/keys" }, +]; + +function chatProvidersSection( + present: Record, + keyChanged: (key: string, on: boolean) => void, +): HTMLElement { + const list = h("div", { style: "display:flex;flex-direction:column;gap:8px" }); + for (const def of CLOUD) { + const p = providerDef(def.id); + const key = p.key!; + const input = h("input", { + type: "password", + placeholder: present[key] ? CHAT_STRINGS.stored : def.placeholder, + autocomplete: "off", + spellcheck: "false", + style: "flex:1 1 auto;min-width:0", + }) as HTMLInputElement; + const dotEl = statusDot(present[key] ?? false); + const saveBtn = h("button", { text: CHAT_STRINGS.save }); + const removeBtn = h("button", { class: "danger", text: CHAT_STRINGS.remove }); + const refresh = () => { + input.placeholder = present[key] ? CHAT_STRINGS.stored : def.placeholder; + dotEl.style.background = present[key] ? "#22c55e" : "#f4505e"; + removeBtn.style.display = present[key] ? "" : "none"; + }; + saveBtn.addEventListener("click", async () => { + const value = input.value.trim(); + if (!value) return; + try { + await Bridge.secretSet(key, value); + present[key] = true; + input.value = ""; + keyChanged(key, true); + } catch { + dotEl.style.background = "#f5a524"; + return; + } + refresh(); + }); + removeBtn.addEventListener("click", async () => { + try { + await Bridge.secretClear(key); + present[key] = false; + keyChanged(key, false); + } catch { + dotEl.style.background = "#f5a524"; + return; + } + refresh(); + }); + refresh(); + list.append( + h("div", { class: "row" }, + h("label", {}, + h("i", { class: "dot", style: `background:${p.accent};margin-right:8px` }), + h("span", { text: def.name }), + ), + input, saveBtn, removeBtn, dotEl, + ), + h("div", { class: "hint", style: "margin:-4px 0 0 144px", text: t("Key from {site}", { site: def.where }) }), + ); + } + return h( + "section", + {}, + h("h2", {}, h("span", { text: CHAT_STRINGS.providersTitle })), + h("div", { class: "hint", text: CHAT_STRINGS.providersHint }), + list, + ); +} + +// ── Local models section ────────────────────────────────────────────────────── + +type LocalId = "ollama" | "lmstudio" | "custom"; + +/** Redraws the local models section after a change made elsewhere (the island). */ +let localRedraw: (() => void) | null = null; + +const LOCAL: Record = { + ollama: { name: "Ollama", usual: "http://127.0.0.1:11434" }, + lmstudio: { name: "LM Studio", usual: "http://127.0.0.1:1234" }, + // No usual address: any server that speaks the OpenAI API. + custom: { name: N_("OpenAI-compatible"), usual: "" }, +}; + +/** What an address means for the user's data, as a hint line. */ +function exposureNotice(url: string, withKey: boolean): HTMLElement | null { + switch (urlExposure(url)) { + case "local": + return h("div", { class: "hint", text: CHAT_STRINGS.localOnly }); + case "remote": + return h("div", { class: "hint", text: CHAT_STRINGS.remote }); + case "remote-http": + return withKey + ? h("div", { class: "notice warn", text: CHAT_STRINGS.keyOverHttp }) + : h("div", { class: "hint", text: CHAT_STRINGS.remoteHttp }); + case "invalid": + return url.trim() ? h("div", { class: "notice err", text: CHAT_STRINGS.invalid }) : null; + } +} + +function localSection(customKey: boolean): HTMLElement { + const body = h("div", { style: "display:flex;flex-direction:column;gap:14px" }); + const section = h( + "section", + {}, + h("h2", {}, h("span", { text: CHAT_STRINGS.localTitle })), + h("div", { class: "hint", text: CHAT_STRINGS.localHint }), + body, + ); + const redraw = () => { + clear(body); + for (const id of Object.keys(LOCAL) as LocalId[]) body.append(serverBlock(id)); + }; + + function serverBlock(id: LocalId): HTMLElement { + const def = LOCAL[id]; + const p = providerDef(id); + const field = p.urlField!; + const connected = settings[field] !== ""; + const status = h("div", {}); + const exposure = h("div", {}); + const label = h("label", {}, + h("i", { class: "dot", style: `background:${p.accent};margin-right:8px` }), + h("span", { text: t(def.name) }), + ); + const block = h("div", { style: "display:flex;flex-direction:column;gap:6px" }); + + if (connected) { + const inUse = settings.chatProvider === id; + const use = h("button", { class: inUse ? "" : "primary", text: inUse ? CHAT_STRINGS.inUse : CHAT_STRINGS.useInChat }); + use.disabled = inUse; + use.addEventListener("click", () => { + settings.chatProvider = id; + void save().then(redraw); + }); + const disconnect = h("button", { class: "danger", text: CHAT_STRINGS.disconnect }); + disconnect.addEventListener("click", async () => { + settings[field] = ""; + if (settings.chatProvider === id) settings.chatProvider = "anthropic"; + if (id === "custom") { + await Bridge.secretClear(CUSTOM_SERVER_KEY).catch(() => {}); + customKey = false; + } + await save(); + redraw(); + }); + block.append( + h("div", { class: "row" }, label, h("span", { class: "path", text: settings[field] }), statusDot(true), use, disconnect), + status, + ); + exposure.append(exposureNotice(settings[field], id === "custom" && customKey) ?? ""); + block.append(exposure); + return block; + } + + const input = h("input", { + type: "text", + placeholder: def.usual || "https://llm.example.com", + style: "flex:1 1 auto;min-width:0", + spellcheck: "false", + autocomplete: "off", + }) as HTMLInputElement; + // A custom server may want a key; it goes to the keychain, never to settings.json. + const key = h("input", { + type: "password", + placeholder: customKey ? CHAT_STRINGS.stored : CHAT_STRINGS.keyOptional, + style: "flex:1 1 auto;min-width:0", + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + const connect = h("button", { class: "primary", text: CHAT_STRINGS.connect }); + + const showExposure = () => { + clear(exposure); + const withKey = id === "custom" && (customKey || key.value.trim() !== ""); + const notice = exposureNotice(input.value || def.usual, withKey); + if (notice) exposure.append(notice); + }; + input.addEventListener("input", showExposure); + key.addEventListener("input", showExposure); + + connect.addEventListener("click", async () => { + connect.disabled = true; + clear(status); + status.append(h("div", { class: "hint", text: CHAT_STRINGS.connecting })); + try { + if (id === "custom" && key.value.trim()) { + // Stored with this address: the key is only ever sent there. + await Bridge.localSetKey(input.value || def.usual, key.value.trim()); + key.value = ""; + customKey = true; + } + const server = await Bridge.localConnect(id, input.value); + if (!server.models.length) { + clear(status); + status.append(h("div", { class: "notice err", text: CHAT_STRINGS.noModels(t(def.name)) })); + } else { + settings[field] = server.url; + if (!server.models.includes(settings.chatModels[id] ?? "")) { + settings.chatModels = { ...settings.chatModels, [id]: server.models[0] }; + } + await save(); + redraw(); + return; + } + } catch (err) { + clear(status); + status.append(h("div", { class: "notice err", text: String(err).replace(/^Error:\s*/, "") })); + } + connect.disabled = false; + }); + + block.append(h("div", { class: "row" }, label, input, connect)); + if (id === "custom") block.append(h("div", { class: "row" }, h("label", { text: "" }), key)); + block.append(status, exposure); + showExposure(); + return block; + } + + redraw(); + localRedraw = redraw; + return section; +} + // ── Integrations section ────────────────────────────────────────────────────── interface IntegrationDef { @@ -262,29 +742,40 @@ interface IntegrationDef { color: string; /** Credential Manager keys, in the order they are shown. */ fields: { key: string; label: string; placeholder: string; secret: boolean }[]; + /** What the key needs, shown under its field. */ + hint?: string; } const INTEGRATIONS: IntegrationDef[] = [ { id: "integration_stripe", name: "Stripe", color: "#0570DE", - fields: [{ key: "stripe-api-key", label: "Secret key", placeholder: "sk_live_…", secret: true }] }, + fields: [{ key: "stripe-api-key", label: N_("Secret key"), placeholder: "sk_live_…", secret: true }] }, { id: "integration_github", name: "GitHub", color: "#F4505E", - fields: [{ key: "github-token", label: "Token", placeholder: "ghp_…", secret: true }] }, + fields: [{ key: "github-token", label: N_("Token"), placeholder: "ghp_…", secret: true }], + hint: N_("Classic token with the repo scope, or fine-grained with read access to Pull requests, Commit statuses and Actions.") }, { id: "integration_vercel", name: "Vercel", color: "#7C5CFF", - fields: [{ key: "vercel-token", label: "Token", placeholder: "…", secret: true }] }, + fields: [{ key: "vercel-token", label: N_("Token"), placeholder: "…", secret: true }] }, { id: "integration_n8n", name: "n8n", color: "#F29B38", fields: [ - { key: "n8n-url", label: "Instance URL", placeholder: "https://n8n.example.com", secret: false }, - { key: "n8n-api-key", label: "API key", placeholder: "…", secret: true }, + { key: "n8n-url", label: N_("Instance URL"), placeholder: "https://n8n.example.com", secret: false }, + { key: "n8n-api-key", label: N_("API key"), placeholder: "…", secret: true }, ] }, { id: "integration_resend", name: "Resend", color: "#22C55E", - fields: [{ key: "resend-api-key", label: "API key", placeholder: "re_…", secret: true }] }, + fields: [{ key: "resend-api-key", label: N_("API key"), placeholder: "re_…", secret: true }] }, { id: "integration_notion", name: "Notion", color: "#8C8C8C", - fields: [{ key: "notion-api-key", label: "Integration token", placeholder: "ntn_…", secret: true }] }, + fields: [{ key: "notion-api-key", label: N_("Integration token"), placeholder: "ntn_…", secret: true }] }, { id: "integration_calcom", name: "Cal.com", color: "#C9956A", - fields: [{ key: "calcom-api-key", label: "API key", placeholder: "cal_…", secret: true }] }, + fields: [{ key: "calcom-api-key", label: N_("API key"), placeholder: "cal_…", secret: true }] }, ]; -const MAX_ACTIVE = 4; +const MAX_ACTIVE = MAX_DECLARED; + +/** Everything that shows the declared pills, redrawn when any of them changes. */ +const declaredViews: (() => void)[] = []; + +function declaredChanged() { + for (const redraw of declaredViews) redraw(); + void save(); +} function integrationsSection(present: Record): HTMLElement { const note = h("div", { class: "hint" }); @@ -292,8 +783,9 @@ function integrationsSection(present: Record): HTMLElement { function updateNote() { const used = settings.activeIntegrations.length; - note.textContent = `Pick up to ${MAX_ACTIVE} pills to show next to Mochi — ${used}/${MAX_ACTIVE} in use. Keys are stored in the Windows Credential Manager, never on disk.`; + note.textContent = t("Pick up to {max} pills to show next to Mochi — {used}/{max} in use. Keys are stored in the {store}, never on disk.", { max: MAX_ACTIVE, used, store: KEY_STORE }); } + declaredViews.push(updateNote); for (const def of INTEGRATIONS) { const active = settings.activeIntegrations.includes(def.id); @@ -307,20 +799,19 @@ function integrationsSection(present: Record): HTMLElement { settings.activeIntegrations = [...settings.activeIntegrations, def.id]; } sw.classList.toggle("on", !on); - updateNote(); - void save(); + declaredChanged(); }); const rows = h("div", { style: "display:flex;flex-direction:column;gap:6px;flex:1 1 auto;min-width:0" }); for (const field of def.fields) { const input = h("input", { type: field.secret ? "password" : "text", - placeholder: present[field.key] ? "•••••••• (stored)" : field.placeholder, + placeholder: present[field.key] ? CHAT_STRINGS.stored : field.placeholder, autocomplete: "off", spellcheck: "false", style: "flex:1 1 auto;min-width:0", }) as HTMLInputElement; - const saveBtn = h("button", { text: "Save" }); + const saveBtn = h("button", { text: t("Save") }); const dotEl = statusDot(present[field.key] ?? false); saveBtn.addEventListener("click", async () => { const value = input.value.trim(); @@ -328,7 +819,7 @@ function integrationsSection(present: Record): HTMLElement { await Bridge.secretSet(field.key, value); present[field.key] = value.length > 0; input.value = ""; - input.placeholder = value ? "•••••••• (stored)" : field.placeholder; + input.placeholder = value ? CHAT_STRINGS.stored : field.placeholder; dotEl.style.background = value ? "#22c55e" : "#f4505e"; } catch { dotEl.style.background = "#f5a524"; @@ -336,12 +827,14 @@ function integrationsSection(present: Record): HTMLElement { }); rows.append( h("div", { class: "row" }, - h("label", { style: "min-width:104px", text: field.label }), + h("label", { style: "min-width:104px", text: t(field.label) }), input, saveBtn, dotEl, ), ); } + if (def.hint) rows.append(h("div", { class: "hint", text: t(def.hint) })); + list.append( h("div", { style: "display:flex;gap:12px;align-items:flex-start" }, h("div", { style: "display:flex;align-items:center;gap:8px;min-width:132px;padding-top:4px" }, @@ -355,7 +848,7 @@ function integrationsSection(present: Record): HTMLElement { } updateNote(); - return h("section", {}, h("h2", {}, h("span", { text: "Integrations" })), note, list); + return h("section", {}, h("h2", {}, h("span", { text: t("Integrations") })), note, list); } // ── General section ─────────────────────────────────────────────────────────── @@ -383,40 +876,347 @@ function generalSection(): HTMLElement { const screen = h("select", {}) as HTMLSelectElement; screen.append( - h("option", { value: "primary", text: "Main display" }), - h("option", { value: "cursor", text: "Display under the cursor" }), + h("option", { value: "primary", text: t("Main display") }), + h("option", { value: "cursor", text: t("Display under the cursor") }), ); screen.value = settings.screen; + void Bridge.listMonitors().then((list) => { + for (const m of list ?? []) screen.append(h("option", { value: m.key, text: m.label })); + // Set again now the option exists. A display saved under an older key (moved, + // resized, or saved before names were kept) is shown by its place or its + // name; one that is gone shows as the main one. + const saved = settings.screen; + const [place, name] = saved.split("|"); + const keys = (list ?? []).map((m) => m.key); + screen.value = + keys.find((k) => k === saved) ?? + (saved.startsWith("at:") ? keys.find((k) => k.split("|")[0] === place) : undefined) ?? + (name ? keys.find((k) => k.split("|")[1] === name) : undefined) ?? + saved; + if (!screen.value) screen.value = "primary"; + }); screen.addEventListener("change", () => { - settings.screen = screen.value as Settings["screen"]; + settings.screen = screen.value; void save(); }); return h( "section", {}, - h("h2", {}, h("span", { text: "General" })), + h("h2", {}, h("span", { text: t("General") })), h("div", { class: "row" }, - h("label", { text: "Sound" }), + h("label", { text: t("Sound") }), toggle(settings.soundEnabled, (v) => { settings.soundEnabled = v; void save(); }), volume, ), h("div", { class: "row" }, - h("label", { text: "Auto-close" }), + h("label", { text: t("Auto-close") }), autoClose, - h("span", { class: "hint", text: "seconds after you leave the island" }), + h("span", { class: "hint", text: t("seconds after you leave the island") }), ), h("div", { class: "row" }, - h("label", { text: "Island lives on" }), + h("label", { text: t("Island lives on") }), screen, ), h("div", { class: "row" }, - h("label", { text: "Launch at startup" }), + h("label", { text: t("Launch at startup") }), toggle(settings.autostart, (v) => { settings.autostart = v; void save(); }), ), + ...recapRows(), + languageRow(), + ); +} + +/** + * Settings → General → Language, as on the Mac: "System" follows the + * system's language when Coucou has it (else English), or one of the ten. + * Both windows and the tray switch in place, without a restart. + */ +function languageRow(): HTMLElement { + const select = h("select", {}) as HTMLSelectElement; + select.append(h("option", { value: "", text: t("System") })); + for (const { code, name } of LANGUAGES) select.append(h("option", { value: code, text: name, lang: code })); + select.value = LANGUAGES.some((l) => l.code === settings.language) ? settings.language : ""; + select.addEventListener("change", () => { + settings.language = select.value; + void save(); + applyLanguage(); + }); + return h("div", { class: "row" }, h("label", { text: t("Language") }), select); +} + +// ── Shortcuts section ───────────────────────────────────────────────────────── + +const SHORTCUTS_UI = { + get title() { return t("Shortcuts"); }, + get hint() { return t("Work from any app. Click a shortcut to change it, then press the new keys — Esc cancels, Backspace removes it."); }, + get global() { return t("From anywhere"); }, + get island() { return t("In the open island"); }, + get recording() { return t("Press keys…"); }, + get none() { return t("None"); }, + get reset() { return t("Reset to defaults"); }, + get inUse() { return t("In use by another app"); }, + get duplicate() { return t("Used twice"); }, + get invalid() { return t("Not a valid shortcut"); }, + get unavailable() { return t("Not available"); }, + types: (ch: string) => t("Types “{char}”", { char: ch }), + typesNote: (keys: string, ch: string) => + t("{keys} types “{char}” on your keyboard, so it can't be a shortcut. Pick another key.", { keys, char: ch }), + get needsModifier() { return t("Hold Ctrl, Alt or the Windows key with it."); }, + get unsupportedKey() { return t("That key can't be used in a shortcut."); }, + get wayland() { + return t("Your Wayland desktop doesn't let apps listen for keys outside their own windows. Add the shortcuts in your system's keyboard settings instead, with these commands:"); + }, + get noDisplay() { return t("No display server was found, so global shortcuts are off."); }, +}; + +function shortcutsSection(initial: ShortcutsReport | null): HTMLElement { + let report = initial; + const list = h("div", { class: "shortcut-list" }); + const feedback = h("div", {}); + const blockedNote = h("div", {}); + + let stopRecording: (() => void) | null = null; + + function store(id: string, binding: Binding) { + settings.shortcuts = { ...settings.shortcuts, [id]: binding }; + void save(); + } + + function tagFor(id: string, dups: Set): HTMLElement | null { + if (dups.has(id)) return h("span", { class: "tag err", text: SHORTCUTS_UI.duplicate }); + const st = report?.actions.find((a) => a.id === id); + switch (st?.status) { + case "inUse": return h("span", { class: "tag warn", text: SHORTCUTS_UI.inUse }); + case "duplicate": return h("span", { class: "tag err", text: SHORTCUTS_UI.duplicate }); + case "invalid": return h("span", { class: "tag err", text: SHORTCUTS_UI.invalid }); + case "typesCharacter": return h("span", { class: "tag warn", text: SHORTCUTS_UI.types(st.typed ?? "?") }); + case "unsupported": return h("span", { class: "tag", text: SHORTCUTS_UI.unavailable }); + default: return null; + } + } + + function record(id: string, binding: Binding, button: HTMLButtonElement) { + stopRecording?.(); + clear(feedback); + button.classList.add("recording"); + button.textContent = SHORTCUTS_UI.recording; + void Bridge.shortcutsSuspend(true); + + const onKey = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + const result = recordPress(e); + switch (result.kind) { + case "pending": + return; + case "keys": + finish(); + store(id, { keys: result.keys, enabled: true }); + return; + case "clear": + finish(); + store(id, { keys: "", enabled: binding.enabled }); + return; + case "typesCharacter": + finish(); + feedback.append(h("div", { + class: "notice warn", + text: SHORTCUTS_UI.typesNote(displayKeys(result.keys), result.typed), + })); + return; + case "needsModifier": + feedback.replaceChildren(h("div", { class: "notice warn", text: SHORTCUTS_UI.needsModifier })); + return; + case "unsupported": + feedback.replaceChildren(h("div", { class: "notice warn", text: SHORTCUTS_UI.unsupportedKey })); + return; + case "cancel": + finish(); + return; + } + }; + const onBlur = () => finish(); + + function finish() { + window.removeEventListener("keydown", onKey, true); + window.removeEventListener("blur", onBlur); + stopRecording = null; + // Takes the global shortcuts back, from what is saved by now. + void Bridge.shortcutsSuspend(false); + draw(); + } + stopRecording = finish; + window.addEventListener("keydown", onKey, true); + window.addEventListener("blur", onBlur); + } + + function draw() { + clear(list); + const dups = duplicates(activeKeys(settings.shortcuts)); + for (const d of SHORTCUTS) { + if (!d.ported) continue; + const binding = effective(d, settings.shortcuts); + const keycap = h("button", { + class: "keycap", + text: binding.keys ? displayKeys(binding.keys) : SHORTCUTS_UI.none, + }) as HTMLButtonElement; + keycap.disabled = !binding.enabled; + keycap.addEventListener("click", () => record(d.id, binding, keycap)); + const sw = toggle(binding.enabled, (on) => store(d.id, { keys: binding.keys, enabled: on })); + const tag = binding.enabled ? tagFor(d.id, dups) : null; + list.append(h("div", { class: binding.enabled ? "row shortcut" : "row shortcut off" }, + sw, + h("span", { class: "shortcut-name", text: t(SHORTCUT_TEXT[d.id]) }), + ...(tag ? [tag] : []), + keycap, + )); + } + + clear(blockedNote); + if (report?.blocked === "wayland") { + const commands = h("div", { class: "diff" }); + for (const d of SHORTCUTS) { + if (d.ported) commands.append(h("div", { class: "ctx", text: `${report.command} ${d.id}` })); + } + blockedNote.append(h("div", { class: "notice warn", text: SHORTCUTS_UI.wayland }), commands); + } else if (report?.blocked) { + blockedNote.append(h("div", { class: "notice warn", text: SHORTCUTS_UI.noDisplay })); + } + } + + const islandList = h("div", { class: "shortcut-list" }); + for (const row of ISLAND_SHORTCUTS) { + islandList.append(h("div", { class: "row shortcut" }, + h("span", { class: "shortcut-name", text: t(row.description) }), + h("span", { class: "keycap static", text: row.keys }), + )); + } + + const reset = h("button", { + text: SHORTCUTS_UI.reset, + onclick: () => { + stopRecording?.(); + settings.shortcuts = {}; + clear(feedback); + void save(); + draw(); + }, + }); + + // The events are listened to once (see main); only the section on screen redraws. + shortcutsListener = { + report(fresh) { + report = fresh; + if (!stopRecording) draw(); + }, + settingsChanged() { + if (!stopRecording) draw(); + }, + }; + + draw(); + return h( + "section", + {}, + h("h2", {}, h("span", { text: SHORTCUTS_UI.title })), + h("div", { class: "hint", text: SHORTCUTS_UI.hint }), + h("div", { class: "subhead", text: SHORTCUTS_UI.global }), + list, + blockedNote, + feedback, + h("div", { class: "row" }, reset), + h("div", { class: "subhead", text: SHORTCUTS_UI.island }), + islandList, ); } +/** Settings → General → Weekly recap. The prefs live with the history in Rust. */ +function recapRows(): HTMLElement[] { + const T = { + label: t("Weekly recap"), + keep: t("Keep a history of my coding sessions"), + clear: t("Clear history"), + cleared: t("History cleared."), + about: t("Counts and project names only — never commands, files or prompts. Kept on this computer for 12 weeks."), + }; + const feedback = h("span", { class: "hint" }); + const sw = toggle(true, (v) => { void Bridge.recapSetEnabled(v); }); + void Bridge.recapPrefs().then((prefs) => { + if (prefs) sw.classList.toggle("on", prefs.enabled); + }); + const clearBtn = h("button", { + class: "danger", + text: T.clear, + onclick: async () => { + clearBtn.disabled = true; + await Bridge.recapClear(); + feedback.textContent = T.cleared; + window.setTimeout(() => { + clearBtn.disabled = false; + feedback.textContent = ""; + }, 2400); + }, + }) as HTMLButtonElement; + return [ + h("div", { class: "row" }, + h("label", { text: T.label }), + sw, + h("span", { class: "hint", text: T.keep }), + ), + h("div", { class: "row" }, + h("label", {}), + clearBtn, + feedback, + ), + h("div", { class: "row" }, + h("label", {}), + h("span", { class: "hint", style: "flex:1 1 0;min-width:0", text: T.about }), + ), + ]; +} + +// ── Language ────────────────────────────────────────────────────────────────── + +/** The shortcuts section on screen, told about the events listened to once in main. */ +let shortcutsListener: { report(fresh: ShortcutsReport): void; settingsChanged(): void } | null = null; + +/** + * Shows the language Settings asks for. A change redraws the window in place, + * where it was scrolled to: nothing reloads, nothing is written. + */ +function applyLanguage() { + setLanguage(resolveLanguage(settings.language, systemLanguages())); +} + +function applyDirection() { + document.documentElement.dir = isRtl() ? "rtl" : "ltr"; + document.title = t("Settings — Coucou"); +} + +let rendering: Promise | null = null; +let renderAgain = false; + +/** Redraws every section from fresh state, keeping the scroll position. */ +async function rerender() { + if (rendering) { + renderAgain = true; + return; + } + const scroll = document.scrollingElement?.scrollTop ?? 0; + rendering = render(); + try { + await rendering; + } finally { + rendering = null; + } + if (document.scrollingElement) document.scrollingElement.scrollTop = scroll; + if (renderAgain) { + renderAgain = false; + await rerender(); + } +} + // ── Boot ────────────────────────────────────────────────────────────────────── async function main() { @@ -425,11 +1225,35 @@ async function main() { settings = { ...settings, ...boot.settings }; version = boot.version; } + setLanguage(resolveLanguage(settings.language, systemLanguages())); + applyDirection(); + onLanguageChange(() => { + applyDirection(); + void rerender(); + }); + await render(); + + void onEvent("shortcuts-status", (fresh) => shortcutsListener?.report(fresh)); + void onEvent("settings-changed", (s) => { + const before = `${settings.chatProvider}|${settings.ollamaUrl}|${settings.lmstudioUrl}|${settings.customUrl}`; + settings = { ...settings, ...s }; + shortcutsListener?.settingsChanged(); + for (const redraw of declaredViews) redraw(); + const after = `${settings.chatProvider}|${settings.ollamaUrl}|${settings.lmstudioUrl}|${settings.customUrl}`; + if (before !== after) localRedraw?.(); + applyLanguage(); + }); +} + +/** Reads what the sections show and draws them all. */ +async function render() { const status = (await Bridge.hooksStatus()) ?? { - installed: false, settingsPath: "", hookPath: "", hookReady: false, + installed: false, planRelayInstalled: false, settingsPath: "", hookPath: "", hookReady: false, }; + const agents = await Bridge.agentHooksList(); const hasKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false; + const shortcutReport = await Bridge.shortcutsStatus(); const keys = [ "stripe-api-key", "github-token", "vercel-token", @@ -438,22 +1262,49 @@ async function main() { const present: Record = {}; for (const k of keys) present[k] = (await Bridge.secretPresent(k)) ?? false; + // A main pill this build can run, and no pill declared twice. + settings = { ...settings, ...sanitizeDeclared(settings) }; + const connected: Record = { ...((await Bridge.agentHooksStatus()) ?? {}) }; + for (const def of availablePills()) { + if (def.connect.kind === "key") { + connected[def.id] = present[def.connect.key] ?? (await Bridge.secretPresent(def.connect.key)) ?? false; + } + } + /** A chat provider's key was saved or removed: its pill's row says so at once. */ + const keyChanged = (key: string, on: boolean) => { + for (const def of availablePills()) { + if (def.connect.kind === "key" && def.connect.key === key) connected[def.id] = on; + } + for (const redraw of declaredViews) redraw(); + }; + const chatKeys: Record = {}; + for (const def of CLOUD) { + const key = providerDef(def.id).key!; + chatKeys[key] = (await Bridge.secretPresent(key)) ?? false; + } + const customKey = (await Bridge.secretPresent(CUSTOM_SERVER_KEY)) ?? false; + + declaredViews.length = 0; + localRedraw = null; + shortcutsListener = null; clear(root); root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), + agentsSection(agents), + planSection(status), apiSection(hasKey), + chatProvidersSection(chatKeys, keyChanged), + localSection(customKey), + activePillsSection(connected), integrationsSection(present), generalSection(), + shortcutsSection(shortcutReport), h("div", { class: "hint", - text: "No telemetry. Network requests only go to the services you configure yourself.", + text: t("No telemetry. Network requests only go to the services you configure yourself."), }), ); - - void onEvent("settings-changed", (s) => { - settings = { ...settings, ...s }; - }); } void main(); diff --git a/windows/src/settings/parts.ts b/windows/src/settings/parts.ts new file mode 100644 index 000000000..7704ee3e4 --- /dev/null +++ b/windows/src/settings/parts.ts @@ -0,0 +1,16 @@ +// Small pieces every settings section draws with. + +import { h } from "../views/dom"; + +export function statusDot(ok: boolean): HTMLElement { + return h("i", { class: "dot", style: `background:${ok ? "#22c55e" : "#f4505e"}` }); +} + +export function renderDiff(text: string): HTMLElement { + const box = h("div", { class: "diff" }); + for (const line of text.split("\n")) { + const cls = line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : "ctx"; + box.append(h("div", { class: cls, text: line })); + } + return box; +} diff --git a/windows/src/settings/settings.css b/windows/src/settings/settings.css index 605b82025..155113b74 100644 --- a/windows/src/settings/settings.css +++ b/windows/src/settings/settings.css @@ -5,7 +5,10 @@ the