Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4affb949 | ||
|
|
b219ad5fda | ||
|
|
c583e64bb7 | ||
|
|
9df63b008d | ||
|
|
3daecc092c | ||
|
|
4d42811ecf | ||
|
|
1bebcf8033 | ||
|
|
45c555a4bd | ||
|
|
5986a83e80 | ||
|
|
732de4acf0 | ||
|
|
7400c0946e | ||
|
|
14ee2b2c11 | ||
|
|
c3e1b8cfd9 | ||
|
|
67a67df35a | ||
|
|
0f0578b268 | ||
|
|
662208949f | ||
|
|
e41821342b | ||
|
|
d3458a4fc3 | ||
|
|
32c91bbb25 | ||
|
|
aee13507f9 | ||
|
|
61b67f6301 | ||
|
|
b86619bcd0 | ||
|
|
31b5b45581 | ||
|
|
33cdb16b9e | ||
|
|
53fd7a4473 | ||
|
|
10d56d31e9 | ||
|
|
3633c829ae | ||
|
|
6cda84432e | ||
|
|
b914eaa6fa | ||
|
|
988b67aa30 | ||
|
|
0ed5b82389 | ||
|
|
b417fe5727 | ||
|
|
fabad7aa7a | ||
|
|
3c54da952a | ||
|
|
2ef2646b31 | ||
|
|
82ad7e29a6 | ||
|
|
2290a3c8af | ||
|
|
d216cebff5 | ||
|
|
05bd345828 |
No files matched your search
@@ -30,6 +30,7 @@ vendor/
|
||||
|
||||
# Vendor build artifacts
|
||||
vendor/a2ui/renderers/lit/dist/
|
||||
.bundle.hash
|
||||
|
||||
# fastlane (iOS)
|
||||
apps/ios/fastlane/README.md
|
||||
|
||||
+12
-2
@@ -1,6 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 2.0.0-beta5 — Unreleased
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
|
||||
|
||||
### Docs
|
||||
- Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky.
|
||||
|
||||
## 2.0.0-beta5 — 2026-01-03
|
||||
|
||||
### Fixed
|
||||
- Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).
|
||||
@@ -49,7 +57,8 @@
|
||||
- CLI: add `configure`, `doctor`, and `update` wizards for ongoing setup, health checks, and modernization.
|
||||
- CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.
|
||||
- CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.
|
||||
- CLI: add `clawdis tui` gateway-connected terminal UI (local or remote).
|
||||
- CLI: enhance `clawdis tui` with model/session pickers, tool cards, and slash commands (local or remote).
|
||||
- Gateway: allow `sessions.patch` to set per-session model overrides (used by the TUI `/model` flow).
|
||||
- Skills: allow `bun` as a node manager for skill installs.
|
||||
- Skills: add `things-mac` (Things 3 CLI) for read/search plus add/update via URL scheme.
|
||||
- Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).
|
||||
@@ -62,6 +71,7 @@
|
||||
- Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).
|
||||
- macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.
|
||||
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
|
||||
- Chat UI: show rich session display names in Web Chat + SwiftUI + Android.
|
||||
- Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.
|
||||
- Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).
|
||||
- Providers: make outbound text chunk limits configurable via `*.textChunkLimit` (defaults remain 4000/Discord 2000).
|
||||
|
||||
+198
-17
@@ -3,29 +3,210 @@
|
||||
<channel>
|
||||
<title>Clawdis</title>
|
||||
<item>
|
||||
<title>2.0.0-beta3</title>
|
||||
<pubDate>Sat, 27 Dec 2025 19:02:02 +0100</pubDate>
|
||||
<title>2.0.0-beta5</title>
|
||||
<pubDate>Sat, 03 Jan 2026 07:15:16 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<sparkle:version>2.0.0-beta3</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta3</sparkle:shortVersionString>
|
||||
<sparkle:version>2765</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta3/Clawdis-2.0.0-beta3.zip" length="70407960" type="application/octet-stream" sparkle:edSignature="A8ySMmbLRrpIkqkrmc9QrC+6om8Iyqray/6x/YNiJxDoJeXjp2T5t8XT0CKJeNBUlDkzIj/fwiK53v0qQ59cDQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2.0.0-beta4</title>
|
||||
<pubDate>Sat, 27 Dec 2025 19:43:22 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||
<sparkle:version>2.0.0-beta4</sparkle:version>
|
||||
<sparkle:shortVersionString>2.0.0-beta4</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdis 2.0.0-beta4</h2>
|
||||
<description><![CDATA[<h2>Clawdis 2.0.0-beta5</h2>
|
||||
<h3>Fixed</h3>
|
||||
<ul>
|
||||
<li>Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).</li>
|
||||
<li>Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).</li>
|
||||
<li>Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).</li>
|
||||
<li>Tests: cover read tool image metadata + text output.</li>
|
||||
<li>Tests: add queue mode coverage (collect/followup + directive parsing).</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li>Skills config schema moved under <code>skills.*</code>:</li>
|
||||
</ul>
|
||||
- <code>skillsLoad.extraDirs</code> → <code>skills.load.extraDirs</code>
|
||||
- <code>skillsInstall.*</code> → <code>skills.install.*</code>
|
||||
- per-skill config map moved to <code>skills.entries</code> (e.g. <code>skills.peekaboo.enabled</code> → <code>skills.entries.peekaboo.enabled</code>)
|
||||
- new optional bundled allowlist: <code>skills.allowBundled</code> (only affects bundled skills)
|
||||
<ul>
|
||||
<li>Sessions: group keys now use <code>surface:group:<id></code> / <code>surface:channel:<id></code>; legacy <code>group:*</code> keys migrate on next message; <code>groupdm</code> keys are no longer recognized.</li>
|
||||
<li>Discord: remove legacy <code>discord.allowFrom</code>, <code>discord.guildAllowFrom</code>, and <code>discord.requireMention</code>; use <code>discord.dm</code> + <code>discord.guilds</code>.</li>
|
||||
<li>Providers: Discord/Telegram no longer auto-start from env tokens alone; add <code>discord: { enabled: true }</code> / <code>telegram: { enabled: true }</code> to your config when using <code>DISCORD_BOT_TOKEN</code> / <code>TELEGRAM_BOT_TOKEN</code>.</li>
|
||||
<li>Config: remove <code>routing.allowFrom</code>; use <code>whatsapp.allowFrom</code> instead (run <code>clawdis doctor</code> to migrate).</li>
|
||||
<li>Config: remove <code>routing.groupChat.requireMention</code> + <code>telegram.requireMention</code>; use <code>whatsapp.groups</code>, <code>imessage.groups</code>, and <code>telegram.groups</code> defaults instead (run <code>clawdis doctor</code> to migrate).</li>
|
||||
</ul>
|
||||
<h3>Features</h3>
|
||||
<ul>
|
||||
<li>Discord: expand <code>discord</code> tool actions (reactions, stickers, polls, threads, search, moderation gates) (#115) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord/Telegram: add reply tags (<code>[[reply_to_current]]</code>, <code>[[reply_to:<id>]]</code>) with per-provider <code>replyToMode</code> (off|first|all) for native threaded replies.</li>
|
||||
<li>Talk mode: continuous speech conversations (macOS/iOS/Android) with ElevenLabs TTS, reply directives, and optional interrupt-on-speech.</li>
|
||||
<li>Auto-reply: expand queue modes (steer/followup/collect/steer-backlog) with debounce/cap/drop options and followup backlog handling.</li>
|
||||
<li>UI: add optional <code>ui.seamColor</code> accent to tint the Talk Mode side bubble (macOS/iOS/Android).</li>
|
||||
<li>Nix mode: opt-in declarative config + read-only settings UI when <code>CLAWDIS_NIX_MODE=1</code> (thanks @joshp123 for the persistence — earned my trust; I'll merge these going forward).</li>
|
||||
<li>CLI: add Google Antigravity OAuth auth option for Claude Opus 4.5/Gemini 3 (#88) — thanks @mukhtharcm.</li>
|
||||
<li>Agent runtime: accept legacy <code>Z_AI_API_KEY</code> for Z.AI provider auth (maps to <code>ZAI_API_KEY</code>).</li>
|
||||
<li>Groups: add per-group mention gating defaults/overrides for Telegram/WhatsApp/iMessage via <code>*.groups</code> with <code>"*"</code> defaults; Discord now supports <code>discord.guilds."*"</code> as a default.</li>
|
||||
<li>Discord: add user-installed slash command handling with per-user sessions and auto-registration (#94) — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: add DM enable/allowlist plus guild channel/user/guild allowlists with id/name matching.</li>
|
||||
<li>Signal: add <code>signal-cli</code> JSON-RPC support for send/receive via the Signal provider.</li>
|
||||
<li>iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support.</li>
|
||||
<li>Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI.</li>
|
||||
<li>UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: allow agent-triggered reactions via <code>clawdis_discord</code> when enabled, and surface message ids in context.</li>
|
||||
<li>Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off).</li>
|
||||
<li>Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists).</li>
|
||||
<li>Skills: add Trello skill for board/list/card management (thanks @clawd).</li>
|
||||
<li>Docker: add containerized gateway/CLI setup via Dockerfile, compose, and setup script (thanks @dan-dr).</li>
|
||||
<li>Tests: add a Z.AI live test gate for smoke validation when keys are present.</li>
|
||||
<li>macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.</li>
|
||||
<li>CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.</li>
|
||||
<li>CLI: add ASCII banner header to wizard entry points.</li>
|
||||
<li>CLI: add <code>configure</code>, <code>doctor</code>, and <code>update</code> wizards for ongoing setup, health checks, and modernization.</li>
|
||||
<li>CLI: add Signal CLI auto-install from GitHub releases in the wizard and persist wizard run metadata in config.</li>
|
||||
<li>CLI: add remote gateway client config (gateway.remote.*) with Bonjour-assisted discovery.</li>
|
||||
<li>CLI: enhance <code>clawdis tui</code> with model/session pickers, tool cards, and slash commands (local or remote).</li>
|
||||
<li>Gateway: allow <code>sessions.patch</code> to set per-session model overrides (used by the TUI <code>/model</code> flow).</li>
|
||||
<li>Skills: allow <code>bun</code> as a node manager for skill installs.</li>
|
||||
<li>Skills: add <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
|
||||
<li>Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).</li>
|
||||
<li>Tests: add a Docker-based onboarding E2E harness.</li>
|
||||
<li>Tests: harden wizard E2E flows for reset, providers, skills, and remote non-interactive runs.</li>
|
||||
<li>Browser tools: add remote CDP URL support, Linux launcher options (<code>executablePath</code>, <code>noSandbox</code>), and surface <code>cdpUrl</code> in status.</li>
|
||||
<li>Skills: add tmux-first coding-agent skill + <code>requires.anyBins</code> gate for multi-CLI setup (thanks @sreekaransrinath).</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Package contents: include Discord/hooks build outputs in the npm tarball to avoid missing module errors.</li>
|
||||
<li>Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).</li>
|
||||
<li>macOS onboarding: add scrollable page gutter for overflowing content (#105) — thanks @thewilloftheshadow.</li>
|
||||
<li>Chat UI: keep the chat scrolled to the latest message after switching sessions.</li>
|
||||
<li>Chat UI: show rich session display names in Web Chat + SwiftUI + Android.</li>
|
||||
<li>Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.</li>
|
||||
<li>Discord: avoid duplicate sends when block streaming is enabled (race with typing hook).</li>
|
||||
<li>Providers: make outbound text chunk limits configurable via <code>*.textChunkLimit</code> (defaults remain 4000/Discord 2000).</li>
|
||||
<li>CLI onboarding: persist gateway token in config so local CLI auth works; recommend auth Off unless you need multi-machine access.</li>
|
||||
<li>Control UI: accept a <code>?token=</code> URL param to auto-fill Gateway auth; onboarding now opens the dashboard with token auth when configured.</li>
|
||||
<li>Agent prompt: remove hardcoded user name in system prompt example.</li>
|
||||
<li>Chat UI: add extra top padding before the first message bubble in Web Chat (macOS/iOS/Android).</li>
|
||||
<li>Control UI: refine Web Chat session selector styling (chevron spacing + background).</li>
|
||||
<li>WebChat: stream live updates for sessions even when runs start outside the chat UI.</li>
|
||||
<li>Gateway CLI: read <code>CLAWDIS_GATEWAY_PASSWORD</code> from environment in <code>callGateway()</code> — allows <code>doctor</code>/<code>health</code> commands to auth without explicit <code>--password</code> flag.</li>
|
||||
<li>Gateway: add password auth support for remote gateway connections (thanks @jeffersonwarrior).</li>
|
||||
<li>Auto-reply: strip stray leading/trailing <code>HEARTBEAT_OK</code> from normal replies; drop short (≤ 30 chars) heartbeat acks.</li>
|
||||
<li>WhatsApp auto-reply: default to self-only when no config is present.</li>
|
||||
<li>Logging: trim provider prefix duplication in Discord/Signal/Telegram runtime log lines.</li>
|
||||
<li>Logging/Signal: treat signal-cli "Failed …" lines as errors in gateway logs.</li>
|
||||
<li>Discord: include recent guild context when replying to mentions and add <code>discord.historyLimit</code> to tune how many messages are captured.</li>
|
||||
<li>Discord: include author tag + id in group context <code>[from:]</code> lines for ping-ready replies (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: include replied-to message context when a Discord message references another message (thanks @thewilloftheshadow).</li>
|
||||
<li>Discord: preserve newlines when stripping reply tags from agent output.</li>
|
||||
<li>Gateway: fix TypeScript build by aligning hook mapping <code>channel</code> types and removing a dead Group DM branch in Discord monitor.</li>
|
||||
<li>Skills: switch imsg installer to brew tap formula.</li>
|
||||
<li>Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.</li>
|
||||
<li>Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.</li>
|
||||
<li>Onboarding: auto-verify Claude OAuth tokens, show “verified” when detected working, and avoid re-auth prompts unless verification fails.</li>
|
||||
<li>CLI onboarding: include exit code + a useful one-line summary when skill dependency installs fail.</li>
|
||||
<li>CLI onboarding: explain Tailscale exposure options (Off/Serve/Funnel) and colorize provider status (linked/configured/needs setup).</li>
|
||||
<li>CLI onboarding: add provider primers (WhatsApp/Telegram/Discord/Signal) incl. Discord bot token setup steps.</li>
|
||||
<li>CLI onboarding: allow skipping the “install missing skill dependencies” selection without canceling the wizard.</li>
|
||||
<li>CLI onboarding: always prompt for WhatsApp <code>whatsapp.allowFrom</code> and print (optionally open) the Control UI URL when done.</li>
|
||||
<li>CLI onboarding: detect gateway reachability and annotate Local/Remote choices (helps pick the right mode).</li>
|
||||
<li>macOS settings: colorize provider status subtitles to distinguish healthy vs degraded states.</li>
|
||||
<li>macOS: keep config writes on the main actor to satisfy Swift concurrency rules.</li>
|
||||
<li>macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient <code>cancelled</code> device refresh errors, and auto-recover the control channel on disconnect.</li>
|
||||
<li>macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus.</li>
|
||||
<li>macOS menu: tighten session row padding and time out session preview loading with cached fallback.</li>
|
||||
<li>macOS: log health refresh failures and recovery to make gateway issues easier to diagnose.</li>
|
||||
<li>macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b</li>
|
||||
<li>macOS codesign: include camera entitlement so permission prompts work in the menu bar app.</li>
|
||||
<li>Agent tools: bash tool supports real TTY via <code>stdinMode: "pty"</code> with node-pty, warning + fallback on load/start failure.</li>
|
||||
<li>Agent tools: map <code>camera.snap</code> JPEG payloads to <code>image/jpeg</code> to avoid MIME mismatch errors.</li>
|
||||
<li>Tests: cover <code>camera.snap</code> MIME mapping to prevent image/png vs image/jpeg mismatches.</li>
|
||||
<li>macOS camera: wait for exposure/white balance to settle before capturing a snap to avoid dark images.</li>
|
||||
<li>Camera snap: add <code>delayMs</code> parameter (default 2000ms on macOS) to improve exposure reliability.</li>
|
||||
<li>Camera: add <code>camera.list</code> and optional <code>deviceId</code> selection for snaps/clips.</li>
|
||||
<li>Tests: cover camera device selection params in CLI + agent tools.</li>
|
||||
<li>macOS packaging: move rpath config into swift build for reliability (#69) — thanks @petter-b</li>
|
||||
<li>macOS: prioritize main bundle for device resources to prevent crash (#73) — thanks @petter-b</li>
|
||||
<li>macOS remote: route settings through gateway config and avoid local config reads in remote mode.</li>
|
||||
<li>Telegram: align token resolution for cron/agent/CLI sends (env/config/tokenFile) to prevent isolated delivery failures (#76).</li>
|
||||
<li>Telegram: honor per-group mention gating defaults/overrides via <code>telegram.groups</code> and <code>"*"</code> defaults (thanks @joshp123).</li>
|
||||
<li>Chat UI: clear composer input immediately and allow clear while editing to prevent duplicate sends (#72) — thanks @hrdwdmrbl</li>
|
||||
<li>Restart: use systemd on Linux (and report actual restart method) instead of always launchctl.</li>
|
||||
<li>Gateway relay: detect Bun binaries via execPath to resolve packaged assets on macOS.</li>
|
||||
<li>Cron: prevent <code>every</code> schedules without an anchor from firing in a tight loop (thanks @jamesgroat).</li>
|
||||
<li>Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock</li>
|
||||
<li>Docs/agent tools: clarify that browser <code>wait</code> should be avoided by default and used only in exceptional cases.</li>
|
||||
<li>Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.</li>
|
||||
<li>Browser tools: <code>upload</code> supports auto-click refs, direct <code>inputRef</code>/<code>element</code> file inputs, and emits input/change after <code>setFiles</code> so JS-heavy sites pick up attachments.</li>
|
||||
<li>Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.</li>
|
||||
<li>Browser CLI: add <code>clawdis browser reset-profile</code> to move the clawd profile to Trash when it gets wedged.</li>
|
||||
<li>Signal: fix daemon startup race (wait for <code>/api/v1/check</code>) and normalize JSON-RPC <code>version</code> probe parsing.</li>
|
||||
<li>Docs/Signal: clarify bot-number vs personal-account setup (self-chat loop protection) and add a quickstart config snippet.</li>
|
||||
<li>Docs: refresh the CLI wizard guide and highlight onboarding in the README.</li>
|
||||
<li>CLI: tighten onboarding prompt typing to keep bun builds green.</li>
|
||||
<li>macOS: Voice Wake now fully tears down the Speech pipeline when disabled (cancel pending restarts, drop stale callbacks) to avoid high CPU in the background.</li>
|
||||
<li>macOS menu: add a Talk Mode action alongside the Open Dashboard/Chat/Canvas entries.</li>
|
||||
<li>macOS Debug: hide “Restart Gateway” when the app won’t start a local gateway (remote mode / attach-only).</li>
|
||||
<li>macOS Debug: add an icon for the App Logging submenu.</li>
|
||||
<li>macOS Talk Mode: orb overlay refresh, ElevenLabs request logging, API key status in settings, and auto-select first voice when none is configured.</li>
|
||||
<li>macOS Talk Mode: add hard timeout around ElevenLabs TTS synthesis to avoid getting stuck “speaking” forever on hung requests.</li>
|
||||
<li>macOS Talk Mode: avoid stuck playback when the audio player never starts (fail-fast + watchdog).</li>
|
||||
<li>macOS Talk Mode: fix audio stop ordering so disabling Talk Mode always stops in-flight playback.</li>
|
||||
<li>macOS Talk Mode: throttle audio-level updates (avoid per-buffer task creation) to reduce CPU/task churn.</li>
|
||||
<li>macOS Talk Mode: increase overlay window size so wave rings don’t clip; close button is hover-only and closer to the orb.</li>
|
||||
<li>WebChat: preserve chat run ordering per session so concurrent runs don’t strand the typing indicator.</li>
|
||||
<li>Talk Mode: fall back to system TTS when ElevenLabs is unavailable, returns non-audio, or playback fails (macOS/iOS/Android).</li>
|
||||
<li>Talk Mode: stream PCM on macOS/iOS for lower latency (incremental playback); Android continues MP3 streaming.</li>
|
||||
<li>Talk Mode: validate ElevenLabs v3 stability and latency tier directives before sending requests.</li>
|
||||
<li>iOS/Android Talk Mode: auto-select the first ElevenLabs voice when none is configured.</li>
|
||||
<li>ElevenLabs: add retry/backoff for 429/5xx and include content-type in errors for debugging.</li>
|
||||
<li>Talk Mode: align to the gateway’s main session key and fall back to history polling when chat events drop (prevents stuck “thinking” / missing messages).</li>
|
||||
<li>Talk Mode: treat history timestamps as seconds or milliseconds to avoid stale assistant picks (macOS/iOS/Android).</li>
|
||||
<li>Chat UI: clear streaming/tool bubbles when external runs finish, preventing duplicate assistant bubbles.</li>
|
||||
<li>Chat UI: user bubbles use <code>ui.seamColor</code> (fallback to a calmer default blue).</li>
|
||||
<li>Android Chat UI: use <code>onPrimary</code> for user bubble text to preserve contrast (thanks @Syhids).</li>
|
||||
<li>Control UI: sync sidebar navigation with the URL for deep-linking, and auto-scroll chat to the latest message.</li>
|
||||
<li>Control UI: disable Web Chat + Talk when no iOS/Android node is connected; refreshed Web Chat styling and keyboard send.</li>
|
||||
<li>Control UI: keep chat pinned to the latest message while typing/sending and restore drafts on send failures.</li>
|
||||
<li>Control UI: soften chat bubble text opacity for calmer readability.</li>
|
||||
<li>macOS Web Chat: improve empty/error states, focus message field on open, keep pill/send inside the input field, and make the composer pill edge-to-edge with square top corners.</li>
|
||||
<li>macOS: bundle Control UI assets into the app relay so the packaged app can serve them (thanks @mbelinky).</li>
|
||||
<li>Talk Mode: wait for chat history to surface the assistant reply before starting TTS (macOS/iOS/Android).</li>
|
||||
<li>iOS Talk Mode: fix chat completion wait to time out even if no events arrive (prevents “Thinking…” hangs).</li>
|
||||
<li>iOS Talk Mode: keep recognition running during playback to support interrupt-on-speech.</li>
|
||||
<li>iOS Talk Mode: preserve directive voice/model overrides across config reloads and add ElevenLabs request timeouts.</li>
|
||||
<li>iOS/Android Talk Mode: explicitly <code>chat.subscribe</code> when Talk Mode is active, so completion events arrive even if the Chat UI isn’t open.</li>
|
||||
<li>Chat UI: refresh history when another client finishes a run in the same session, so Talk Mode + Voice Wake transcripts appear consistently.</li>
|
||||
<li>Gateway: <code>voice.transcript</code> now also maps agent bus output to <code>chat</code> events, ensuring chat UIs refresh for voice-triggered runs.</li>
|
||||
<li>Gateway: auto-migrate legacy config on startup (non-Nix); Nix mode hard-fails with a clear error when legacy keys are present.</li>
|
||||
<li>iOS/Android: show a centered Talk Mode orb overlay while Talk Mode is enabled.</li>
|
||||
<li>Gateway config: inject <code>talk.apiKey</code> from <code>ELEVENLABS_API_KEY</code>/shell profile so nodes can fetch it on demand.</li>
|
||||
<li>Canvas A2UI: tag requests with <code>platform=android|ios|macos</code> and boost Android canvas background contrast.</li>
|
||||
<li>iOS/Android nodes: enable scrolling for loaded web pages in the Canvas WebView (default scaffold stays touch-first).</li>
|
||||
<li>macOS menu: device list now uses <code>node.list</code> (devices only; no agent/tool presence entries).</li>
|
||||
<li>macOS menu: device list now shows connected nodes only.</li>
|
||||
<li>macOS menu: device rows now pack platform/version on the first line, and command lists wrap in submenus.</li>
|
||||
<li>macOS menu: split device platform/version across first and second rows for better fit.</li>
|
||||
<li>macOS Canvas: show remote control status in the debug overlay and log A2UI auto-nav decisions.</li>
|
||||
<li>Canvas A2UI: polish the debug status HUD styling.</li>
|
||||
<li>iOS node: fix ReplayKit screen recording crash caused by queue isolation assertions during capture.</li>
|
||||
<li>iOS Talk Mode: avoid audio tap queue assertions when starting recognition.</li>
|
||||
<li>macOS: use $HOME/Library/pnpm for SSH PATH exports (thanks @mbelinky).</li>
|
||||
<li>macOS remote: harden SSH tunnel recovery/logging, honor <code>gateway.remote.url</code> port when forwarding, clarify gateway disconnect status, and add Debug menu tunnel reset.</li>
|
||||
<li>iOS/Android nodes: bridge auto-connect refreshes stale tokens and settings now show richer bridge/device details.</li>
|
||||
<li>macOS: bundle device model resources to prevent Instances crashes (thanks @mbelinky).</li>
|
||||
<li>iOS/Android nodes: status pill now surfaces camera activity instead of overlay toasts.</li>
|
||||
<li>iOS/Android/macOS nodes: camera snaps recompress to keep base64 payloads under 5 MB.</li>
|
||||
<li>iOS/Android nodes: status pill now surfaces pairing, screen recording, voice wake, and foreground-required states.</li>
|
||||
<li>iOS/Android nodes: avoid duplicating “Gateway reconnecting…” when the bridge is already connecting.</li>
|
||||
<li>iOS/Android nodes: Talk Mode now lives on a side bubble (with an iOS toggle to hide it), and Android settings no longer show the Talk Mode switch.</li>
|
||||
<li>macOS menu: top status line now shows pending node pairing approvals (incl. repairs).</li>
|
||||
<li>CLI: avoid spurious gateway close errors after successful request/response cycles.</li>
|
||||
<li>Agent runtime: clamp tool-result images to the 5MB Anthropic limit to avoid hard request rejections.</li>
|
||||
<li>Agent runtime: write v2 session headers so Pi session branching stays in the Clawdis sessions dir.</li>
|
||||
<li>Tests: add Swift Testing coverage for camera errors and Kotest coverage for Android bridge endpoints.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta4/Clawdis-2.0.0-beta4.zip" length="70407894" type="application/octet-stream" sparkle:edSignature="HmuiC7TnUn80ZApnKfb6w+JGSrjc3uUOndMrtbTp42bkBSVifbttNVazqvJueGBo4MgoJV8CP+zQNzVmtVihAQ=="/>
|
||||
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta5/Clawdis-2.0.0-beta5.zip" length="145432870" type="application/octet-stream" sparkle:edSignature="qKPcmSx2pAaIYz9NqFp0TY63KrcDlpctUHnNpRs6Q60qQqBWtQycLIhhvhxmGnHupaiEXJfspb/Ad9RgODIzAw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
@@ -148,7 +148,7 @@ fun ChatComposer(
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
ConnectionPill(sessionKey = sessionKey, healthOk = healthOk)
|
||||
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
@@ -186,7 +186,7 @@ fun ChatComposer(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionPill(sessionKey: String, healthOk: Boolean) {
|
||||
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
@@ -201,7 +201,7 @@ private fun ConnectionPill(sessionKey: String, healthOk: Boolean) {
|
||||
shape = androidx.compose.foundation.shape.CircleShape,
|
||||
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
|
||||
) {}
|
||||
Text(sessionKey, style = MaterialTheme.typography.labelSmall)
|
||||
Text(sessionLabel, style = MaterialTheme.typography.labelSmall)
|
||||
Text(
|
||||
if (healthOk) "Connected" else "Connecting…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
|
||||
@@ -289,6 +289,12 @@ struct ConnectionsSettings: View {
|
||||
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("DMs enabled")
|
||||
Toggle("", isOn: self.$store.discordDmEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Group DMs")
|
||||
Toggle("", isOn: self.$store.discordGroupEnabled)
|
||||
@@ -310,6 +316,20 @@ struct ConnectionsSettings: View {
|
||||
TextField("20", text: self.$store.discordHistoryLimit)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Text chunk limit")
|
||||
TextField("2000", text: self.$store.discordTextChunkLimit)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Reply to mode")
|
||||
Picker("", selection: self.$store.discordReplyToMode) {
|
||||
Text("off").tag("off")
|
||||
Text("first").tag("first")
|
||||
Text("all").tag("all")
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Slash command")
|
||||
Toggle("", isOn: self.$store.discordSlashEnabled)
|
||||
@@ -336,6 +356,79 @@ struct ConnectionsSettings: View {
|
||||
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
Text("Guilds")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach($store.discordGuilds) { $guild in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
TextField("guild id or slug", text: $guild.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Remove") {
|
||||
self.store.discordGuilds.removeAll { $0.id == guild.id }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Slug")
|
||||
TextField("optional slug", text: $guild.slug)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Require mention")
|
||||
Toggle("", isOn: $guild.requireMention)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Users allowlist")
|
||||
TextField("123456789, username#1234", text: $guild.users)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Channels")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach($guild.channels) { $channel in
|
||||
HStack(spacing: 10) {
|
||||
TextField("channel id or slug", text: $channel.key)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Allow", isOn: $channel.allow)
|
||||
.toggleStyle(.checkbox)
|
||||
Toggle("Require mention", isOn: $channel.requireMention)
|
||||
.toggleStyle(.checkbox)
|
||||
Button("Remove") {
|
||||
guild.channels.removeAll { $0.id == channel.id }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
Button("Add channel") {
|
||||
guild.channels.append(DiscordGuildChannelForm())
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.secondary.opacity(0.08))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
Button("Add guild") {
|
||||
self.store.discordGuilds.append(DiscordGuildForm())
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Divider().padding(.vertical, 2)
|
||||
|
||||
Text("Tool actions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -144,6 +144,42 @@ struct ConfigSnapshot: Codable {
|
||||
let issues: [Issue]?
|
||||
}
|
||||
|
||||
struct DiscordGuildChannelForm: Identifiable {
|
||||
let id = UUID()
|
||||
var key: String
|
||||
var allow: Bool
|
||||
var requireMention: Bool
|
||||
|
||||
init(key: String = "", allow: Bool = true, requireMention: Bool = false) {
|
||||
self.key = key
|
||||
self.allow = allow
|
||||
self.requireMention = requireMention
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscordGuildForm: Identifiable {
|
||||
let id = UUID()
|
||||
var key: String
|
||||
var slug: String
|
||||
var requireMention: Bool
|
||||
var users: String
|
||||
var channels: [DiscordGuildChannelForm]
|
||||
|
||||
init(
|
||||
key: String = "",
|
||||
slug: String = "",
|
||||
requireMention: Bool = false,
|
||||
users: String = "",
|
||||
channels: [DiscordGuildChannelForm] = []
|
||||
) {
|
||||
self.key = key
|
||||
self.slug = slug
|
||||
self.requireMention = requireMention
|
||||
self.users = users
|
||||
self.channels = channels
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ConnectionsStore {
|
||||
@@ -169,11 +205,15 @@ final class ConnectionsStore {
|
||||
var telegramBusy = false
|
||||
var discordEnabled = true
|
||||
var discordToken: String = ""
|
||||
var discordDmEnabled = true
|
||||
var discordAllowFrom: String = ""
|
||||
var discordGroupEnabled = false
|
||||
var discordGroupChannels: String = ""
|
||||
var discordMediaMaxMb: String = ""
|
||||
var discordHistoryLimit: String = ""
|
||||
var discordTextChunkLimit: String = ""
|
||||
var discordReplyToMode: String = "off"
|
||||
var discordGuilds: [DiscordGuildForm] = []
|
||||
var discordActionReactions = true
|
||||
var discordActionStickers = true
|
||||
var discordActionPolls = true
|
||||
@@ -401,6 +441,7 @@ final class ConnectionsStore {
|
||||
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
||||
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||
let discordDm = discord?["dm"]?.dictionaryValue
|
||||
self.discordDmEnabled = discordDm?["enabled"]?.boolValue ?? true
|
||||
if let allow = discordDm?["allowFrom"]?.arrayValue {
|
||||
let strings = allow.compactMap { entry -> String? in
|
||||
if let str = entry.stringValue { return str }
|
||||
@@ -434,6 +475,56 @@ final class ConnectionsStore {
|
||||
} else {
|
||||
self.discordHistoryLimit = ""
|
||||
}
|
||||
if let limit = discord?["textChunkLimit"]?.doubleValue ?? discord?["textChunkLimit"]?.intValue.map(Double.init) {
|
||||
self.discordTextChunkLimit = String(Int(limit))
|
||||
} else {
|
||||
self.discordTextChunkLimit = ""
|
||||
}
|
||||
if let mode = discord?["replyToMode"]?.stringValue, ["off", "first", "all"].contains(mode) {
|
||||
self.discordReplyToMode = mode
|
||||
} else {
|
||||
self.discordReplyToMode = "off"
|
||||
}
|
||||
if let guilds = discord?["guilds"]?.dictionaryValue {
|
||||
self.discordGuilds = guilds
|
||||
.map { key, value in
|
||||
let entry = value.dictionaryValue ?? [:]
|
||||
let slug = entry["slug"]?.stringValue ?? ""
|
||||
let requireMention = entry["requireMention"]?.boolValue ?? false
|
||||
let users = entry["users"]?.arrayValue?
|
||||
.compactMap { item -> String? in
|
||||
if let str = item.stringValue { return str }
|
||||
if let intVal = item.intValue { return String(intVal) }
|
||||
if let doubleVal = item.doubleValue { return String(Int(doubleVal)) }
|
||||
return nil
|
||||
}
|
||||
.joined(separator: ", ") ?? ""
|
||||
let channels: [DiscordGuildChannelForm]
|
||||
if let channelMap = entry["channels"]?.dictionaryValue {
|
||||
channels = channelMap.map { channelKey, channelValue in
|
||||
let channelEntry = channelValue.dictionaryValue ?? [:]
|
||||
let allow = channelEntry["allow"]?.boolValue ?? true
|
||||
let channelRequireMention =
|
||||
channelEntry["requireMention"]?.boolValue ?? false
|
||||
return DiscordGuildChannelForm(
|
||||
key: channelKey,
|
||||
allow: allow,
|
||||
requireMention: channelRequireMention)
|
||||
}
|
||||
} else {
|
||||
channels = []
|
||||
}
|
||||
return DiscordGuildForm(
|
||||
key: key,
|
||||
slug: slug,
|
||||
requireMention: requireMention,
|
||||
users: users,
|
||||
channels: channels)
|
||||
}
|
||||
.sorted { $0.key < $1.key }
|
||||
} else {
|
||||
self.discordGuilds = []
|
||||
}
|
||||
let discordActions = discord?["actions"]?.dictionaryValue
|
||||
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
|
||||
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
|
||||
@@ -625,6 +716,11 @@ final class ConnectionsStore {
|
||||
}
|
||||
|
||||
var dm: [String: Any] = (discord["dm"] as? [String: Any]) ?? [:]
|
||||
if self.discordDmEnabled {
|
||||
dm.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
dm["enabled"] = false
|
||||
}
|
||||
let allow = self.discordAllowFrom
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
@@ -673,6 +769,53 @@ final class ConnectionsStore {
|
||||
discord.removeValue(forKey: "historyLimit")
|
||||
}
|
||||
|
||||
let chunkLimit = self.discordTextChunkLimit.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if chunkLimit.isEmpty {
|
||||
discord.removeValue(forKey: "textChunkLimit")
|
||||
} else if let value = Int(chunkLimit), value > 0 {
|
||||
discord["textChunkLimit"] = value
|
||||
} else {
|
||||
discord.removeValue(forKey: "textChunkLimit")
|
||||
}
|
||||
|
||||
let replyToMode = self.discordReplyToMode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if replyToMode.isEmpty || replyToMode == "off" {
|
||||
discord.removeValue(forKey: "replyToMode")
|
||||
} else if ["first", "all"].contains(replyToMode) {
|
||||
discord["replyToMode"] = replyToMode
|
||||
} else {
|
||||
discord.removeValue(forKey: "replyToMode")
|
||||
}
|
||||
|
||||
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
|
||||
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
var payload: [String: Any] = [:]
|
||||
let slug = entry.slug.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !slug.isEmpty { payload["slug"] = slug }
|
||||
if entry.requireMention { payload["requireMention"] = true }
|
||||
let users = entry.users
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if !users.isEmpty { payload["users"] = users }
|
||||
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
|
||||
let channelKey = channel.key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !channelKey.isEmpty else { return }
|
||||
var channelPayload: [String: Any] = [:]
|
||||
if !channel.allow { channelPayload["allow"] = false }
|
||||
if channel.requireMention { channelPayload["requireMention"] = true }
|
||||
channelsResult[channelKey] = channelPayload
|
||||
}
|
||||
if !channels.isEmpty { payload["channels"] = channels }
|
||||
result[key] = payload
|
||||
}
|
||||
if guilds.isEmpty {
|
||||
discord.removeValue(forKey: "guilds")
|
||||
} else {
|
||||
discord["guilds"] = guilds
|
||||
}
|
||||
|
||||
var actions: [String: Any] = (discord["actions"] as? [String: Any]) ?? [:]
|
||||
func setAction(_ key: String, value: Bool, defaultValue: Bool) {
|
||||
if value == defaultValue {
|
||||
|
||||
@@ -613,23 +613,27 @@ public struct SessionsPatchParams: Codable {
|
||||
public let key: String
|
||||
public let thinkinglevel: AnyCodable?
|
||||
public let verboselevel: AnyCodable?
|
||||
public let model: AnyCodable?
|
||||
public let groupactivation: AnyCodable?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
thinkinglevel: AnyCodable?,
|
||||
verboselevel: AnyCodable?,
|
||||
model: AnyCodable?,
|
||||
groupactivation: AnyCodable?
|
||||
) {
|
||||
self.key = key
|
||||
self.thinkinglevel = thinkinglevel
|
||||
self.verboselevel = verboselevel
|
||||
self.model = model
|
||||
self.groupactivation = groupactivation
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case thinkinglevel = "thinkingLevel"
|
||||
case verboselevel = "verboseLevel"
|
||||
case model
|
||||
case groupactivation = "groupActivation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ struct ClawdisChatComposer: View {
|
||||
Circle()
|
||||
.fill(self.viewModel.healthOK ? .green : .orange)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(self.viewModel.sessionKey)
|
||||
Text(self.activeSessionLabel)
|
||||
.font(.caption2.weight(.semibold))
|
||||
Text(self.viewModel.healthOK ? "Connected" : "Connecting…")
|
||||
.font(.caption2)
|
||||
@@ -221,6 +221,12 @@ struct ClawdisChatComposer: View {
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private var activeSessionLabel: String {
|
||||
let match = self.viewModel.sessions.first { $0.key == self.viewModel.sessionKey }
|
||||
let trimmed = match?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? self.viewModel.sessionKey : trimmed
|
||||
}
|
||||
|
||||
private var editorOverlay: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
|
||||
@@ -37,6 +37,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
|
||||
- [ ] Generate the Sparkle appcast (HTML notes via `scripts/make_appcast.sh`) and update `appcast.xml`.
|
||||
- [ ] Keep the app zip (and optional dSYM zip) ready to attach to the GitHub release.
|
||||
- [ ] Follow `docs/mac/release.md` for the exact commands and required env vars.
|
||||
- `APP_BUILD` must be numeric + monotonic (no `-beta`) so Sparkle compares versions correctly.
|
||||
- If notarizing, use the `clawdis-notary` keychain profile created from App Store Connect API env vars (see `docs/mac/release.md`).
|
||||
|
||||
6) **Publish (npm)**
|
||||
|
||||
+109
-1
@@ -32,6 +32,33 @@ Some features are platform-specific:
|
||||
- **iMessage** — macOS only (uses `imsg` CLI)
|
||||
- **Clawdis.app** — macOS native app (optional, gateway works without it)
|
||||
|
||||
### What are the minimum system requirements?
|
||||
|
||||
**Basically nothing!** The gateway is very lightweight — all heavy compute happens on Anthropic's servers.
|
||||
|
||||
- **RAM:** 512MB-1GB is enough (community member runs on 1GB VPS!)
|
||||
- **CPU:** 1 core is fine for personal use
|
||||
- **Disk:** ~500MB for Clawdis + deps, plus space for logs/media
|
||||
|
||||
The gateway is just shuffling messages around. A Raspberry Pi 4 can run it. You can also use **Bun** instead of Node for even lower memory footprint:
|
||||
|
||||
```bash
|
||||
bun clawdis gateway
|
||||
```
|
||||
|
||||
### How do I install on Linux without Homebrew?
|
||||
|
||||
Build CLIs from source! Example for `gogcli`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/steipete/gogcli.git
|
||||
cd gogcli
|
||||
make
|
||||
sudo mv bin/gog /usr/local/bin/
|
||||
```
|
||||
|
||||
Most of Peter's tools are Go binaries — clone, build, move to PATH. No brew needed.
|
||||
|
||||
### I'm getting "unauthorized" errors on health check
|
||||
|
||||
You need a config file. Run the onboarding wizard:
|
||||
@@ -83,10 +110,20 @@ The macOS app onboarding is still being polished and can have quirks (e.g., What
|
||||
|
||||
They're **separate billing**! An API key does NOT use your subscription.
|
||||
|
||||
**For OAuth:** During onboarding, pick "Anthropic OAuth", log in to your Claude account, paste the code back.
|
||||
**For OAuth:** During onboarding, pick "Anthropic OAuth", log in to your Claude account, paste the code back. Or just run:
|
||||
|
||||
```bash
|
||||
pnpm clawdis login
|
||||
```
|
||||
|
||||
**If OAuth fails** (headless/container): Do OAuth on a normal machine, then copy `~/.clawdis/` to your server. The auth is just a JSON file.
|
||||
|
||||
### Does enterprise OAuth work?
|
||||
|
||||
**Not currently.** Enterprise accounts use SSO which requires a different auth flow that pi-coding-agent doesn't support yet.
|
||||
|
||||
**Workaround:** Ask your enterprise admin to provision an API key via the Anthropic console, then use that with `ANTHROPIC_API_KEY`.
|
||||
|
||||
### OAuth callback not working (containers/headless)?
|
||||
|
||||
OAuth needs the callback to reach the machine running the CLI. Options:
|
||||
@@ -271,6 +308,33 @@ cat ~/.clawdis/clawdis.json | grep workspace
|
||||
- **Signal** — Via `signal-cli` (see [signal.md](./signal.md)).
|
||||
- **WebChat** — Browser-based chat UI.
|
||||
|
||||
### Discord: Bot works in channels but not DMs?
|
||||
|
||||
Discord has **separate allowlists** for channels vs DMs:
|
||||
|
||||
- `discord.guilds.*.users` — controls who can talk in server channels
|
||||
- `discord.dm.allowFrom` — controls who can DM the bot
|
||||
|
||||
If channels work but DMs don't, add `discord.dm.allowFrom` to your config:
|
||||
|
||||
```json
|
||||
{
|
||||
"discord": {
|
||||
"dm": {
|
||||
"enabled": true,
|
||||
"allowFrom": ["YOUR_DISCORD_USER_ID"]
|
||||
},
|
||||
"guilds": {
|
||||
"your-server": {
|
||||
"users": ["YOUR_DISCORD_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Find your user ID: Discord Settings → Advanced → Developer Mode → right-click yourself → Copy User ID.
|
||||
|
||||
### Can I use multiple platforms at once?
|
||||
|
||||
Yes! One Clawdis gateway can connect to WhatsApp, Telegram, Discord, and more simultaneously. Each platform maintains its own sessions.
|
||||
@@ -399,6 +463,50 @@ Quick reference (send these in chat):
|
||||
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
|
||||
| `/verbose on\|off` | Toggle verbose mode |
|
||||
| `/activation mention\|always` | Group activation (owner-only) |
|
||||
| `/model <name>` | Switch AI model (see below) |
|
||||
| `/queue instant\|batch\|serial` | Message queuing mode |
|
||||
|
||||
### How do I switch models on the fly?
|
||||
|
||||
Use `/model` to switch without restarting:
|
||||
|
||||
```
|
||||
/model sonnet
|
||||
/model haiku
|
||||
/model opus
|
||||
```
|
||||
|
||||
**Setup:** Configure allowed models and aliases in `clawdis.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"model": "anthropic/claude-opus-4-5-20251022",
|
||||
"allowedModels": [
|
||||
"anthropic/claude-opus-4-5-20251022",
|
||||
"anthropic/claude-sonnet-4-5-20251022",
|
||||
"anthropic/claude-haiku-4-5-20251001"
|
||||
],
|
||||
"modelAliases": {
|
||||
"opus": "anthropic/claude-opus-4-5-20251022",
|
||||
"sonnet": "anthropic/claude-sonnet-4-5-20251022",
|
||||
"haiku": "anthropic/claude-haiku-4-5-20251001"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tip:** `/model` is processed at the gateway level — it works even if you're rate-limited (429) on the current model!
|
||||
|
||||
### How do rapid-fire messages work?
|
||||
|
||||
Use `/queue` to control how messages sent in quick succession are handled:
|
||||
|
||||
- **`/queue instant`** — New messages interrupt/steer the current response
|
||||
- **`/queue batch`** — Messages queue up, processed after current turn
|
||||
- **`/queue serial`** — One at a time, in order
|
||||
|
||||
If you tend to send multiple short messages, `/queue instant` feels most natural.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-3
@@ -21,11 +21,16 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
|
||||
- Sparkle tools are fetched automatically via SwiftPM at `apps/macos/.build/artifacts/sparkle/Sparkle/bin/` (`sign_update`, `generate_appcast`, etc.).
|
||||
|
||||
## Build & package
|
||||
Notes:
|
||||
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
|
||||
- Universal by default (`arm64 x86_64`). Override with `BUILD_ARCHS="arm64"` if you need arm-only.
|
||||
|
||||
```bash
|
||||
# From repo root; set release IDs so Sparkle feed is enabled
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
BUNDLE_ID=com.steipete.clawdis \
|
||||
APP_VERSION=0.1.0 \
|
||||
APP_BUILD=0.1.0 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
||||
scripts/package-mac-app.sh
|
||||
@@ -43,7 +48,7 @@ scripts/create-dmg.sh dist/Clawdis.app dist/Clawdis-0.1.0.dmg
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdis-notary \
|
||||
BUNDLE_ID=com.steipete.clawdis \
|
||||
APP_VERSION=0.1.0 \
|
||||
APP_BUILD=0.1.0 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
summary: "Refactor plan: Gateway TUI parity with pi-mono interactive UI"
|
||||
read_when:
|
||||
- Building or refactoring the Gateway TUI
|
||||
- Syncing TUI slash commands with Clawdis behavior
|
||||
---
|
||||
# Gateway TUI refactor plan
|
||||
|
||||
Updated: 2026-01-03
|
||||
|
||||
## Goals
|
||||
- Match pi-mono interactive TUI feel (editor, streaming, tool cards, selectors).
|
||||
- Keep Clawdis semantics: Gateway WS only, session store owns state, no branching/export.
|
||||
- Work locally or remotely via Gateway URL/token.
|
||||
|
||||
## Non-goals
|
||||
- Branching, export, OAuth flows, or hook UIs.
|
||||
- File-system operations on the Gateway host from the TUI.
|
||||
|
||||
## Checklist
|
||||
- [x] Protocol + server: sessions.patch supports model overrides; agent events include tool results (text-only payloads).
|
||||
- [x] Gateway TUI client: add session/model helpers + stricter typing.
|
||||
- [x] TUI UI kit: theme + components (editor, message feed, tool cards, selectors).
|
||||
- [x] TUI controller: keybindings + Clawdis slash commands + history/stream wiring.
|
||||
- [x] Docs + changelog updated for the new TUI behavior.
|
||||
- [x] Gate: lint, build, tests, docs list.
|
||||
+18
-4
@@ -10,6 +10,7 @@ Updated: 2026-01-03
|
||||
|
||||
## What it is
|
||||
- A terminal UI that connects to the Gateway WebSocket and speaks the same chat APIs as WebChat.
|
||||
- Uses Gateway agent events for tool cards while streaming responses.
|
||||
- Works locally (loopback) or remotely (Tailscale/SSH tunnel) without running a separate agent process.
|
||||
|
||||
## Run
|
||||
@@ -28,7 +29,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
|
||||
- `--token <token>`: Gateway token (if required).
|
||||
- `--password <password>`: Gateway password (if required).
|
||||
- `--session <key>`: Session key (default: `session.mainKey` or `main`).
|
||||
- `--deliver`: Deliver assistant replies to the provider.
|
||||
- `--deliver`: Deliver assistant replies to the provider (default off).
|
||||
- `--thinking <level>`: Override thinking level for sends.
|
||||
- `--timeout-ms <ms>`: Agent timeout in ms (default 30000).
|
||||
- `--history-limit <n>`: History entries to load (default 200).
|
||||
@@ -36,16 +37,29 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
|
||||
## Controls
|
||||
- Enter: send message
|
||||
- Esc: abort active run
|
||||
- Ctrl+C: exit
|
||||
- Ctrl+C: clear input (press twice to exit)
|
||||
- Ctrl+D: exit
|
||||
- Ctrl+L: model picker
|
||||
- Ctrl+P: session picker
|
||||
- Ctrl+O: toggle tool output expansion
|
||||
- Ctrl+T: toggle thinking visibility
|
||||
|
||||
## Slash commands
|
||||
- `/help`
|
||||
- `/session <key>`
|
||||
- `/status`
|
||||
- `/session <key>` (or `/sessions`)
|
||||
- `/model <provider/model>` (or `/models`)
|
||||
- `/think <off|minimal|low|medium|high>`
|
||||
- `/verbose <on|off>`
|
||||
- `/activation <mention|always>`
|
||||
- `/deliver <on|off>`
|
||||
- `/new` or `/reset`
|
||||
- `/abort`
|
||||
- `/settings`
|
||||
- `/exit`
|
||||
|
||||
## Notes
|
||||
- The TUI shows Gateway chat deltas (`event: chat`) and final responses.
|
||||
- The TUI shows Gateway chat deltas (`event: chat`) and agent tool events.
|
||||
- It registers as a Gateway client with `mode: "tui"` for presence and debugging.
|
||||
|
||||
## Files
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"commander": "^14.0.2",
|
||||
"croner": "^9.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"discord-api-types": "^0.38.33",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
|
||||
@@ -2,7 +2,7 @@ diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js
|
||||
index ff9cbcfebfac6b4370d85dc838f5cacf2a60ed64..42096c82aec925b412258348a36ba4a7025b283b 100644
|
||||
--- a/dist/providers/google-shared.js
|
||||
+++ b/dist/providers/google-shared.js
|
||||
@@ -140,6 +140,71 @@ export function convertMessages(model, context) {
|
||||
@@ -140,6 +140,72 @@ export function convertMessages(model, context) {
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
Generated
+8
-5
@@ -9,7 +9,7 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
'@mariozechner/pi-ai':
|
||||
hash: 717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4
|
||||
hash: 969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae
|
||||
path: patches/@mariozechner__pi-ai.patch
|
||||
'@mariozechner/pi-coding-agent@0.31.1':
|
||||
hash: d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745
|
||||
@@ -33,7 +33,7 @@ importers:
|
||||
version: 0.31.1(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.31.1
|
||||
version: 0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)
|
||||
version: 0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.31.1
|
||||
version: 0.31.1(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)
|
||||
@@ -70,6 +70,9 @@ importers:
|
||||
detect-libc:
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.2
|
||||
discord-api-types:
|
||||
specifier: ^0.38.33
|
||||
version: 0.38.37
|
||||
discord.js:
|
||||
specifier: ^14.25.1
|
||||
version: 14.25.1
|
||||
@@ -3350,7 +3353,7 @@ snapshots:
|
||||
|
||||
'@mariozechner/pi-agent-core@0.31.1(ws@8.18.3)(zod@4.3.4)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai': 0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-tui': 0.31.1
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
@@ -3360,7 +3363,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)':
|
||||
'@mariozechner/pi-ai@0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.4)
|
||||
'@google/genai': 1.34.0
|
||||
@@ -3383,7 +3386,7 @@ snapshots:
|
||||
'@mariozechner/pi-coding-agent@0.31.1(patch_hash=d0d5ffa1bfda8a0f9d14a5e73a074014346d3edbdb2ffc91444d3be5119f5745)(ws@8.18.3)(zod@4.3.4)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-agent-core': 0.31.1(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai': 0.31.1(patch_hash=717192ba4aea08520822591984491823add75c2f88037188acc7a916b52326f4)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-ai': 0.31.1(patch_hash=969db6f3f4cc91fec48124e1f5e515b386b1f1bed807769d0a80c28abadbaaae)(ws@8.18.3)(zod@4.3.4)
|
||||
'@mariozechner/pi-tui': 0.31.1
|
||||
chalk: 5.6.2
|
||||
cli-highlight: 2.1.11
|
||||
|
||||
+145
-17
@@ -6,7 +6,7 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP_ROOT="$ROOT_DIR/dist/Clawdis.app"
|
||||
BUILD_PATH="$ROOT_DIR/apps/macos/.build"
|
||||
BUILD_ROOT="$ROOT_DIR/apps/macos/.build"
|
||||
PRODUCT="Clawdis"
|
||||
BUNDLE_ID="${BUNDLE_ID:-com.steipete.clawdis.debug}"
|
||||
PKG_VERSION="$(cd "$ROOT_DIR" && node -p "require('./package.json').version" 2>/dev/null || echo "0.0.0")"
|
||||
@@ -16,6 +16,9 @@ GIT_BUILD_NUMBER=$(cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null || ec
|
||||
APP_VERSION="${APP_VERSION:-$PKG_VERSION}"
|
||||
APP_BUILD="${APP_BUILD:-$GIT_BUILD_NUMBER}"
|
||||
BUILD_CONFIG="${BUILD_CONFIG:-debug}"
|
||||
BUILD_ARCHS_VALUE="${BUILD_ARCHS:-arm64 x86_64}"
|
||||
IFS=' ' read -r -a BUILD_ARCHS <<< "$BUILD_ARCHS_VALUE"
|
||||
PRIMARY_ARCH="${BUILD_ARCHS[0]}"
|
||||
SPARKLE_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY:-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=}"
|
||||
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml}"
|
||||
AUTO_CHECKS=true
|
||||
@@ -23,6 +26,104 @@ if [[ "$BUNDLE_ID" == *.debug ]]; then
|
||||
SPARKLE_FEED_URL=""
|
||||
AUTO_CHECKS=false
|
||||
fi
|
||||
if [[ "$AUTO_CHECKS" == "true" && ! "$APP_BUILD" =~ ^[0-9]+$ ]]; then
|
||||
echo "ERROR: APP_BUILD must be numeric for Sparkle compare (CFBundleVersion). Got: $APP_BUILD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build_path_for_arch() {
|
||||
echo "$BUILD_ROOT/$1"
|
||||
}
|
||||
|
||||
bin_for_arch() {
|
||||
echo "$(build_path_for_arch "$1")/$BUILD_CONFIG/$PRODUCT"
|
||||
}
|
||||
|
||||
sparkle_framework_for_arch() {
|
||||
echo "$(build_path_for_arch "$1")/$BUILD_CONFIG/Sparkle.framework"
|
||||
}
|
||||
|
||||
merge_framework_machos() {
|
||||
local primary="$1"
|
||||
local dest="$2"
|
||||
shift 2
|
||||
local others=("$@")
|
||||
|
||||
archs_for() {
|
||||
/usr/bin/lipo -info "$1" | /usr/bin/sed -E 's/.*are: //; s/.*architecture: //'
|
||||
}
|
||||
|
||||
arch_in_list() {
|
||||
local needle="$1"
|
||||
shift
|
||||
for item in "$@"; do
|
||||
if [[ "$item" == "$needle" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
while IFS= read -r -d '' file; do
|
||||
if /usr/bin/file "$file" | /usr/bin/grep -q "Mach-O"; then
|
||||
local rel="${file#$primary/}"
|
||||
local primary_archs
|
||||
primary_archs=$(archs_for "$file")
|
||||
IFS=' ' read -r -a primary_arch_array <<< "$primary_archs"
|
||||
|
||||
local missing_files=()
|
||||
local tmp_dir
|
||||
tmp_dir=$(mktemp -d)
|
||||
for fw in "${others[@]}"; do
|
||||
local other_file="$fw/$rel"
|
||||
if [[ ! -f "$other_file" ]]; then
|
||||
echo "ERROR: Missing $rel in $fw" >&2
|
||||
rm -rf "$tmp_dir"
|
||||
exit 1
|
||||
fi
|
||||
if /usr/bin/file "$other_file" | /usr/bin/grep -q "Mach-O"; then
|
||||
local other_archs
|
||||
other_archs=$(archs_for "$other_file")
|
||||
IFS=' ' read -r -a other_arch_array <<< "$other_archs"
|
||||
for arch in "${other_arch_array[@]}"; do
|
||||
if ! arch_in_list "$arch" "${primary_arch_array[@]}"; then
|
||||
local thin_file="$tmp_dir/$(echo "$rel" | tr '/' '_')-$arch"
|
||||
/usr/bin/lipo -thin "$arch" "$other_file" -output "$thin_file"
|
||||
missing_files+=("$thin_file")
|
||||
primary_arch_array+=("$arch")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "${#missing_files[@]}" -gt 0 ]]; then
|
||||
/usr/bin/lipo -create "$file" "${missing_files[@]}" -output "$dest/$rel"
|
||||
fi
|
||||
rm -rf "$tmp_dir"
|
||||
fi
|
||||
done < <(find "$primary" -type f -print0)
|
||||
}
|
||||
|
||||
build_relay_binary() {
|
||||
local arch="$1"
|
||||
local out="$2"
|
||||
local define_arg="__CLAWDIS_VERSION__=\\\"$PKG_VERSION\\\""
|
||||
local bun_bin="bun"
|
||||
local -a cmd=("$bun_bin" build "$ROOT_DIR/dist/macos/relay.js" --compile --bytecode --outfile "$out" -e electron --define "$define_arg")
|
||||
if [[ "$arch" == "x86_64" ]]; then
|
||||
if ! arch -x86_64 /usr/bin/true >/dev/null 2>&1; then
|
||||
echo "ERROR: Rosetta is required to build the x86_64 relay. Install Rosetta and retry." >&2
|
||||
exit 1
|
||||
fi
|
||||
local bun_x86="${BUN_X86_64_BIN:-$HOME/.bun-x64/bin/bun}"
|
||||
if [[ -x "$bun_x86" ]]; then
|
||||
cmd=("$bun_x86" build "$ROOT_DIR/dist/macos/relay.js" --compile --bytecode --outfile "$out" -e electron --define "$define_arg")
|
||||
fi
|
||||
arch -x86_64 "${cmd[@]}"
|
||||
else
|
||||
"${cmd[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "📦 Ensuring deps (pnpm install)"
|
||||
(cd "$ROOT_DIR" && pnpm install --no-frozen-lockfile --config.node-linker=hoisted)
|
||||
@@ -42,11 +143,14 @@ fi
|
||||
|
||||
cd "$ROOT_DIR/apps/macos"
|
||||
|
||||
echo "🔨 Building $PRODUCT ($BUILD_CONFIG)"
|
||||
swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" -Xlinker -rpath -Xlinker @executable_path/../Frameworks
|
||||
echo "🔨 Building $PRODUCT ($BUILD_CONFIG) [${BUILD_ARCHS[*]}]"
|
||||
for arch in "${BUILD_ARCHS[@]}"; do
|
||||
BUILD_PATH="$(build_path_for_arch "$arch")"
|
||||
swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" --arch "$arch" -Xlinker -rpath -Xlinker @executable_path/../Frameworks
|
||||
done
|
||||
|
||||
BIN="$BUILD_PATH/$BUILD_CONFIG/$PRODUCT"
|
||||
echo "pkg: binary $BIN" >&2
|
||||
BIN_PRIMARY="$(bin_for_arch "$PRIMARY_ARCH")"
|
||||
echo "pkg: binary $BIN_PRIMARY" >&2
|
||||
echo "🧹 Cleaning old app bundle"
|
||||
rm -rf "$APP_ROOT"
|
||||
mkdir -p "$APP_ROOT/Contents/MacOS"
|
||||
@@ -77,15 +181,32 @@ else
|
||||
fi
|
||||
|
||||
echo "🚚 Copying binary"
|
||||
cp "$BIN" "$APP_ROOT/Contents/MacOS/Clawdis"
|
||||
cp "$BIN_PRIMARY" "$APP_ROOT/Contents/MacOS/Clawdis"
|
||||
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
|
||||
BIN_INPUTS=()
|
||||
for arch in "${BUILD_ARCHS[@]}"; do
|
||||
BIN_INPUTS+=("$(bin_for_arch "$arch")")
|
||||
done
|
||||
/usr/bin/lipo -create "${BIN_INPUTS[@]}" -output "$APP_ROOT/Contents/MacOS/Clawdis"
|
||||
fi
|
||||
chmod +x "$APP_ROOT/Contents/MacOS/Clawdis"
|
||||
# SwiftPM outputs ad-hoc signed binaries; strip the signature before install_name_tool to avoid warnings.
|
||||
/usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/Clawdis" 2>/dev/null || true
|
||||
|
||||
SPARKLE_FRAMEWORK="$BUILD_PATH/$BUILD_CONFIG/Sparkle.framework"
|
||||
if [ -d "$SPARKLE_FRAMEWORK" ]; then
|
||||
SPARKLE_FRAMEWORK_PRIMARY="$(sparkle_framework_for_arch "$PRIMARY_ARCH")"
|
||||
if [ -d "$SPARKLE_FRAMEWORK_PRIMARY" ]; then
|
||||
echo "✨ Embedding Sparkle.framework"
|
||||
cp -R "$SPARKLE_FRAMEWORK" "$APP_ROOT/Contents/Frameworks/"
|
||||
cp -R "$SPARKLE_FRAMEWORK_PRIMARY" "$APP_ROOT/Contents/Frameworks/"
|
||||
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
|
||||
OTHER_FRAMEWORKS=()
|
||||
for arch in "${BUILD_ARCHS[@]}"; do
|
||||
if [[ "$arch" == "$PRIMARY_ARCH" ]]; then
|
||||
continue
|
||||
fi
|
||||
OTHER_FRAMEWORKS+=("$(sparkle_framework_for_arch "$arch")")
|
||||
done
|
||||
merge_framework_machos "$SPARKLE_FRAMEWORK_PRIMARY" "$APP_ROOT/Contents/Frameworks/Sparkle.framework" "${OTHER_FRAMEWORKS[@]}"
|
||||
fi
|
||||
chmod -R a+rX "$APP_ROOT/Contents/Frameworks/Sparkle.framework"
|
||||
fi
|
||||
|
||||
@@ -106,14 +227,21 @@ if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
|
||||
|
||||
echo "🧰 Building bundled relay (bun --compile)"
|
||||
mkdir -p "$RELAY_DIR"
|
||||
RELAY_OUT="$RELAY_DIR/clawdis"
|
||||
bun build "$ROOT_DIR/dist/macos/relay.js" \
|
||||
--compile \
|
||||
--bytecode \
|
||||
--outfile "$RELAY_OUT" \
|
||||
-e electron \
|
||||
--define "__CLAWDIS_VERSION__=\\\"$PKG_VERSION\\\""
|
||||
chmod +x "$RELAY_OUT"
|
||||
RELAY_OUT="$RELAY_DIR/clawdis"
|
||||
RELAY_BUILD_DIR="$RELAY_DIR/.relay-build"
|
||||
rm -rf "$RELAY_BUILD_DIR"
|
||||
mkdir -p "$RELAY_BUILD_DIR"
|
||||
for arch in "${BUILD_ARCHS[@]}"; do
|
||||
RELAY_ARCH_OUT="$RELAY_BUILD_DIR/clawdis-$arch"
|
||||
build_relay_binary "$arch" "$RELAY_ARCH_OUT"
|
||||
chmod +x "$RELAY_ARCH_OUT"
|
||||
done
|
||||
if [[ "${#BUILD_ARCHS[@]}" -gt 1 ]]; then
|
||||
/usr/bin/lipo -create "$RELAY_BUILD_DIR"/clawdis-* -output "$RELAY_OUT"
|
||||
else
|
||||
cp "$RELAY_BUILD_DIR/clawdis-${BUILD_ARCHS[0]}" "$RELAY_OUT"
|
||||
fi
|
||||
rm -rf "$RELAY_BUILD_DIR"
|
||||
|
||||
echo "🎨 Copying gateway A2UI host assets"
|
||||
rm -rf "$RELAY_DIR/a2ui"
|
||||
|
||||
+15
-5
@@ -1,26 +1,36 @@
|
||||
---
|
||||
name: gog
|
||||
description: Google Workspace CLI for Gmail, Calendar, Drive, and Contacts.
|
||||
description: Google Workspace CLI for Gmail, Calendar, Drive, Contacts, Sheets, and Docs.
|
||||
homepage: https://gogcli.sh
|
||||
metadata: {"clawdis":{"emoji":"🎮","requires":{"bins":["gog"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gogcli","bins":["gog"],"label":"Install gog (brew)"}]}}
|
||||
---
|
||||
|
||||
# gog
|
||||
|
||||
Use `gog` for Gmail/Calendar/Drive/Contacts. Requires OAuth setup.
|
||||
Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
|
||||
|
||||
Setup (once)
|
||||
- `gog auth credentials /path/to/client_secret.json`
|
||||
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts`
|
||||
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,sheets,docs`
|
||||
- `gog auth list`
|
||||
|
||||
Common commands
|
||||
- Gmail search: `gog gmail search 'newer_than:7d' --max 10`
|
||||
- Gmail send: `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
|
||||
- Calendar: `gog calendar events <calendarId> --from <rfc3339> --to <rfc3339>`
|
||||
- Drive: `gog drive search "query" --max 10`
|
||||
- Calendar: `gog calendar events <calendarId> --from <iso> --to <iso>`
|
||||
- Drive search: `gog drive search "query" --max 10`
|
||||
- Contacts: `gog contacts list --max 20`
|
||||
- Sheets get: `gog sheets get <sheetId> "Tab!A1:D10" --json`
|
||||
- Sheets update: `gog sheets update <sheetId> "Tab!A1:B2" --values-json '[["A","B"],["1","2"]]' --input USER_ENTERED`
|
||||
- Sheets append: `gog sheets append <sheetId> "Tab!A:C" --values-json '[["x","y","z"]]' --insert INSERT_ROWS`
|
||||
- Sheets clear: `gog sheets clear <sheetId> "Tab!A2:Z"`
|
||||
- Sheets metadata: `gog sheets metadata <sheetId> --json`
|
||||
- Docs export: `gog docs export <docId> --format txt --out /tmp/doc.txt`
|
||||
- Docs cat: `gog docs cat <docId>`
|
||||
|
||||
Notes
|
||||
- Set `GOG_ACCOUNT=you@gmail.com` to avoid repeating `--account`.
|
||||
- For scripting, prefer `--json` plus `--no-input`.
|
||||
- Sheets values can be passed via `--values-json` (recommended) or as inline rows.
|
||||
- Docs supports export/cat/copy. In-place edits require a Docs API client (not in gog).
|
||||
- Confirm before sending mail or creating events.
|
||||
@@ -197,7 +197,10 @@ export function createBashTool(
|
||||
rows: 30,
|
||||
});
|
||||
} catch (error) {
|
||||
if (ptyShell !== DEFAULT_SHELL_PATH && existsSync(DEFAULT_SHELL_PATH)) {
|
||||
if (
|
||||
ptyShell !== DEFAULT_SHELL_PATH &&
|
||||
existsSync(DEFAULT_SHELL_PATH)
|
||||
) {
|
||||
try {
|
||||
pty = ptyModule.spawn(
|
||||
DEFAULT_SHELL_PATH,
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
writeScreenRecordToFile,
|
||||
} from "../cli/nodes-screen.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { type DiscordActionConfig, loadConfig } from "../config/config.js";
|
||||
import {
|
||||
addRoleDiscord,
|
||||
banMemberDiscord,
|
||||
@@ -1745,7 +1745,7 @@ function createDiscordTool(): AnyAgentTool {
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const cfg = loadConfig();
|
||||
const isActionEnabled = (
|
||||
key: keyof NonNullable<typeof cfg.discord>["actions"],
|
||||
key: keyof DiscordActionConfig,
|
||||
defaultValue = true,
|
||||
) => {
|
||||
const value = cfg.discord?.actions?.[key];
|
||||
|
||||
@@ -16,6 +16,36 @@ import {
|
||||
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
|
||||
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
|
||||
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
|
||||
const TOOL_RESULT_MAX_CHARS = 8000;
|
||||
|
||||
function truncateToolText(text: string): string {
|
||||
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
|
||||
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
|
||||
}
|
||||
|
||||
function sanitizeToolResult(result: unknown): unknown {
|
||||
if (!result || typeof result !== "object") return result;
|
||||
const record = result as Record<string, unknown>;
|
||||
const content = Array.isArray(record.content) ? record.content : null;
|
||||
if (!content) return record;
|
||||
const sanitized = content.map((item) => {
|
||||
if (!item || typeof item !== "object") return item;
|
||||
const entry = item as Record<string, unknown>;
|
||||
const type = typeof entry.type === "string" ? entry.type : undefined;
|
||||
if (type === "text" && typeof entry.text === "string") {
|
||||
return { ...entry, text: truncateToolText(entry.text) };
|
||||
}
|
||||
if (type === "image") {
|
||||
const data = typeof entry.data === "string" ? entry.data : undefined;
|
||||
const bytes = data ? data.length : undefined;
|
||||
const cleaned = { ...entry };
|
||||
delete cleaned.data;
|
||||
return { ...cleaned, bytes, omitted: true };
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
return { ...record, content: sanitized };
|
||||
}
|
||||
|
||||
function stripThinkingSegments(text: string): string {
|
||||
if (!text || !THINKING_TAG_RE.test(text)) return text;
|
||||
@@ -187,6 +217,36 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
});
|
||||
}
|
||||
|
||||
if (evt.type === "tool_execution_update") {
|
||||
const toolName = String(
|
||||
(evt as AgentEvent & { toolName: string }).toolName,
|
||||
);
|
||||
const toolCallId = String(
|
||||
(evt as AgentEvent & { toolCallId: string }).toolCallId,
|
||||
);
|
||||
const partial = (evt as AgentEvent & { partialResult?: unknown })
|
||||
.partialResult;
|
||||
const sanitized = sanitizeToolResult(partial);
|
||||
emitAgentEvent({
|
||||
runId: params.runId,
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "update",
|
||||
name: toolName,
|
||||
toolCallId,
|
||||
partialResult: sanitized,
|
||||
},
|
||||
});
|
||||
params.onAgentEvent?.({
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "update",
|
||||
name: toolName,
|
||||
toolCallId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (evt.type === "tool_execution_end") {
|
||||
const toolName = String(
|
||||
(evt as AgentEvent & { toolName: string }).toolName,
|
||||
@@ -197,6 +257,8 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
const isError = Boolean(
|
||||
(evt as AgentEvent & { isError: boolean }).isError,
|
||||
);
|
||||
const result = (evt as AgentEvent & { result?: unknown }).result;
|
||||
const sanitizedResult = sanitizeToolResult(result);
|
||||
const meta = toolMetaById.get(toolCallId);
|
||||
toolMetas.push({ toolName, meta });
|
||||
toolDebouncer.push(toolName, meta);
|
||||
@@ -210,6 +272,7 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
toolCallId,
|
||||
meta,
|
||||
isError,
|
||||
result: sanitizedResult,
|
||||
},
|
||||
});
|
||||
params.onAgentEvent?.({
|
||||
|
||||
@@ -116,7 +116,11 @@ describe("directive parsing", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/queue collect debounce:bogus cap:zero drop:maybe", From: "+1222", To: "+1222" },
|
||||
{
|
||||
Body: "/queue collect debounce:bogus cap:zero drop:maybe",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
|
||||
@@ -42,9 +42,8 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
process.env.HOME = previousHome;
|
||||
try {
|
||||
await fs.rm(base, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes("ENOTEMPTY")) throw err;
|
||||
} catch {
|
||||
// ignore cleanup failures in tests
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,9 +104,7 @@ describe("queue followups", () => {
|
||||
cfg,
|
||||
);
|
||||
|
||||
const secondText = Array.isArray(second)
|
||||
? second[0]?.text
|
||||
: second?.text;
|
||||
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
|
||||
expect(secondText).toBe("main");
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
@@ -450,6 +450,8 @@ describe("trigger handling", () => {
|
||||
});
|
||||
|
||||
describe("group intro prompts", () => {
|
||||
const groupParticipationNote =
|
||||
"Be a good group participant: lurk and follow the conversation, but only chime in when you have something genuinely helpful or relevant to add. Don't feel obligated to respond to every message — quality over quantity. Even when lurking silently, you can use emoji reactions to acknowledge messages, show support, or react to humor — reactions are always appreciated and don't clutter the chat.";
|
||||
it("labels Discord groups using the surface metadata", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
@@ -479,7 +481,7 @@ describe("group intro prompts", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]
|
||||
?.extraSystemPrompt ?? "";
|
||||
expect(extraSystemPrompt).toBe(
|
||||
'You are replying inside the Discord group "Release Squad". Group members: Alice, Bob. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). Address the specific sender noted in the message context.',
|
||||
`You are replying inside the Discord group "Release Squad". Group members: Alice, Bob. Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -512,7 +514,7 @@ describe("group intro prompts", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]
|
||||
?.extraSystemPrompt ?? "";
|
||||
expect(extraSystemPrompt).toBe(
|
||||
'You are replying inside the WhatsApp group "Ops". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). Address the specific sender noted in the message context.',
|
||||
`You are replying inside the WhatsApp group "Ops". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -545,7 +547,7 @@ describe("group intro prompts", () => {
|
||||
vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]
|
||||
?.extraSystemPrompt ?? "";
|
||||
expect(extraSystemPrompt).toBe(
|
||||
'You are replying inside the Telegram group "Dev Chat". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). Address the specific sender noted in the message context.',
|
||||
`You are replying inside the Telegram group "Dev Chat". Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included). ${groupParticipationNote} Address the specific sender noted in the message context.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+17
-12
@@ -30,6 +30,7 @@ import {
|
||||
DEFAULT_AGENT_WORKSPACE_DIR,
|
||||
ensureAgentWorkspace,
|
||||
} from "../agents/workspace.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
||||
import {
|
||||
buildGroupDisplayName,
|
||||
@@ -54,7 +55,6 @@ import {
|
||||
import { clearCommandLane, getQueueSize } from "../process/command-queue.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
|
||||
import {
|
||||
@@ -192,10 +192,18 @@ function normalizeQueueMode(raw?: string): QueueMode | undefined {
|
||||
if (!raw) return undefined;
|
||||
const cleaned = raw.trim().toLowerCase();
|
||||
if (cleaned === "queue" || cleaned === "queued") return "steer";
|
||||
if (cleaned === "interrupt" || cleaned === "interrupts" || cleaned === "abort")
|
||||
if (
|
||||
cleaned === "interrupt" ||
|
||||
cleaned === "interrupts" ||
|
||||
cleaned === "abort"
|
||||
)
|
||||
return "interrupt";
|
||||
if (cleaned === "steer" || cleaned === "steering") return "steer";
|
||||
if (cleaned === "followup" || cleaned === "follow-ups" || cleaned === "followups")
|
||||
if (
|
||||
cleaned === "followup" ||
|
||||
cleaned === "follow-ups" ||
|
||||
cleaned === "followups"
|
||||
)
|
||||
return "followup";
|
||||
if (cleaned === "collect" || cleaned === "coalesce") return "collect";
|
||||
if (
|
||||
@@ -536,10 +544,7 @@ function buildSummaryPrompt(queue: FollowupQueueState): string | undefined {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildCollectPrompt(
|
||||
items: FollowupRun[],
|
||||
summary?: string,
|
||||
): string {
|
||||
function buildCollectPrompt(items: FollowupRun[], summary?: string): string {
|
||||
const blocks: string[] = ["[Queued messages while agent was busy]"];
|
||||
if (summary) {
|
||||
blocks.push(summary);
|
||||
@@ -706,7 +711,8 @@ function resolveQueueSettings(params: {
|
||||
mode: resolvedMode,
|
||||
debounceMs:
|
||||
typeof debounceRaw === "number" ? Math.max(0, debounceRaw) : undefined,
|
||||
cap: typeof capRaw === "number" ? Math.max(1, Math.floor(capRaw)) : undefined,
|
||||
cap:
|
||||
typeof capRaw === "number" ? Math.max(1, Math.floor(capRaw)) : undefined,
|
||||
dropPolicy: dropRaw,
|
||||
};
|
||||
}
|
||||
@@ -1954,9 +1960,7 @@ export async function getReplyFromConfig(
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
defaultRuntime.error?.(
|
||||
`Followup agent failed before reply: ${message}`,
|
||||
);
|
||||
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2010,7 +2014,8 @@ export async function getReplyFromConfig(
|
||||
...entry,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens: promptTokens > 0 ? promptTokens : usage.total ?? input,
|
||||
totalTokens:
|
||||
promptTokens > 0 ? promptTokens : (usage.total ?? input),
|
||||
model: modelUsed,
|
||||
contextTokens: contextTokensUsed ?? entry.contextTokens,
|
||||
updatedAt: Date.now(),
|
||||
|
||||
@@ -1 +1 @@
|
||||
debc115fd2b264d3b3090e28b733551ca5f7532287d827c13fc579e5a7a95b9d
|
||||
549aa1fff40f95b4a899940ad2cde0adc3991423136ba02c23bcdbd621765ad7
|
||||
+8
-2
@@ -22,8 +22,14 @@ export function registerTuiCli(program: Command) {
|
||||
.option("--history-limit <n>", "History entries to load", "200")
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const timeoutMs = Number.parseInt(String(opts.timeoutMs ?? "30000"), 10);
|
||||
const historyLimit = Number.parseInt(String(opts.historyLimit ?? "200"), 10);
|
||||
const timeoutMs = Number.parseInt(
|
||||
String(opts.timeoutMs ?? "30000"),
|
||||
10,
|
||||
);
|
||||
const historyLimit = Number.parseInt(
|
||||
String(opts.historyLimit ?? "200"),
|
||||
10,
|
||||
);
|
||||
await runTui({
|
||||
url: opts.url as string | undefined,
|
||||
token: opts.token as string | undefined,
|
||||
|
||||
@@ -5,16 +5,20 @@
|
||||
* On VPS/SSH/headless: Shows URL and prompts user to paste the callback URL manually.
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { stdin, stdout } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { loginAntigravity, type OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
|
||||
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
|
||||
const decode = (s: string) => Buffer.from(s, "base64").toString();
|
||||
const CLIENT_ID = decode("MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==");
|
||||
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
|
||||
const CLIENT_ID = decode(
|
||||
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
|
||||
);
|
||||
const CLIENT_SECRET = decode(
|
||||
"R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=",
|
||||
);
|
||||
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
|
||||
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
||||
@@ -98,9 +102,7 @@ export function shouldUseManualOAuthFlow(): boolean {
|
||||
*/
|
||||
function generatePKCESync(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString("hex");
|
||||
const challenge = createHash("sha256")
|
||||
.update(verifier)
|
||||
.digest("base64url");
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
@@ -196,7 +198,7 @@ async function exchangeCodeForTokens(
|
||||
|
||||
// Fetch user email
|
||||
const email = await getUserEmail(data.access_token);
|
||||
|
||||
|
||||
// Fetch project ID
|
||||
const projectId = await fetchProjectId(data.access_token);
|
||||
|
||||
@@ -288,7 +290,7 @@ async function fetchProjectId(accessToken: string): Promise<string> {
|
||||
return data.cloudaicompanionProject.id;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
// ignore failed endpoint, try next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +372,9 @@ export async function loginAntigravityManual(
|
||||
console.log("=".repeat(60));
|
||||
console.log("\n1. Open the URL above in your LOCAL browser");
|
||||
console.log("2. Complete the Google sign-in");
|
||||
console.log("3. Your browser will redirect to a localhost URL that won't load");
|
||||
console.log(
|
||||
"3. Your browser will redirect to a localhost URL that won't load",
|
||||
);
|
||||
console.log("4. Copy the ENTIRE URL from your browser's address bar");
|
||||
console.log("5. Paste it below\n");
|
||||
console.log("The URL will look like:");
|
||||
|
||||
@@ -10,16 +10,7 @@ import {
|
||||
spinner,
|
||||
text,
|
||||
} from "@clack/prompts";
|
||||
import {
|
||||
loginAnthropic,
|
||||
type OAuthCredentials,
|
||||
} from "@mariozechner/pi-ai";
|
||||
|
||||
import {
|
||||
loginAntigravityVpsAware,
|
||||
isRemoteEnvironment,
|
||||
} from "./antigravity-oauth.js";
|
||||
|
||||
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
@@ -32,6 +23,10 @@ import { resolveGatewayService } from "../daemon/service.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
import {
|
||||
isRemoteEnvironment,
|
||||
loginAntigravityVpsAware,
|
||||
} from "./antigravity-oauth.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import {
|
||||
applyMinimaxConfig,
|
||||
|
||||
@@ -9,16 +9,7 @@ import {
|
||||
spinner,
|
||||
text,
|
||||
} from "@clack/prompts";
|
||||
import {
|
||||
loginAnthropic,
|
||||
type OAuthCredentials,
|
||||
} from "@mariozechner/pi-ai";
|
||||
|
||||
import {
|
||||
loginAntigravityVpsAware,
|
||||
isRemoteEnvironment,
|
||||
} from "./antigravity-oauth.js";
|
||||
|
||||
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
@@ -31,6 +22,10 @@ import { resolveGatewayService } from "../daemon/service.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { resolveUserPath, sleep } from "../utils.js";
|
||||
import {
|
||||
isRemoteEnvironment,
|
||||
loginAntigravityVpsAware,
|
||||
} from "./antigravity-oauth.js";
|
||||
import { healthCommand } from "./health.js";
|
||||
import {
|
||||
applyMinimaxConfig,
|
||||
|
||||
@@ -99,7 +99,9 @@ export async function runNonInteractiveOnboarding(
|
||||
} else if (authChoice === "minimax") {
|
||||
nextConfig = applyMinimaxConfig(nextConfig);
|
||||
} else if (authChoice === "oauth" || authChoice === "antigravity") {
|
||||
runtime.error(`${authChoice === "oauth" ? "OAuth" : "Antigravity"} requires interactive mode.`);
|
||||
runtime.error(
|
||||
`${authChoice === "oauth" ? "OAuth" : "Antigravity"} requires interactive mode.`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export type OnboardMode = "local" | "remote";
|
||||
export type AuthChoice = "oauth" | "antigravity" | "apiKey" | "minimax" | "skip";
|
||||
export type AuthChoice =
|
||||
| "oauth"
|
||||
| "antigravity"
|
||||
| "apiKey"
|
||||
| "minimax"
|
||||
| "skip";
|
||||
export type GatewayAuthChoice = "off" | "token" | "password";
|
||||
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||
export type GatewayBind = "loopback" | "lan" | "tailnet" | "auto";
|
||||
|
||||
@@ -114,11 +114,11 @@ export function resolveHookMappings(
|
||||
): HookMappingResolved[] {
|
||||
const presets = hooks?.presets ?? [];
|
||||
const mappings: HookMappingConfig[] = [];
|
||||
if (hooks?.mappings) mappings.push(...hooks.mappings);
|
||||
for (const preset of presets) {
|
||||
const presetMappings = hookPresetMappings[preset];
|
||||
if (presetMappings) mappings.push(...presetMappings);
|
||||
}
|
||||
if (hooks?.mappings) mappings.push(...hooks.mappings);
|
||||
if (mappings.length === 0) return [];
|
||||
|
||||
const configDir = path.dirname(CONFIG_PATH_CLAWDIS);
|
||||
|
||||
@@ -297,6 +297,7 @@ export const SessionsPatchParamsSchema = Type.Object(
|
||||
key: NonEmptyString,
|
||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
groupActivation: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("mention"),
|
||||
|
||||
@@ -4074,6 +4074,16 @@ describe("gateway server", () => {
|
||||
expect(main2?.thinkingLevel).toBe("medium");
|
||||
expect(main2?.verboseLevel).toBeUndefined();
|
||||
|
||||
piSdkMock.enabled = true;
|
||||
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
|
||||
const modelPatched = await rpcReq<{
|
||||
ok: true;
|
||||
entry: { modelOverride?: string; providerOverride?: string };
|
||||
}>(ws, "sessions.patch", { key: "main", model: "openai/gpt-test-a" });
|
||||
expect(modelPatched.ok).toBe(true);
|
||||
expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a");
|
||||
expect(modelPatched.payload?.entry.providerOverride).toBe("openai");
|
||||
|
||||
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(
|
||||
ws,
|
||||
"sessions.compact",
|
||||
|
||||
+158
-6
@@ -20,7 +20,13 @@ import {
|
||||
type ModelCatalogEntry,
|
||||
resetModelCatalogCacheForTest,
|
||||
} from "../agents/model-catalog.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import {
|
||||
buildAllowedModelSet,
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "../agents/model-selection.js";
|
||||
import { installSkill } from "../agents/skills-install.js";
|
||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.js";
|
||||
@@ -77,6 +83,7 @@ import {
|
||||
} from "../discord/index.js";
|
||||
import { type DiscordProbe, probeDiscord } from "../discord/probe.js";
|
||||
import { isVerbose } from "../globals.js";
|
||||
import { startGmailWatcher, stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
||||
import {
|
||||
monitorIMessageProvider,
|
||||
sendMessageIMessage,
|
||||
@@ -174,10 +181,6 @@ import {
|
||||
type HookMappingResolved,
|
||||
resolveHookMappings,
|
||||
} from "./hooks-mapping.js";
|
||||
import {
|
||||
startGmailWatcher,
|
||||
stopGmailWatcher,
|
||||
} from "../hooks/gmail-watcher.js";
|
||||
|
||||
ensureClawdisCliOnPath();
|
||||
|
||||
@@ -2961,6 +2964,74 @@ export async function startGatewayServer(
|
||||
}
|
||||
}
|
||||
|
||||
if ("model" in p) {
|
||||
const raw = p.model;
|
||||
if (raw === null) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid model: empty",
|
||||
},
|
||||
};
|
||||
}
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid model: ${trimmed}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const catalog = await loadGatewayModelCatalog();
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const key = modelKey(resolved.ref.provider, resolved.ref.model);
|
||||
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `model not allowed: ${key}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
resolved.ref.provider === resolvedDefault.provider &&
|
||||
resolved.ref.model === resolvedDefault.model
|
||||
) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else {
|
||||
next.providerOverride = resolved.ref.provider;
|
||||
next.modelOverride = resolved.ref.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("groupActivation" in p) {
|
||||
const raw = p.groupActivation;
|
||||
if (raw === null) {
|
||||
@@ -5507,6 +5578,83 @@ export async function startGatewayServer(
|
||||
}
|
||||
}
|
||||
|
||||
if ("model" in p) {
|
||||
const raw = p.model;
|
||||
if (raw === null) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"invalid model: empty",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid model: ${trimmed}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const catalog = await loadGatewayModelCatalog();
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
const key = modelKey(
|
||||
resolved.ref.provider,
|
||||
resolved.ref.model,
|
||||
);
|
||||
if (!allowed.allowAny && !allowed.allowedKeys.has(key)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`model not allowed: ${key}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
resolved.ref.provider === resolvedDefault.provider &&
|
||||
resolved.ref.model === resolvedDefault.model
|
||||
) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else {
|
||||
next.providerOverride = resolved.ref.provider;
|
||||
next.modelOverride = resolved.ref.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("groupActivation" in p) {
|
||||
const raw = p.groupActivation;
|
||||
if (raw === null) {
|
||||
@@ -6869,7 +7017,11 @@ export async function startGatewayServer(
|
||||
const gmailResult = await startGmailWatcher(cfgAtStart);
|
||||
if (gmailResult.started) {
|
||||
logHooks.info("gmail watcher started");
|
||||
} else if (gmailResult.reason && gmailResult.reason !== "hooks not enabled" && gmailResult.reason !== "no gmail account configured") {
|
||||
} else if (
|
||||
gmailResult.reason &&
|
||||
gmailResult.reason !== "hooks not enabled" &&
|
||||
gmailResult.reason !== "no gmail account configured"
|
||||
) {
|
||||
logHooks.warn(`gmail watcher not started: ${gmailResult.reason}`);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* if hooks.gmail is configured with an account.
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { hasBinary } from "../agents/skills.js";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
@@ -13,8 +13,8 @@ import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import {
|
||||
buildGogWatchServeArgs,
|
||||
buildGogWatchStartArgs,
|
||||
resolveGmailHookRuntimeConfig,
|
||||
type GmailHookRuntimeConfig,
|
||||
resolveGmailHookRuntimeConfig,
|
||||
} from "./gmail.js";
|
||||
import { ensureTailscaleEndpoint } from "./gmail-setup-utils.js";
|
||||
|
||||
@@ -42,7 +42,8 @@ async function startGmailWatch(
|
||||
try {
|
||||
const result = await runCommandWithTimeout(args, { timeoutMs: 120_000 });
|
||||
if (result.code !== 0) {
|
||||
const message = result.stderr || result.stdout || "gog watch start failed";
|
||||
const message =
|
||||
result.stderr || result.stdout || "gog watch start failed";
|
||||
log.error(`watch start failed: ${message}`);
|
||||
return false;
|
||||
}
|
||||
@@ -60,7 +61,7 @@ async function startGmailWatch(
|
||||
function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
|
||||
const args = buildGogWatchServeArgs(cfg);
|
||||
log.info(`starting gog ${args.join(" ")}`);
|
||||
|
||||
|
||||
const child = spawn("gog", args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
@@ -142,7 +143,10 @@ export async function startGmailWatcher(
|
||||
);
|
||||
} catch (err) {
|
||||
log.error(`tailscale setup failed: ${String(err)}`);
|
||||
return { started: false, reason: `tailscale setup failed: ${String(err)}` };
|
||||
return {
|
||||
started: false,
|
||||
reason: `tailscale setup failed: ${String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +188,7 @@ export async function stopGmailWatcher(): Promise<void> {
|
||||
if (watcherProcess) {
|
||||
log.info("stopping gmail watcher");
|
||||
watcherProcess.kill("SIGTERM");
|
||||
|
||||
|
||||
// Wait a bit for graceful shutdown
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { convertTools } from "@mariozechner/pi-ai/dist/providers/google-shared.js";
|
||||
import type { Tool } from "@mariozechner/pi-ai/dist/types.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> => {
|
||||
expect(value).toBeTruthy();
|
||||
expect(typeof value).toBe("object");
|
||||
expect(Array.isArray(value)).toBe(false);
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
describe("google-shared convertTools", () => {
|
||||
it("strips unsupported JSON Schema keywords", () => {
|
||||
const tools: Tool[] = [
|
||||
{
|
||||
name: "example",
|
||||
description: "Example tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
patternProperties: {
|
||||
"^x-": { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
mode: {
|
||||
type: "string",
|
||||
const: "fast",
|
||||
},
|
||||
options: {
|
||||
anyOf: [{ type: "string" }, { type: "number" }],
|
||||
},
|
||||
list: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
const: "item",
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["mode"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const converted = convertTools(tools);
|
||||
const params = asRecord(
|
||||
converted?.[0]?.functionDeclarations?.[0]?.parameters,
|
||||
);
|
||||
const properties = asRecord(params.properties);
|
||||
const mode = asRecord(properties.mode);
|
||||
const options = asRecord(properties.options);
|
||||
const list = asRecord(properties.list);
|
||||
const items = asRecord(list.items);
|
||||
|
||||
expect(params).not.toHaveProperty("patternProperties");
|
||||
expect(params).not.toHaveProperty("additionalProperties");
|
||||
expect(mode).not.toHaveProperty("const");
|
||||
expect(options).not.toHaveProperty("anyOf");
|
||||
expect(items).not.toHaveProperty("const");
|
||||
expect(params.required).toEqual(["mode"]);
|
||||
});
|
||||
|
||||
it("keeps supported schema fields", () => {
|
||||
const tools: Tool[] = [
|
||||
{
|
||||
name: "settings",
|
||||
description: "Settings tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
config: {
|
||||
type: "object",
|
||||
properties: {
|
||||
retries: { type: "number", minimum: 1 },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["retries"],
|
||||
},
|
||||
},
|
||||
required: ["config"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const converted = convertTools(tools);
|
||||
const params = asRecord(
|
||||
converted?.[0]?.functionDeclarations?.[0]?.parameters,
|
||||
);
|
||||
const config = asRecord(asRecord(params.properties).config);
|
||||
const configProps = asRecord(config.properties);
|
||||
const retries = asRecord(configProps.retries);
|
||||
const tags = asRecord(configProps.tags);
|
||||
const items = asRecord(tags.items);
|
||||
|
||||
expect(params.type).toBe("object");
|
||||
expect(config.type).toBe("object");
|
||||
expect(retries.minimum).toBe(1);
|
||||
expect(tags.type).toBe("array");
|
||||
expect(items.type).toBe("string");
|
||||
expect(config.required).toEqual(["retries"]);
|
||||
expect(params.required).toEqual(["config"]);
|
||||
});
|
||||
});
|
||||
@@ -217,6 +217,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
runtime,
|
||||
bot,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { SlashCommand } from "@mariozechner/pi-tui";
|
||||
|
||||
const THINK_LEVELS = ["off", "minimal", "low", "medium", "high"];
|
||||
const VERBOSE_LEVELS = ["on", "off"];
|
||||
const ACTIVATION_LEVELS = ["mention", "always"];
|
||||
const TOGGLE = ["on", "off"];
|
||||
|
||||
export type ParsedCommand = {
|
||||
name: string;
|
||||
args: string;
|
||||
};
|
||||
|
||||
export function parseCommand(input: string): ParsedCommand {
|
||||
const trimmed = input.replace(/^\//, "").trim();
|
||||
if (!trimmed) return { name: "", args: "" };
|
||||
const [name, ...rest] = trimmed.split(/\s+/);
|
||||
return { name: name.toLowerCase(), args: rest.join(" ").trim() };
|
||||
}
|
||||
|
||||
export function getSlashCommands(): SlashCommand[] {
|
||||
return [
|
||||
{ name: "help", description: "Show slash command help" },
|
||||
{ name: "status", description: "Show gateway status summary" },
|
||||
{ name: "session", description: "Switch session (or open picker)" },
|
||||
{ name: "sessions", description: "Open session picker" },
|
||||
{
|
||||
name: "model",
|
||||
description: "Set model (or open picker)",
|
||||
},
|
||||
{ name: "models", description: "Open model picker" },
|
||||
{
|
||||
name: "think",
|
||||
description: "Set thinking level",
|
||||
getArgumentCompletions: (prefix) =>
|
||||
THINK_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||
(value) => ({ value, label: value }),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "verbose",
|
||||
description: "Set verbose on/off",
|
||||
getArgumentCompletions: (prefix) =>
|
||||
VERBOSE_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||
(value) => ({ value, label: value }),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "activation",
|
||||
description: "Set group activation",
|
||||
getArgumentCompletions: (prefix) =>
|
||||
ACTIVATION_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||
(value) => ({ value, label: value }),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "deliver",
|
||||
description: "Toggle delivery of assistant replies",
|
||||
getArgumentCompletions: (prefix) =>
|
||||
TOGGLE.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||
(value) => ({ value, label: value }),
|
||||
),
|
||||
},
|
||||
{ name: "abort", description: "Abort active run" },
|
||||
{ name: "new", description: "Reset the session" },
|
||||
{ name: "reset", description: "Reset the session" },
|
||||
{ name: "settings", description: "Open settings" },
|
||||
{ name: "exit", description: "Exit the TUI" },
|
||||
{ name: "quit", description: "Exit the TUI" },
|
||||
];
|
||||
}
|
||||
|
||||
export function helpText(): string {
|
||||
return [
|
||||
"Slash commands:",
|
||||
"/help",
|
||||
"/status",
|
||||
"/session <key> (or /sessions)",
|
||||
"/model <provider/model> (or /models)",
|
||||
"/think <off|minimal|low|medium|high>",
|
||||
"/verbose <on|off>",
|
||||
"/activation <mention|always>",
|
||||
"/deliver <on|off>",
|
||||
"/new or /reset",
|
||||
"/abort",
|
||||
"/settings",
|
||||
"/exit",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
|
||||
import { markdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
export class AssistantMessageComponent extends Container {
|
||||
private body: Markdown;
|
||||
|
||||
constructor(text: string) {
|
||||
super();
|
||||
this.body = new Markdown(text, 1, 0, markdownTheme, {
|
||||
color: (line) => theme.fg(line),
|
||||
});
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.body);
|
||||
}
|
||||
|
||||
setText(text: string) {
|
||||
this.body.setText(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { theme } from "../theme/theme.js";
|
||||
import { AssistantMessageComponent } from "./assistant-message.js";
|
||||
import { ToolExecutionComponent } from "./tool-execution.js";
|
||||
import { UserMessageComponent } from "./user-message.js";
|
||||
|
||||
export class ChatLog extends Container {
|
||||
private toolById = new Map<string, ToolExecutionComponent>();
|
||||
private streamingAssistant: AssistantMessageComponent | null = null;
|
||||
private streamingRunId: string | null = null;
|
||||
private streamingText: string | null = null;
|
||||
private toolsExpanded = false;
|
||||
|
||||
clearAll() {
|
||||
this.clear();
|
||||
this.toolById.clear();
|
||||
this.streamingAssistant = null;
|
||||
this.streamingRunId = null;
|
||||
this.streamingText = null;
|
||||
}
|
||||
|
||||
addSystem(text: string) {
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.system(text), 1, 0));
|
||||
}
|
||||
|
||||
addUser(text: string) {
|
||||
this.addChild(new UserMessageComponent(text));
|
||||
}
|
||||
|
||||
startAssistant(text: string, runId?: string) {
|
||||
const component = new AssistantMessageComponent(text);
|
||||
this.streamingAssistant = component;
|
||||
this.streamingRunId = runId ?? null;
|
||||
this.streamingText = text;
|
||||
this.addChild(component);
|
||||
return component;
|
||||
}
|
||||
|
||||
updateAssistant(text: string, runId?: string) {
|
||||
if (
|
||||
!this.streamingAssistant ||
|
||||
(runId && this.streamingRunId && runId !== this.streamingRunId)
|
||||
) {
|
||||
this.startAssistant(text, runId);
|
||||
return;
|
||||
}
|
||||
this.streamingText = text;
|
||||
this.streamingAssistant.setText(text);
|
||||
}
|
||||
|
||||
finalizeAssistant(text: string, runId?: string) {
|
||||
if (
|
||||
this.streamingAssistant &&
|
||||
(!runId || runId === this.streamingRunId || text === this.streamingText)
|
||||
) {
|
||||
this.streamingText = text;
|
||||
this.streamingAssistant.setText(text);
|
||||
} else {
|
||||
this.startAssistant(text, runId);
|
||||
}
|
||||
this.streamingAssistant = null;
|
||||
this.streamingRunId = null;
|
||||
this.streamingText = null;
|
||||
}
|
||||
|
||||
startTool(toolCallId: string, toolName: string, args: unknown) {
|
||||
const existing = this.toolById.get(toolCallId);
|
||||
if (existing) {
|
||||
existing.setArgs(args);
|
||||
return existing;
|
||||
}
|
||||
const component = new ToolExecutionComponent(toolName, args);
|
||||
component.setExpanded(this.toolsExpanded);
|
||||
this.toolById.set(toolCallId, component);
|
||||
this.addChild(component);
|
||||
return component;
|
||||
}
|
||||
|
||||
updateToolArgs(toolCallId: string, args: unknown) {
|
||||
const existing = this.toolById.get(toolCallId);
|
||||
if (!existing) return;
|
||||
existing.setArgs(args);
|
||||
}
|
||||
|
||||
updateToolResult(
|
||||
toolCallId: string,
|
||||
result: unknown,
|
||||
opts?: { isError?: boolean; partial?: boolean },
|
||||
) {
|
||||
const existing = this.toolById.get(toolCallId);
|
||||
if (!existing) return;
|
||||
if (opts?.partial) {
|
||||
existing.setPartialResult(result as Record<string, unknown>);
|
||||
return;
|
||||
}
|
||||
existing.setResult(result as Record<string, unknown>, {
|
||||
isError: opts?.isError,
|
||||
});
|
||||
}
|
||||
|
||||
setToolsExpanded(expanded: boolean) {
|
||||
this.toolsExpanded = expanded;
|
||||
for (const tool of this.toolById.values()) {
|
||||
tool.setExpanded(expanded);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Editor,
|
||||
isAltEnter,
|
||||
isCtrlC,
|
||||
isCtrlD,
|
||||
isCtrlL,
|
||||
isCtrlO,
|
||||
isCtrlP,
|
||||
isCtrlT,
|
||||
isEscape,
|
||||
isShiftTab,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
export class CustomEditor extends Editor {
|
||||
onEscape?: () => void;
|
||||
onCtrlC?: () => void;
|
||||
onCtrlD?: () => void;
|
||||
onCtrlL?: () => void;
|
||||
onCtrlO?: () => void;
|
||||
onCtrlP?: () => void;
|
||||
onCtrlT?: () => void;
|
||||
onShiftTab?: () => void;
|
||||
onAltEnter?: () => void;
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (isAltEnter(data) && this.onAltEnter) {
|
||||
this.onAltEnter();
|
||||
return;
|
||||
}
|
||||
if (isCtrlL(data) && this.onCtrlL) {
|
||||
this.onCtrlL();
|
||||
return;
|
||||
}
|
||||
if (isCtrlO(data) && this.onCtrlO) {
|
||||
this.onCtrlO();
|
||||
return;
|
||||
}
|
||||
if (isCtrlP(data) && this.onCtrlP) {
|
||||
this.onCtrlP();
|
||||
return;
|
||||
}
|
||||
if (isCtrlT(data) && this.onCtrlT) {
|
||||
this.onCtrlT();
|
||||
return;
|
||||
}
|
||||
if (isShiftTab(data) && this.onShiftTab) {
|
||||
this.onShiftTab();
|
||||
return;
|
||||
}
|
||||
if (isEscape(data) && this.onEscape && !this.isShowingAutocomplete()) {
|
||||
this.onEscape();
|
||||
return;
|
||||
}
|
||||
if (isCtrlC(data) && this.onCtrlC) {
|
||||
this.onCtrlC();
|
||||
return;
|
||||
}
|
||||
if (isCtrlD(data)) {
|
||||
if (this.getText().length === 0 && this.onCtrlD) {
|
||||
this.onCtrlD();
|
||||
}
|
||||
return;
|
||||
}
|
||||
super.handleInput(data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
type SettingItem,
|
||||
SettingsList,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { selectListTheme, settingsListTheme } from "../theme/theme.js";
|
||||
|
||||
export function createSelectList(items: SelectItem[], maxVisible = 7) {
|
||||
return new SelectList(items, maxVisible, selectListTheme);
|
||||
}
|
||||
|
||||
export function createSettingsList(
|
||||
items: SettingItem[],
|
||||
onChange: (id: string, value: string) => void,
|
||||
onCancel: () => void,
|
||||
maxVisible = 7,
|
||||
) {
|
||||
return new SettingsList(
|
||||
items,
|
||||
maxVisible,
|
||||
settingsListTheme,
|
||||
onChange,
|
||||
onCancel,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { markdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
type ToolResultContent = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
mimeType?: string;
|
||||
bytes?: number;
|
||||
omitted?: boolean;
|
||||
};
|
||||
|
||||
type ToolResult = {
|
||||
content?: ToolResultContent[];
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const PREVIEW_LINES = 12;
|
||||
|
||||
function formatArgs(toolName: string, args: unknown): string {
|
||||
if (!args || typeof args !== "object") return "";
|
||||
const record = args as Record<string, unknown>;
|
||||
if (toolName === "bash" && typeof record.command === "string") {
|
||||
return record.command;
|
||||
}
|
||||
const path = typeof record.path === "string" ? record.path : undefined;
|
||||
if (path) return path;
|
||||
try {
|
||||
return JSON.stringify(args);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function extractText(result?: ToolResult): string {
|
||||
if (!result?.content) return "";
|
||||
const lines: string[] = [];
|
||||
for (const entry of result.content) {
|
||||
if (entry.type === "text" && entry.text) {
|
||||
lines.push(entry.text);
|
||||
} else if (entry.type === "image") {
|
||||
const mime = entry.mimeType ?? "image";
|
||||
const size = entry.bytes ? ` ${Math.round(entry.bytes / 1024)}kb` : "";
|
||||
const omitted = entry.omitted ? " (omitted)" : "";
|
||||
lines.push(`[${mime}${size}${omitted}]`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
export class ToolExecutionComponent extends Container {
|
||||
private box: Box;
|
||||
private header: Text;
|
||||
private argsLine: Text;
|
||||
private output: Markdown;
|
||||
private toolName: string;
|
||||
private args: unknown;
|
||||
private result?: ToolResult;
|
||||
private expanded = false;
|
||||
private isError = false;
|
||||
private isPartial = true;
|
||||
|
||||
constructor(toolName: string, args: unknown) {
|
||||
super();
|
||||
this.toolName = toolName;
|
||||
this.args = args;
|
||||
this.box = new Box(1, 1, (line) => theme.toolPendingBg(line));
|
||||
this.header = new Text("", 0, 0);
|
||||
this.argsLine = new Text("", 0, 0);
|
||||
this.output = new Markdown("", 0, 0, markdownTheme, {
|
||||
color: (line) => theme.toolOutput(line),
|
||||
});
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.box);
|
||||
this.box.addChild(this.header);
|
||||
this.box.addChild(this.argsLine);
|
||||
this.box.addChild(this.output);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
setArgs(args: unknown) {
|
||||
this.args = args;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
setExpanded(expanded: boolean) {
|
||||
this.expanded = expanded;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
setResult(result: ToolResult | undefined, opts?: { isError?: boolean }) {
|
||||
this.result = result;
|
||||
this.isPartial = false;
|
||||
this.isError = Boolean(opts?.isError);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
setPartialResult(result: ToolResult | undefined) {
|
||||
this.result = result;
|
||||
this.isPartial = true;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private refresh() {
|
||||
const bg = this.isPartial
|
||||
? theme.toolPendingBg
|
||||
: this.isError
|
||||
? theme.toolErrorBg
|
||||
: theme.toolSuccessBg;
|
||||
this.box.setBgFn((line) => bg(line));
|
||||
|
||||
const title = `${this.toolName}${this.isPartial ? " (running)" : ""}`;
|
||||
this.header.setText(theme.toolTitle(theme.bold(title)));
|
||||
|
||||
const argLine = formatArgs(this.toolName, this.args);
|
||||
this.argsLine.setText(argLine ? theme.dim(argLine) : theme.dim(" "));
|
||||
|
||||
const raw = extractText(this.result);
|
||||
const text = raw || (this.isPartial ? "…" : "");
|
||||
if (!this.expanded && text) {
|
||||
const lines = text.split("\n");
|
||||
const preview =
|
||||
lines.length > PREVIEW_LINES
|
||||
? `${lines.slice(0, PREVIEW_LINES).join("\n")}\n…`
|
||||
: text;
|
||||
this.output.setText(preview);
|
||||
} else {
|
||||
this.output.setText(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
|
||||
import { markdownTheme, theme } from "../theme/theme.js";
|
||||
|
||||
export class UserMessageComponent extends Container {
|
||||
private body: Markdown;
|
||||
|
||||
constructor(text: string) {
|
||||
super();
|
||||
this.body = new Markdown(text, 1, 1, markdownTheme, {
|
||||
bgColor: (line) => theme.userBg(line),
|
||||
color: (line) => theme.userText(line),
|
||||
});
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(this.body);
|
||||
}
|
||||
|
||||
setText(text: string) {
|
||||
this.body.setText(text);
|
||||
}
|
||||
}
|
||||
+64
-7
@@ -1,7 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { GatewayClient } from "../gateway/client.js";
|
||||
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
|
||||
import {
|
||||
type HelloOk,
|
||||
PROTOCOL_VERSION,
|
||||
type SessionsListParams,
|
||||
type SessionsPatchParams,
|
||||
} from "../gateway/protocol/index.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
export type GatewayConnectionOptions = {
|
||||
@@ -21,6 +26,32 @@ export type ChatSendOptions = {
|
||||
export type GatewayEvent = {
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
seq?: number;
|
||||
};
|
||||
|
||||
export type GatewaySessionList = {
|
||||
ts: number;
|
||||
path: string;
|
||||
count: number;
|
||||
defaults?: { model?: string | null; contextTokens?: number | null };
|
||||
sessions: Array<{
|
||||
key: string;
|
||||
sessionId?: string;
|
||||
updatedAt?: number | null;
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
model?: string;
|
||||
contextTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
displayName?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type GatewayModelChoice = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
contextWindow?: number;
|
||||
};
|
||||
|
||||
export class GatewayChatClient {
|
||||
@@ -28,6 +59,7 @@ export class GatewayChatClient {
|
||||
private readyPromise: Promise<void>;
|
||||
private resolveReady?: () => void;
|
||||
readonly connection: { url: string; token?: string; password?: string };
|
||||
hello?: HelloOk;
|
||||
|
||||
onEvent?: (evt: GatewayEvent) => void;
|
||||
onConnected?: () => void;
|
||||
@@ -53,12 +85,17 @@ export class GatewayChatClient {
|
||||
instanceId: randomUUID(),
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
onHelloOk: () => {
|
||||
onHelloOk: (hello) => {
|
||||
this.hello = hello;
|
||||
this.resolveReady?.();
|
||||
this.onConnected?.();
|
||||
},
|
||||
onEvent: (evt) => {
|
||||
this.onEvent?.({ event: evt.event, payload: evt.payload });
|
||||
this.onEvent?.({
|
||||
event: evt.event,
|
||||
payload: evt.payload,
|
||||
seq: evt.seq,
|
||||
});
|
||||
},
|
||||
onClose: (_code, reason) => {
|
||||
this.onDisconnected?.(reason);
|
||||
@@ -81,8 +118,7 @@ export class GatewayChatClient {
|
||||
await this.readyPromise;
|
||||
}
|
||||
|
||||
async sendChat(opts: ChatSendOptions): Promise<{ runId: string }>
|
||||
{
|
||||
async sendChat(opts: ChatSendOptions): Promise<{ runId: string }> {
|
||||
const runId = randomUUID();
|
||||
await this.client.request("chat.send", {
|
||||
sessionKey: opts.sessionKey,
|
||||
@@ -112,12 +148,33 @@ export class GatewayChatClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listSessions(opts?: { limit?: number; activeMinutes?: number }) {
|
||||
return await this.client.request("sessions.list", {
|
||||
async listSessions(opts?: SessionsListParams) {
|
||||
return await this.client.request<GatewaySessionList>("sessions.list", {
|
||||
limit: opts?.limit,
|
||||
activeMinutes: opts?.activeMinutes,
|
||||
includeGlobal: opts?.includeGlobal,
|
||||
includeUnknown: opts?.includeUnknown,
|
||||
});
|
||||
}
|
||||
|
||||
async patchSession(opts: SessionsPatchParams) {
|
||||
return await this.client.request("sessions.patch", opts);
|
||||
}
|
||||
|
||||
async resetSession(key: string) {
|
||||
return await this.client.request("sessions.reset", { key });
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
return await this.client.request("status");
|
||||
}
|
||||
|
||||
async listModels(): Promise<GatewayModelChoice[]> {
|
||||
const res = await this.client.request<{ models?: GatewayModelChoice[] }>(
|
||||
"models.list",
|
||||
);
|
||||
return Array.isArray(res?.models) ? res.models : [];
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGatewayConnection(opts: GatewayConnectionOptions) {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
|
||||
export class ChatLayout implements Component {
|
||||
constructor(
|
||||
private header: Component,
|
||||
private messages: Component,
|
||||
private status: Component,
|
||||
private input: Component,
|
||||
) {}
|
||||
|
||||
invalidate(): void {
|
||||
this.header.invalidate?.();
|
||||
this.messages.invalidate?.();
|
||||
this.status.invalidate?.();
|
||||
this.input.invalidate?.();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const rows = process.stdout.rows ?? 24;
|
||||
const headerLines = this.header.render(width);
|
||||
const statusLines = this.status.render(width);
|
||||
const inputLines = this.input.render(width);
|
||||
|
||||
const reserved = headerLines.length + statusLines.length + inputLines.length;
|
||||
const available = Math.max(rows - reserved, 0);
|
||||
|
||||
const messageLines = this.messages.render(width);
|
||||
const slicedMessages =
|
||||
available > 0
|
||||
? messageLines.slice(Math.max(0, messageLines.length - available))
|
||||
: [];
|
||||
|
||||
const lines = [...headerLines, ...slicedMessages, ...statusLines, ...inputLines];
|
||||
if (lines.length < rows) {
|
||||
const padding = Array.from({ length: rows - lines.length }, () => "");
|
||||
return [...lines, ...padding];
|
||||
}
|
||||
return lines.slice(0, rows);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import type { MarkdownTheme, DefaultTextStyle } from "@mariozechner/pi-tui";
|
||||
import { theme } from "./theme.js";
|
||||
|
||||
export class MessageList extends Container {
|
||||
private assistantById = new Map<string, Markdown>();
|
||||
|
||||
constructor(
|
||||
private markdownTheme: MarkdownTheme,
|
||||
private styles: {
|
||||
user: DefaultTextStyle;
|
||||
assistant: DefaultTextStyle;
|
||||
system: DefaultTextStyle;
|
||||
tool: DefaultTextStyle;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.assistantById.clear();
|
||||
this.clear();
|
||||
}
|
||||
|
||||
addSystem(text: string): void {
|
||||
this.addMessage("system", text, this.styles.system);
|
||||
}
|
||||
|
||||
addTool(text: string): void {
|
||||
this.addMessage("tool", text, this.styles.tool);
|
||||
}
|
||||
|
||||
addUser(text: string): void {
|
||||
this.addMessage("user", text, this.styles.user);
|
||||
}
|
||||
|
||||
addAssistant(text: string, id?: string): string {
|
||||
const messageId = id ?? crypto.randomUUID();
|
||||
const label = new Text(theme.assistant("clawd"), 1, 0);
|
||||
const body = new Markdown(text, 1, 0, this.markdownTheme, this.styles.assistant);
|
||||
const group = new Container();
|
||||
group.addChild(label);
|
||||
group.addChild(body);
|
||||
this.addChild(group);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
this.assistantById.set(messageId, body);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
updateAssistant(id: string, text: string): void {
|
||||
const component = this.assistantById.get(id);
|
||||
if (!component) return;
|
||||
component.setText(text);
|
||||
}
|
||||
|
||||
private addMessage(
|
||||
role: MessageEntry["role"],
|
||||
text: string,
|
||||
style: DefaultTextStyle,
|
||||
) {
|
||||
const messageId = crypto.randomUUID();
|
||||
const label = new Text(
|
||||
role === "user"
|
||||
? theme.user("you")
|
||||
: role === "system"
|
||||
? theme.system("system")
|
||||
: theme.dim("tool"),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
const body = new Markdown(text, 1, 0, this.markdownTheme, style);
|
||||
const group = new Container();
|
||||
group.addChild(label);
|
||||
group.addChild(body);
|
||||
this.addChild(group);
|
||||
this.addChild(new Spacer(1));
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import chalk from "chalk";
|
||||
import type { MarkdownTheme } from "@mariozechner/pi-tui";
|
||||
|
||||
export const markdownTheme: MarkdownTheme = {
|
||||
heading: (text) => chalk.bold.cyan(text),
|
||||
link: (text) => chalk.blue(text),
|
||||
linkUrl: (text) => chalk.gray(text),
|
||||
code: (text) => chalk.yellow(text),
|
||||
codeBlock: (text) => chalk.yellow(text),
|
||||
codeBlockBorder: (text) => chalk.gray(text),
|
||||
quote: (text) => chalk.gray(text),
|
||||
quoteBorder: (text) => chalk.gray(text),
|
||||
hr: (text) => chalk.gray(text),
|
||||
listBullet: (text) => chalk.cyan(text),
|
||||
bold: (text) => chalk.bold(text),
|
||||
italic: (text) => chalk.italic(text),
|
||||
strikethrough: (text) => chalk.strikethrough(text),
|
||||
underline: (text) => chalk.underline(text),
|
||||
};
|
||||
|
||||
export const theme = {
|
||||
header: (text: string) => chalk.bold.cyan(text),
|
||||
dim: (text: string) => chalk.gray(text),
|
||||
user: (text: string) => chalk.cyan(text),
|
||||
assistant: (text: string) => chalk.green(text),
|
||||
system: (text: string) => chalk.magenta(text),
|
||||
error: (text: string) => chalk.red(text),
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
EditorTheme,
|
||||
MarkdownTheme,
|
||||
SelectListTheme,
|
||||
SettingsListTheme,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
|
||||
const palette = {
|
||||
text: "#E8E3D5",
|
||||
dim: "#7B7F87",
|
||||
accent: "#F6C453",
|
||||
accentSoft: "#F2A65A",
|
||||
border: "#3C414B",
|
||||
userBg: "#2B2F36",
|
||||
userText: "#F3EEE0",
|
||||
systemText: "#9BA3B2",
|
||||
toolPendingBg: "#1F2A2F",
|
||||
toolSuccessBg: "#1E2D23",
|
||||
toolErrorBg: "#2F1F1F",
|
||||
toolTitle: "#F6C453",
|
||||
toolOutput: "#E1DACB",
|
||||
quote: "#8CC8FF",
|
||||
quoteBorder: "#3B4D6B",
|
||||
code: "#F0C987",
|
||||
codeBlock: "#1E232A",
|
||||
codeBorder: "#343A45",
|
||||
link: "#7DD3A5",
|
||||
error: "#F97066",
|
||||
success: "#7DD3A5",
|
||||
};
|
||||
|
||||
const fg = (hex: string) => (text: string) => chalk.hex(hex)(text);
|
||||
const bg = (hex: string) => (text: string) => chalk.bgHex(hex)(text);
|
||||
|
||||
export const theme = {
|
||||
fg: fg(palette.text),
|
||||
dim: fg(palette.dim),
|
||||
accent: fg(palette.accent),
|
||||
accentSoft: fg(palette.accentSoft),
|
||||
success: fg(palette.success),
|
||||
error: fg(palette.error),
|
||||
header: (text: string) => chalk.bold(fg(palette.accent)(text)),
|
||||
system: fg(palette.systemText),
|
||||
userBg: bg(palette.userBg),
|
||||
userText: fg(palette.userText),
|
||||
toolTitle: fg(palette.toolTitle),
|
||||
toolOutput: fg(palette.toolOutput),
|
||||
toolPendingBg: bg(palette.toolPendingBg),
|
||||
toolSuccessBg: bg(palette.toolSuccessBg),
|
||||
toolErrorBg: bg(palette.toolErrorBg),
|
||||
border: fg(palette.border),
|
||||
bold: (text: string) => chalk.bold(text),
|
||||
italic: (text: string) => chalk.italic(text),
|
||||
};
|
||||
|
||||
export const markdownTheme: MarkdownTheme = {
|
||||
heading: (text) => chalk.bold(fg(palette.accent)(text)),
|
||||
link: (text) => fg(palette.link)(text),
|
||||
linkUrl: (text) => chalk.dim(text),
|
||||
code: (text) => fg(palette.code)(text),
|
||||
codeBlock: (text) => fg(palette.code)(text),
|
||||
codeBlockBorder: (text) => fg(palette.codeBorder)(text),
|
||||
quote: (text) => fg(palette.quote)(text),
|
||||
quoteBorder: (text) => fg(palette.quoteBorder)(text),
|
||||
hr: (text) => fg(palette.border)(text),
|
||||
listBullet: (text) => fg(palette.accentSoft)(text),
|
||||
bold: (text) => chalk.bold(text),
|
||||
italic: (text) => chalk.italic(text),
|
||||
strikethrough: (text) => chalk.strikethrough(text),
|
||||
underline: (text) => chalk.underline(text),
|
||||
};
|
||||
|
||||
export const selectListTheme: SelectListTheme = {
|
||||
selectedPrefix: (text) => fg(palette.accent)(text),
|
||||
selectedText: (text) => chalk.bold(fg(palette.accent)(text)),
|
||||
description: (text) => fg(palette.dim)(text),
|
||||
scrollInfo: (text) => fg(palette.dim)(text),
|
||||
noMatch: (text) => fg(palette.dim)(text),
|
||||
};
|
||||
|
||||
export const settingsListTheme: SettingsListTheme = {
|
||||
label: (text, selected) =>
|
||||
selected ? chalk.bold(fg(palette.accent)(text)) : fg(palette.text)(text),
|
||||
value: (text, selected) =>
|
||||
selected ? fg(palette.accentSoft)(text) : fg(palette.dim)(text),
|
||||
description: (text) => fg(palette.systemText)(text),
|
||||
cursor: fg(palette.accent)("→ "),
|
||||
hint: (text) => fg(palette.dim)(text),
|
||||
};
|
||||
|
||||
export const editorTheme: EditorTheme = {
|
||||
borderColor: (text) => fg(palette.border)(text),
|
||||
selectList: selectListTheme,
|
||||
};
|
||||
+602
-171
@@ -1,17 +1,21 @@
|
||||
import {
|
||||
CombinedAutocompleteProvider,
|
||||
type Component,
|
||||
Input,
|
||||
Container,
|
||||
ProcessTerminal,
|
||||
Text,
|
||||
TUI,
|
||||
isCtrlC,
|
||||
isEscape,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { getSlashCommands, helpText, parseCommand } from "./commands.js";
|
||||
import { ChatLog } from "./components/chat-log.js";
|
||||
import { CustomEditor } from "./components/custom-editor.js";
|
||||
import {
|
||||
createSelectList,
|
||||
createSettingsList,
|
||||
} from "./components/selectors.js";
|
||||
import { GatewayChatClient } from "./gateway-chat.js";
|
||||
import { ChatLayout } from "./layout.js";
|
||||
import { MessageList } from "./message-list.js";
|
||||
import { markdownTheme, theme } from "./theme.js";
|
||||
import { editorTheme, theme } from "./theme/theme.js";
|
||||
|
||||
export type TuiOptions = {
|
||||
url?: string;
|
||||
@@ -32,105 +36,88 @@ type ChatEvent = {
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
class InputWrapper implements Component {
|
||||
constructor(
|
||||
private input: Input,
|
||||
private onAbort: () => void,
|
||||
private onExit: () => void,
|
||||
) {
|
||||
}
|
||||
type AgentEvent = {
|
||||
runId: string;
|
||||
stream: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (isCtrlC(data)) {
|
||||
this.onExit();
|
||||
return;
|
||||
type SessionInfo = {
|
||||
thinkingLevel?: string;
|
||||
verboseLevel?: string;
|
||||
model?: string;
|
||||
contextTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
updatedAt?: number | null;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
function extractTextBlocks(
|
||||
content: unknown,
|
||||
opts?: { includeThinking?: boolean },
|
||||
): string {
|
||||
if (typeof content === "string") return content.trim();
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") continue;
|
||||
const record = block as Record<string, unknown>;
|
||||
if (record.type === "text" && typeof record.text === "string") {
|
||||
parts.push(record.text);
|
||||
}
|
||||
if (isEscape(data)) {
|
||||
this.onAbort();
|
||||
return;
|
||||
if (
|
||||
opts?.includeThinking &&
|
||||
record.type === "thinking" &&
|
||||
typeof record.thinking === "string"
|
||||
) {
|
||||
parts.push(`[thinking]\n${record.thinking}`);
|
||||
}
|
||||
this.input.handleInput(data);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.input.render(width);
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.input.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
function extractText(message?: unknown): string {
|
||||
if (!message || typeof message !== "object") return "";
|
||||
const record = message as Record<string, unknown>;
|
||||
const content = Array.isArray(record.content) ? record.content : [];
|
||||
const parts = content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object") return "";
|
||||
const b = block as Record<string, unknown>;
|
||||
if (b.type === "text" && typeof b.text === "string") return b.text;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
|
||||
function renderHistoryEntry(entry: unknown): { role: "user" | "assistant"; text: string } | null {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
const record = entry as Record<string, unknown>;
|
||||
const role = record.role === "user" || record.role === "assistant" ? record.role : null;
|
||||
if (!role) return null;
|
||||
const text = extractText(record);
|
||||
if (!text) return null;
|
||||
return { role, text };
|
||||
function extractTextFromMessage(
|
||||
message: unknown,
|
||||
opts?: { includeThinking?: boolean },
|
||||
): string {
|
||||
if (!message || typeof message !== "object") return "";
|
||||
const record = message as Record<string, unknown>;
|
||||
return extractTextBlocks(record.content, opts);
|
||||
}
|
||||
|
||||
function formatTokens(total?: number | null, context?: number | null) {
|
||||
if (!total && !context) return "tokens ?";
|
||||
if (!context) return `tokens ${total ?? 0}`;
|
||||
const pct =
|
||||
typeof total === "number" && context > 0
|
||||
? Math.min(999, Math.round((total / context) * 100))
|
||||
: null;
|
||||
return `tokens ${total ?? 0}/${context}${pct !== null ? ` (${pct}%)` : ""}`;
|
||||
}
|
||||
|
||||
function asString(value: unknown, fallback = ""): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function runTui(opts: TuiOptions) {
|
||||
const config = loadConfig();
|
||||
const defaultSession =
|
||||
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main";
|
||||
let currentSession = defaultSession;
|
||||
let activeRunId: string | null = null;
|
||||
let streamingMessageId: string | null = null;
|
||||
let currentSessionKey = defaultSession;
|
||||
let currentSessionId: string | null = null;
|
||||
let activeChatRunId: string | null = null;
|
||||
const finalizedRuns = new Map<string, number>();
|
||||
let historyLoaded = false;
|
||||
|
||||
const messages = new MessageList(markdownTheme, {
|
||||
user: { color: theme.user },
|
||||
assistant: { color: theme.assistant },
|
||||
system: { color: theme.system, italic: true },
|
||||
tool: { color: theme.dim, italic: true },
|
||||
});
|
||||
|
||||
const header = new Text("", 1, 0);
|
||||
const status = new Text("", 1, 0);
|
||||
const input = new Input();
|
||||
|
||||
const tui = new TUI(new ProcessTerminal());
|
||||
const inputWrapper = new InputWrapper(
|
||||
input,
|
||||
async () => {
|
||||
if (!activeRunId) return;
|
||||
try {
|
||||
await client.abortChat({ sessionKey: currentSession, runId: activeRunId });
|
||||
} catch (err) {
|
||||
messages.addSystem(`Abort failed: ${String(err)}`);
|
||||
}
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
setStatus("aborted");
|
||||
tui.requestRender();
|
||||
},
|
||||
() => {
|
||||
client.stop();
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
},
|
||||
);
|
||||
|
||||
const layout = new ChatLayout(header, messages, status, inputWrapper);
|
||||
tui.addChild(layout);
|
||||
tui.setFocus(inputWrapper);
|
||||
let isConnected = false;
|
||||
let toolsExpanded = false;
|
||||
let showThinking = false;
|
||||
let deliverDefault = Boolean(opts.deliver);
|
||||
let sessionInfo: SessionInfo = {};
|
||||
let lastCtrlCAt = 0;
|
||||
|
||||
const client = new GatewayChatClient({
|
||||
url: opts.url,
|
||||
@@ -138,10 +125,28 @@ export async function runTui(opts: TuiOptions) {
|
||||
password: opts.password,
|
||||
});
|
||||
|
||||
const header = new Text("", 1, 0);
|
||||
const status = new Text("", 1, 0);
|
||||
const footer = new Text("", 1, 0);
|
||||
const chatLog = new ChatLog();
|
||||
const editor = new CustomEditor(editorTheme);
|
||||
const overlay = new Container();
|
||||
const root = new Container();
|
||||
root.addChild(header);
|
||||
root.addChild(overlay);
|
||||
root.addChild(chatLog);
|
||||
root.addChild(status);
|
||||
root.addChild(footer);
|
||||
root.addChild(editor);
|
||||
|
||||
const tui = new TUI(new ProcessTerminal());
|
||||
tui.addChild(root);
|
||||
tui.setFocus(editor);
|
||||
|
||||
const updateHeader = () => {
|
||||
header.setText(
|
||||
theme.header(
|
||||
`clawdis tui - ${client.connection.url} - session ${currentSession}`,
|
||||
`clawdis tui - ${client.connection.url} - session ${currentSessionKey}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -150,112 +155,486 @@ export async function runTui(opts: TuiOptions) {
|
||||
status.setText(theme.dim(text));
|
||||
};
|
||||
|
||||
const updateFooter = () => {
|
||||
const connection = isConnected ? "connected" : "disconnected";
|
||||
const sessionLabel = sessionInfo.displayName
|
||||
? `${currentSessionKey} (${sessionInfo.displayName})`
|
||||
: currentSessionKey;
|
||||
const modelLabel = sessionInfo.model ?? "unknown";
|
||||
const tokens = formatTokens(
|
||||
sessionInfo.totalTokens ?? null,
|
||||
sessionInfo.contextTokens ?? null,
|
||||
);
|
||||
const think = sessionInfo.thinkingLevel ?? "off";
|
||||
const verbose = sessionInfo.verboseLevel ?? "off";
|
||||
const deliver = deliverDefault ? "on" : "off";
|
||||
footer.setText(
|
||||
theme.dim(
|
||||
`${connection} | session ${sessionLabel} | model ${modelLabel} | think ${think} | verbose ${verbose} | ${tokens} | deliver ${deliver}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const closeOverlay = () => {
|
||||
overlay.clear();
|
||||
tui.setFocus(editor);
|
||||
};
|
||||
|
||||
const openOverlay = (component: Component) => {
|
||||
overlay.clear();
|
||||
overlay.addChild(component);
|
||||
tui.setFocus(component);
|
||||
};
|
||||
|
||||
const refreshSessionInfo = async () => {
|
||||
try {
|
||||
const result = await client.listSessions({
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
});
|
||||
const entry = result.sessions.find(
|
||||
(row) => row.key === currentSessionKey,
|
||||
);
|
||||
sessionInfo = {
|
||||
thinkingLevel: entry?.thinkingLevel,
|
||||
verboseLevel: entry?.verboseLevel,
|
||||
model: entry?.model ?? result.defaults?.model ?? undefined,
|
||||
contextTokens: entry?.contextTokens ?? result.defaults?.contextTokens,
|
||||
totalTokens: entry?.totalTokens ?? null,
|
||||
updatedAt: entry?.updatedAt ?? null,
|
||||
displayName: entry?.displayName,
|
||||
};
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`sessions list failed: ${String(err)}`);
|
||||
}
|
||||
updateFooter();
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const history = await client.loadHistory({
|
||||
sessionKey: currentSession,
|
||||
sessionKey: currentSessionKey,
|
||||
limit: opts.historyLimit ?? 200,
|
||||
});
|
||||
const historyRecord = history as { messages?: unknown[] } | undefined;
|
||||
messages.clearAll();
|
||||
messages.addSystem(`session ${currentSession}`);
|
||||
for (const entry of historyRecord?.messages ?? []) {
|
||||
const parsed = renderHistoryEntry(entry);
|
||||
if (!parsed) continue;
|
||||
if (parsed.role === "user") messages.addUser(parsed.text);
|
||||
if (parsed.role === "assistant") messages.addAssistant(parsed.text);
|
||||
const record = history as {
|
||||
messages?: unknown[];
|
||||
sessionId?: string;
|
||||
thinkingLevel?: string;
|
||||
};
|
||||
currentSessionId =
|
||||
typeof record.sessionId === "string" ? record.sessionId : null;
|
||||
sessionInfo.thinkingLevel =
|
||||
record.thinkingLevel ?? sessionInfo.thinkingLevel;
|
||||
chatLog.clearAll();
|
||||
chatLog.addSystem(`session ${currentSessionKey}`);
|
||||
for (const entry of record.messages ?? []) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const message = entry as Record<string, unknown>;
|
||||
if (message.role === "user") {
|
||||
const text = extractTextFromMessage(message);
|
||||
if (text) chatLog.addUser(text);
|
||||
continue;
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
const text = extractTextFromMessage(message, {
|
||||
includeThinking: showThinking,
|
||||
});
|
||||
if (text) chatLog.finalizeAssistant(text);
|
||||
continue;
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
const toolCallId = asString(message.toolCallId, "");
|
||||
const toolName = asString(message.toolName, "tool");
|
||||
const component = chatLog.startTool(toolCallId, toolName, {});
|
||||
component.setResult(
|
||||
{
|
||||
content: Array.isArray(message.content)
|
||||
? (message.content as Record<string, unknown>[])
|
||||
: [],
|
||||
details:
|
||||
typeof message.details === "object" && message.details
|
||||
? (message.details as Record<string, unknown>)
|
||||
: undefined,
|
||||
},
|
||||
{ isError: Boolean(message.isError) },
|
||||
);
|
||||
}
|
||||
}
|
||||
historyLoaded = true;
|
||||
tui.requestRender();
|
||||
} catch (err) {
|
||||
messages.addSystem(`history failed: ${String(err)}`);
|
||||
chatLog.addSystem(`history failed: ${String(err)}`);
|
||||
}
|
||||
await refreshSessionInfo();
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const setSession = async (key: string) => {
|
||||
currentSessionKey = key;
|
||||
activeChatRunId = null;
|
||||
currentSessionId = null;
|
||||
historyLoaded = false;
|
||||
updateHeader();
|
||||
await loadHistory();
|
||||
};
|
||||
|
||||
const abortActive = async () => {
|
||||
if (!activeChatRunId) {
|
||||
chatLog.addSystem("no active run");
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.abortChat({
|
||||
sessionKey: currentSessionKey,
|
||||
runId: activeChatRunId,
|
||||
});
|
||||
setStatus("aborted");
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`abort failed: ${String(err)}`);
|
||||
setStatus("abort failed");
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const noteFinalizedRun = (runId: string) => {
|
||||
finalizedRuns.set(runId, Date.now());
|
||||
if (finalizedRuns.size <= 200) return;
|
||||
const keepUntil = Date.now() - 10 * 60 * 1000;
|
||||
for (const [key, ts] of finalizedRuns) {
|
||||
if (finalizedRuns.size <= 150) break;
|
||||
if (ts < keepUntil) finalizedRuns.delete(key);
|
||||
}
|
||||
if (finalizedRuns.size > 200) {
|
||||
for (const key of finalizedRuns.keys()) {
|
||||
finalizedRuns.delete(key);
|
||||
if (finalizedRuns.size <= 150) break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatEvent = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const evt = payload as ChatEvent;
|
||||
if (evt.sessionKey !== currentSession) return;
|
||||
|
||||
if (evt.sessionKey !== currentSessionKey) return;
|
||||
if (finalizedRuns.has(evt.runId)) {
|
||||
if (evt.state === "delta") return;
|
||||
if (evt.state === "final") return;
|
||||
}
|
||||
if (evt.state === "delta") {
|
||||
const text = extractText(evt.message);
|
||||
const text = extractTextFromMessage(evt.message, {
|
||||
includeThinking: showThinking,
|
||||
});
|
||||
if (!text) return;
|
||||
if (!streamingMessageId || activeRunId !== evt.runId) {
|
||||
streamingMessageId = messages.addAssistant(text, evt.runId);
|
||||
activeRunId = evt.runId;
|
||||
} else {
|
||||
messages.updateAssistant(streamingMessageId, text);
|
||||
}
|
||||
chatLog.updateAssistant(text, evt.runId);
|
||||
setStatus("streaming");
|
||||
}
|
||||
|
||||
if (evt.state === "final") {
|
||||
const text = extractText(evt.message);
|
||||
if (streamingMessageId && activeRunId === evt.runId) {
|
||||
messages.updateAssistant(streamingMessageId, text || "(no output)");
|
||||
} else if (text) {
|
||||
messages.addAssistant(text, evt.runId);
|
||||
}
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
const text = extractTextFromMessage(evt.message, {
|
||||
includeThinking: showThinking,
|
||||
});
|
||||
chatLog.finalizeAssistant(text || "(no output)", evt.runId);
|
||||
noteFinalizedRun(evt.runId);
|
||||
activeChatRunId = null;
|
||||
setStatus("idle");
|
||||
}
|
||||
|
||||
if (evt.state === "aborted") {
|
||||
messages.addSystem("run aborted");
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
chatLog.addSystem("run aborted");
|
||||
activeChatRunId = null;
|
||||
setStatus("aborted");
|
||||
}
|
||||
|
||||
if (evt.state === "error") {
|
||||
messages.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
||||
activeChatRunId = null;
|
||||
setStatus("error");
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const handleAgentEvent = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const evt = payload as AgentEvent;
|
||||
if (!currentSessionId || evt.runId !== currentSessionId) return;
|
||||
if (evt.stream === "tool") {
|
||||
const data = evt.data ?? {};
|
||||
const phase = asString(data.phase, "");
|
||||
const toolCallId = asString(data.toolCallId, "");
|
||||
const toolName = asString(data.name, "tool");
|
||||
if (!toolCallId) return;
|
||||
if (phase === "start") {
|
||||
chatLog.startTool(toolCallId, toolName, data.args);
|
||||
} else if (phase === "update") {
|
||||
chatLog.updateToolResult(toolCallId, data.partialResult, {
|
||||
partial: true,
|
||||
});
|
||||
} else if (phase === "result") {
|
||||
chatLog.updateToolResult(toolCallId, data.result, {
|
||||
isError: Boolean(data.isError),
|
||||
});
|
||||
}
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (evt.stream === "job") {
|
||||
const state = typeof evt.data?.state === "string" ? evt.data.state : "";
|
||||
if (state === "started") setStatus("running");
|
||||
if (state === "done") setStatus("idle");
|
||||
if (state === "error") setStatus("error");
|
||||
tui.requestRender();
|
||||
}
|
||||
};
|
||||
|
||||
const openModelSelector = async () => {
|
||||
try {
|
||||
const models = await client.listModels();
|
||||
if (models.length === 0) {
|
||||
chatLog.addSystem("no models available");
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
const items = models.map((model) => ({
|
||||
value: `${model.provider}/${model.id}`,
|
||||
label: `${model.provider}/${model.id}`,
|
||||
description: model.name && model.name !== model.id ? model.name : "",
|
||||
}));
|
||||
const selector = createSelectList(items, 9);
|
||||
selector.onSelect = (item) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await client.patchSession({
|
||||
key: currentSessionKey,
|
||||
model: item.value,
|
||||
});
|
||||
chatLog.addSystem(`model set to ${item.value}`);
|
||||
await refreshSessionInfo();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`model set failed: ${String(err)}`);
|
||||
}
|
||||
closeOverlay();
|
||||
tui.requestRender();
|
||||
})();
|
||||
};
|
||||
selector.onCancel = () => {
|
||||
closeOverlay();
|
||||
tui.requestRender();
|
||||
};
|
||||
openOverlay(selector);
|
||||
tui.requestRender();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`model list failed: ${String(err)}`);
|
||||
tui.requestRender();
|
||||
}
|
||||
};
|
||||
|
||||
const openSessionSelector = async () => {
|
||||
try {
|
||||
const result = await client.listSessions({
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
});
|
||||
const items = result.sessions.map((session) => ({
|
||||
value: session.key,
|
||||
label: session.displayName ?? session.key,
|
||||
description: session.updatedAt
|
||||
? new Date(session.updatedAt).toLocaleString()
|
||||
: "",
|
||||
}));
|
||||
const selector = createSelectList(items, 9);
|
||||
selector.onSelect = (item) => {
|
||||
void (async () => {
|
||||
closeOverlay();
|
||||
await setSession(item.value);
|
||||
tui.requestRender();
|
||||
})();
|
||||
};
|
||||
selector.onCancel = () => {
|
||||
closeOverlay();
|
||||
tui.requestRender();
|
||||
};
|
||||
openOverlay(selector);
|
||||
tui.requestRender();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`sessions list failed: ${String(err)}`);
|
||||
tui.requestRender();
|
||||
}
|
||||
};
|
||||
|
||||
const openSettings = () => {
|
||||
const items = [
|
||||
{
|
||||
id: "deliver",
|
||||
label: "Deliver replies",
|
||||
currentValue: deliverDefault ? "on" : "off",
|
||||
values: ["off", "on"],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tool output",
|
||||
currentValue: toolsExpanded ? "expanded" : "collapsed",
|
||||
values: ["collapsed", "expanded"],
|
||||
},
|
||||
{
|
||||
id: "thinking",
|
||||
label: "Show thinking",
|
||||
currentValue: showThinking ? "on" : "off",
|
||||
values: ["off", "on"],
|
||||
},
|
||||
];
|
||||
const settings = createSettingsList(
|
||||
items,
|
||||
(id, value) => {
|
||||
if (id === "deliver") {
|
||||
deliverDefault = value === "on";
|
||||
updateFooter();
|
||||
}
|
||||
if (id === "tools") {
|
||||
toolsExpanded = value === "expanded";
|
||||
chatLog.setToolsExpanded(toolsExpanded);
|
||||
}
|
||||
if (id === "thinking") {
|
||||
showThinking = value === "on";
|
||||
void loadHistory();
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
() => {
|
||||
closeOverlay();
|
||||
tui.requestRender();
|
||||
},
|
||||
);
|
||||
openOverlay(settings);
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
const handleCommand = async (raw: string) => {
|
||||
const [command, ...rest] = raw.slice(1).trim().split(/\s+/);
|
||||
const arg = rest.join(" ").trim();
|
||||
switch (command) {
|
||||
case "help": {
|
||||
messages.addSystem("/help /session <key> /abort /exit");
|
||||
const { name, args } = parseCommand(raw);
|
||||
if (!name) return;
|
||||
switch (name) {
|
||||
case "help":
|
||||
chatLog.addSystem(helpText());
|
||||
break;
|
||||
}
|
||||
case "session": {
|
||||
if (!arg) {
|
||||
messages.addSystem("missing session key");
|
||||
case "status":
|
||||
try {
|
||||
const status = await client.getStatus();
|
||||
chatLog.addSystem(
|
||||
typeof status === "string"
|
||||
? status
|
||||
: JSON.stringify(status, null, 2),
|
||||
);
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`status failed: ${String(err)}`);
|
||||
}
|
||||
break;
|
||||
case "session":
|
||||
if (!args) {
|
||||
await openSessionSelector();
|
||||
} else {
|
||||
await setSession(args);
|
||||
}
|
||||
break;
|
||||
case "sessions":
|
||||
await openSessionSelector();
|
||||
break;
|
||||
case "model":
|
||||
if (!args) {
|
||||
await openModelSelector();
|
||||
} else {
|
||||
try {
|
||||
await client.patchSession({
|
||||
key: currentSessionKey,
|
||||
model: args,
|
||||
});
|
||||
chatLog.addSystem(`model set to ${args}`);
|
||||
await refreshSessionInfo();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`model set failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "models":
|
||||
await openModelSelector();
|
||||
break;
|
||||
case "think":
|
||||
if (!args) {
|
||||
chatLog.addSystem("usage: /think <off|minimal|low|medium|high>");
|
||||
break;
|
||||
}
|
||||
currentSession = arg;
|
||||
activeRunId = null;
|
||||
streamingMessageId = null;
|
||||
historyLoaded = false;
|
||||
updateHeader();
|
||||
await loadHistory();
|
||||
try {
|
||||
await client.patchSession({
|
||||
key: currentSessionKey,
|
||||
thinkingLevel: args,
|
||||
});
|
||||
chatLog.addSystem(`thinking set to ${args}`);
|
||||
await refreshSessionInfo();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`think failed: ${String(err)}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "abort": {
|
||||
if (!activeRunId) {
|
||||
messages.addSystem("no active run");
|
||||
case "verbose":
|
||||
if (!args) {
|
||||
chatLog.addSystem("usage: /verbose <on|off>");
|
||||
break;
|
||||
}
|
||||
await client.abortChat({ sessionKey: currentSession, runId: activeRunId });
|
||||
try {
|
||||
await client.patchSession({
|
||||
key: currentSessionKey,
|
||||
verboseLevel: args,
|
||||
});
|
||||
chatLog.addSystem(`verbose set to ${args}`);
|
||||
await refreshSessionInfo();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`verbose failed: ${String(err)}`);
|
||||
}
|
||||
break;
|
||||
case "activation":
|
||||
if (!args) {
|
||||
chatLog.addSystem("usage: /activation <mention|always>");
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await client.patchSession({
|
||||
key: currentSessionKey,
|
||||
groupActivation: args === "always" ? "always" : "mention",
|
||||
});
|
||||
chatLog.addSystem(`activation set to ${args}`);
|
||||
await refreshSessionInfo();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`activation failed: ${String(err)}`);
|
||||
}
|
||||
break;
|
||||
case "deliver":
|
||||
if (!args) {
|
||||
chatLog.addSystem("usage: /deliver <on|off>");
|
||||
break;
|
||||
}
|
||||
deliverDefault = args === "on";
|
||||
updateFooter();
|
||||
chatLog.addSystem(`deliver ${deliverDefault ? "on" : "off"}`);
|
||||
break;
|
||||
case "new":
|
||||
case "reset":
|
||||
try {
|
||||
await client.resetSession(currentSessionKey);
|
||||
chatLog.addSystem(`session ${currentSessionKey} reset`);
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`reset failed: ${String(err)}`);
|
||||
}
|
||||
break;
|
||||
case "abort":
|
||||
await abortActive();
|
||||
break;
|
||||
case "settings":
|
||||
openSettings();
|
||||
break;
|
||||
}
|
||||
case "exit":
|
||||
case "quit": {
|
||||
case "quit":
|
||||
client.stop();
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
messages.addSystem(`unknown command: /${command}`);
|
||||
chatLog.addSystem(`unknown command: /${name}`);
|
||||
break;
|
||||
}
|
||||
tui.requestRender();
|
||||
@@ -263,69 +642,121 @@ export async function runTui(opts: TuiOptions) {
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
try {
|
||||
messages.addUser(text);
|
||||
chatLog.addUser(text);
|
||||
tui.requestRender();
|
||||
setStatus("sending");
|
||||
const { runId } = await client.sendChat({
|
||||
sessionKey: currentSession,
|
||||
sessionKey: currentSessionKey,
|
||||
message: text,
|
||||
thinking: opts.thinking,
|
||||
deliver: opts.deliver,
|
||||
deliver: deliverDefault,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
activeRunId = runId;
|
||||
streamingMessageId = null;
|
||||
activeChatRunId = runId;
|
||||
setStatus("waiting");
|
||||
} catch (err) {
|
||||
messages.addSystem(`send failed: ${String(err)}`);
|
||||
chatLog.addSystem(`send failed: ${String(err)}`);
|
||||
setStatus("error");
|
||||
}
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
input.onSubmit = (value) => {
|
||||
const text = value.trim();
|
||||
input.setValue("");
|
||||
if (!text) return;
|
||||
if (text.startsWith("/")) {
|
||||
void handleCommand(text);
|
||||
editor.setAutocompleteProvider(
|
||||
new CombinedAutocompleteProvider(getSlashCommands(), process.cwd()),
|
||||
);
|
||||
editor.onSubmit = (text) => {
|
||||
const value = text.trim();
|
||||
editor.setText("");
|
||||
if (!value) return;
|
||||
if (value.startsWith("/")) {
|
||||
void handleCommand(value);
|
||||
return;
|
||||
}
|
||||
void sendMessage(text);
|
||||
void sendMessage(value);
|
||||
};
|
||||
|
||||
editor.onEscape = () => {
|
||||
void abortActive();
|
||||
};
|
||||
editor.onCtrlC = () => {
|
||||
const now = Date.now();
|
||||
if (editor.getText().trim().length > 0) {
|
||||
editor.setText("");
|
||||
setStatus("cleared input");
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (now - lastCtrlCAt < 1000) {
|
||||
client.stop();
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
lastCtrlCAt = now;
|
||||
setStatus("press ctrl+c again to exit");
|
||||
tui.requestRender();
|
||||
};
|
||||
editor.onCtrlD = () => {
|
||||
client.stop();
|
||||
tui.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
editor.onCtrlO = () => {
|
||||
toolsExpanded = !toolsExpanded;
|
||||
chatLog.setToolsExpanded(toolsExpanded);
|
||||
setStatus(toolsExpanded ? "tools expanded" : "tools collapsed");
|
||||
tui.requestRender();
|
||||
};
|
||||
editor.onCtrlL = () => {
|
||||
void openModelSelector();
|
||||
};
|
||||
editor.onCtrlP = () => {
|
||||
void openSessionSelector();
|
||||
};
|
||||
editor.onCtrlT = () => {
|
||||
showThinking = !showThinking;
|
||||
void loadHistory();
|
||||
};
|
||||
|
||||
client.onEvent = (evt) => {
|
||||
if (evt.event === "chat") handleChatEvent(evt.payload);
|
||||
if (evt.event === "agent") handleAgentEvent(evt.payload);
|
||||
};
|
||||
|
||||
client.onConnected = () => {
|
||||
isConnected = true;
|
||||
setStatus("connected");
|
||||
updateHeader();
|
||||
if (!historyLoaded) {
|
||||
void loadHistory().then(() => {
|
||||
messages.addSystem("gateway connected");
|
||||
chatLog.addSystem("gateway connected");
|
||||
tui.requestRender();
|
||||
});
|
||||
} else {
|
||||
messages.addSystem("gateway reconnected");
|
||||
chatLog.addSystem("gateway reconnected");
|
||||
}
|
||||
updateFooter();
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
client.onDisconnected = (reason) => {
|
||||
messages.addSystem(`gateway disconnected: ${reason || "closed"}`);
|
||||
isConnected = false;
|
||||
chatLog.addSystem(`gateway disconnected: ${reason || "closed"}`);
|
||||
setStatus("disconnected");
|
||||
updateFooter();
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
client.onGap = (info) => {
|
||||
messages.addSystem(`event gap: expected ${info.expected}, got ${info.received}`);
|
||||
chatLog.addSystem(
|
||||
`event gap: expected ${info.expected}, got ${info.received}`,
|
||||
);
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
updateHeader();
|
||||
setStatus("connecting");
|
||||
messages.addSystem("connecting...");
|
||||
updateFooter();
|
||||
chatLog.addSystem("connecting...");
|
||||
tui.start();
|
||||
client.start();
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(),
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
resolveEmbeddedSessionLane: (key: string) =>
|
||||
|
||||
@@ -210,6 +210,10 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field.full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
|
||||
+24
-8
@@ -34,7 +34,12 @@ import {
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
} from "./ui-types";
|
||||
import { loadChatHistory, sendChat, handleChatEvent } from "./controllers/chat";
|
||||
import {
|
||||
loadChatHistory,
|
||||
sendChat,
|
||||
handleChatEvent,
|
||||
type ChatEventPayload,
|
||||
} from "./controllers/chat";
|
||||
import { loadNodes } from "./controllers/nodes";
|
||||
import { loadConfig } from "./controllers/config";
|
||||
import {
|
||||
@@ -139,11 +144,15 @@ export class ClawdisApp extends LitElement {
|
||||
@state() discordForm: DiscordForm = {
|
||||
enabled: true,
|
||||
token: "",
|
||||
dmEnabled: true,
|
||||
allowFrom: "",
|
||||
groupEnabled: false,
|
||||
groupChannels: "",
|
||||
mediaMaxMb: "",
|
||||
historyLimit: "",
|
||||
textChunkLimit: "",
|
||||
replyToMode: "off",
|
||||
guilds: [],
|
||||
actions: { ...defaultDiscordActions },
|
||||
slashEnabled: false,
|
||||
slashName: "",
|
||||
@@ -350,7 +359,8 @@ export class ClawdisApp extends LitElement {
|
||||
].slice(0, 250);
|
||||
|
||||
if (evt.event === "chat") {
|
||||
const state = handleChatEvent(this, evt.payload as unknown);
|
||||
const payload = evt.payload as ChatEventPayload | undefined;
|
||||
const state = handleChatEvent(this, payload);
|
||||
if (state === "final") void loadChatHistory(this);
|
||||
return;
|
||||
}
|
||||
@@ -469,20 +479,26 @@ export class ClawdisApp extends LitElement {
|
||||
if (this.theme !== "system") return;
|
||||
this.applyResolvedTheme(event.matches ? "dark" : "light");
|
||||
};
|
||||
if ("addEventListener" in this.themeMedia) {
|
||||
if (typeof this.themeMedia.addEventListener === "function") {
|
||||
this.themeMedia.addEventListener("change", this.themeMediaHandler);
|
||||
} else {
|
||||
this.themeMedia.addListener(this.themeMediaHandler);
|
||||
return;
|
||||
}
|
||||
const legacy = this.themeMedia as MediaQueryList & {
|
||||
addListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||
};
|
||||
legacy.addListener(this.themeMediaHandler);
|
||||
}
|
||||
|
||||
private detachThemeListener() {
|
||||
if (!this.themeMedia || !this.themeMediaHandler) return;
|
||||
if ("removeEventListener" in this.themeMedia) {
|
||||
if (typeof this.themeMedia.removeEventListener === "function") {
|
||||
this.themeMedia.removeEventListener("change", this.themeMediaHandler);
|
||||
} else {
|
||||
this.themeMedia.removeListener(this.themeMediaHandler);
|
||||
return;
|
||||
}
|
||||
const legacy = this.themeMedia as MediaQueryList & {
|
||||
removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||
};
|
||||
legacy.removeListener(this.themeMediaHandler);
|
||||
this.themeMedia = null;
|
||||
this.themeMediaHandler = null;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export type ChatState = {
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
type ChatEventPayload = {
|
||||
export type ChatEventPayload = {
|
||||
runId: string;
|
||||
sessionKey: string;
|
||||
state: "delta" | "final" | "aborted" | "error";
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
defaultDiscordActions,
|
||||
type DiscordActionForm,
|
||||
type DiscordForm,
|
||||
type DiscordGuildChannelForm,
|
||||
type DiscordGuildForm,
|
||||
type IMessageForm,
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
@@ -96,6 +98,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
const discordDm = (discord.dm ?? {}) as Record<string, unknown>;
|
||||
const slash = (discord.slashCommand ?? {}) as Record<string, unknown>;
|
||||
const discordActions = (discord.actions ?? {}) as Record<string, unknown>;
|
||||
const discordGuilds = discord.guilds;
|
||||
const readAction = (key: keyof DiscordActionForm) =>
|
||||
typeof discordActions[key] === "boolean"
|
||||
? (discordActions[key] as boolean)
|
||||
@@ -103,6 +106,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
state.discordForm = {
|
||||
enabled: typeof discord.enabled === "boolean" ? discord.enabled : true,
|
||||
token: typeof discord.token === "string" ? discord.token : "",
|
||||
dmEnabled: typeof discordDm.enabled === "boolean" ? discordDm.enabled : true,
|
||||
allowFrom: toList(discordDm.allowFrom),
|
||||
groupEnabled:
|
||||
typeof discordDm.groupEnabled === "boolean" ? discordDm.groupEnabled : false,
|
||||
@@ -111,6 +115,57 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
typeof discord.mediaMaxMb === "number" ? String(discord.mediaMaxMb) : "",
|
||||
historyLimit:
|
||||
typeof discord.historyLimit === "number" ? String(discord.historyLimit) : "",
|
||||
textChunkLimit:
|
||||
typeof discord.textChunkLimit === "number"
|
||||
? String(discord.textChunkLimit)
|
||||
: "",
|
||||
replyToMode:
|
||||
discord.replyToMode === "first" || discord.replyToMode === "all"
|
||||
? discord.replyToMode
|
||||
: "off",
|
||||
guilds: Array.isArray(discordGuilds)
|
||||
? []
|
||||
: typeof discordGuilds === "object" && discordGuilds
|
||||
? Object.entries(discordGuilds as Record<string, unknown>).map(
|
||||
([key, value]): DiscordGuildForm => {
|
||||
const entry =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const channelsRaw =
|
||||
entry.channels && typeof entry.channels === "object"
|
||||
? (entry.channels as Record<string, unknown>)
|
||||
: {};
|
||||
const channels = Object.entries(channelsRaw).map(
|
||||
([channelKey, channelValue]): DiscordGuildChannelForm => {
|
||||
const channel =
|
||||
channelValue && typeof channelValue === "object"
|
||||
? (channelValue as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
key: channelKey,
|
||||
allow:
|
||||
typeof channel.allow === "boolean" ? channel.allow : true,
|
||||
requireMention:
|
||||
typeof channel.requireMention === "boolean"
|
||||
? channel.requireMention
|
||||
: false,
|
||||
};
|
||||
},
|
||||
);
|
||||
return {
|
||||
key,
|
||||
slug: typeof entry.slug === "string" ? entry.slug : "",
|
||||
requireMention:
|
||||
typeof entry.requireMention === "boolean"
|
||||
? entry.requireMention
|
||||
: false,
|
||||
users: toList(entry.users),
|
||||
channels,
|
||||
};
|
||||
},
|
||||
)
|
||||
: [],
|
||||
actions: {
|
||||
reactions: readAction("reactions"),
|
||||
stickers: readAction("stickers"),
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
defaultDiscordActions,
|
||||
type DiscordActionForm,
|
||||
type DiscordForm,
|
||||
type DiscordGuildChannelForm,
|
||||
type DiscordGuildForm,
|
||||
type IMessageForm,
|
||||
type SignalForm,
|
||||
type TelegramForm,
|
||||
@@ -233,6 +235,8 @@ export async function saveDiscordConfig(state: ConnectionsState) {
|
||||
const allowFrom = parseList(form.allowFrom);
|
||||
const groupChannels = parseList(form.groupChannels);
|
||||
const dm = { ...(discord.dm ?? {}) } as Record<string, unknown>;
|
||||
if (form.dmEnabled) delete dm.enabled;
|
||||
else dm.enabled = false;
|
||||
if (allowFrom.length > 0) dm.allowFrom = allowFrom;
|
||||
else delete dm.allowFrom;
|
||||
if (form.groupEnabled) dm.groupEnabled = true;
|
||||
@@ -261,6 +265,51 @@ export async function saveDiscordConfig(state: ConnectionsState) {
|
||||
}
|
||||
}
|
||||
|
||||
const chunkLimitRaw = form.textChunkLimit.trim();
|
||||
if (chunkLimitRaw.length === 0) {
|
||||
delete discord.textChunkLimit;
|
||||
} else {
|
||||
const chunkLimit = Number(chunkLimitRaw);
|
||||
if (Number.isFinite(chunkLimit) && chunkLimit > 0) {
|
||||
discord.textChunkLimit = chunkLimit;
|
||||
} else {
|
||||
delete discord.textChunkLimit;
|
||||
}
|
||||
}
|
||||
|
||||
if (form.replyToMode === "off") {
|
||||
delete discord.replyToMode;
|
||||
} else {
|
||||
discord.replyToMode = form.replyToMode;
|
||||
}
|
||||
|
||||
const guildsForm = Array.isArray(form.guilds) ? form.guilds : [];
|
||||
const guilds: Record<string, unknown> = {};
|
||||
guildsForm.forEach((guild: DiscordGuildForm) => {
|
||||
const key = String(guild.key ?? "").trim();
|
||||
if (!key) return;
|
||||
const entry: Record<string, unknown> = {};
|
||||
const slug = String(guild.slug ?? "").trim();
|
||||
if (slug) entry.slug = slug;
|
||||
if (guild.requireMention) entry.requireMention = true;
|
||||
const users = parseList(guild.users);
|
||||
if (users.length > 0) entry.users = users;
|
||||
const channels: Record<string, unknown> = {};
|
||||
const channelForms = Array.isArray(guild.channels) ? guild.channels : [];
|
||||
channelForms.forEach((channel: DiscordGuildChannelForm) => {
|
||||
const channelKey = String(channel.key ?? "").trim();
|
||||
if (!channelKey) return;
|
||||
const channelEntry: Record<string, unknown> = {};
|
||||
if (channel.allow === false) channelEntry.allow = false;
|
||||
if (channel.requireMention) channelEntry.requireMention = true;
|
||||
channels[channelKey] = channelEntry;
|
||||
});
|
||||
if (Object.keys(channels).length > 0) entry.channels = channels;
|
||||
guilds[key] = entry;
|
||||
});
|
||||
if (Object.keys(guilds).length > 0) discord.guilds = guilds;
|
||||
else delete discord.guilds;
|
||||
|
||||
const actions: Partial<DiscordActionForm> = {};
|
||||
const applyAction = (key: keyof DiscordActionForm) => {
|
||||
const value = form.actions[key];
|
||||
|
||||
@@ -11,11 +11,15 @@ export type TelegramForm = {
|
||||
export type DiscordForm = {
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
dmEnabled: boolean;
|
||||
allowFrom: string;
|
||||
groupEnabled: boolean;
|
||||
groupChannels: string;
|
||||
mediaMaxMb: string;
|
||||
historyLimit: string;
|
||||
textChunkLimit: string;
|
||||
replyToMode: "off" | "first" | "all";
|
||||
guilds: DiscordGuildForm[];
|
||||
actions: DiscordActionForm;
|
||||
slashEnabled: boolean;
|
||||
slashName: string;
|
||||
@@ -23,6 +27,20 @@ export type DiscordForm = {
|
||||
slashEphemeral: boolean;
|
||||
};
|
||||
|
||||
export type DiscordGuildForm = {
|
||||
key: string;
|
||||
slug: string;
|
||||
requireMention: boolean;
|
||||
users: string;
|
||||
channels: DiscordGuildChannelForm[];
|
||||
};
|
||||
|
||||
export type DiscordGuildChannelForm = {
|
||||
key: string;
|
||||
allow: boolean;
|
||||
requireMention: boolean;
|
||||
};
|
||||
|
||||
export type DiscordActionForm = {
|
||||
reactions: boolean;
|
||||
stickers: boolean;
|
||||
|
||||
@@ -43,7 +43,10 @@ export function renderChat(props: ChatProps) {
|
||||
props.onSessionKeyChange((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
${sessionOptions.map(
|
||||
(entry) => html`<option value=${entry.key}>${entry.key}</option>`,
|
||||
(entry) =>
|
||||
html`<option value=${entry.key}>
|
||||
${entry.displayName ?? entry.key}
|
||||
</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
@@ -115,6 +118,7 @@ export function renderChat(props: ChatProps) {
|
||||
type SessionOption = {
|
||||
key: string;
|
||||
updatedAt?: number | null;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
function resolveSessionOptions(
|
||||
|
||||
+293
-18
@@ -10,6 +10,24 @@ import type {
|
||||
TelegramForm,
|
||||
} from "../ui-types";
|
||||
|
||||
const discordActionOptions = [
|
||||
{ key: "reactions", label: "Reactions" },
|
||||
{ key: "stickers", label: "Stickers" },
|
||||
{ key: "polls", label: "Polls" },
|
||||
{ key: "permissions", label: "Permissions" },
|
||||
{ key: "messages", label: "Messages" },
|
||||
{ key: "threads", label: "Threads" },
|
||||
{ key: "pins", label: "Pins" },
|
||||
{ key: "search", label: "Search" },
|
||||
{ key: "memberInfo", label: "Member info" },
|
||||
{ key: "roleInfo", label: "Role info" },
|
||||
{ key: "channelInfo", label: "Channel info" },
|
||||
{ key: "voiceStatus", label: "Voice status" },
|
||||
{ key: "events", label: "Events" },
|
||||
{ key: "roles", label: "Role changes" },
|
||||
{ key: "moderation", label: "Moderation" },
|
||||
] satisfies Array<{ key: keyof DiscordActionForm; label: string }>;
|
||||
|
||||
export type ConnectionsProps = {
|
||||
connected: boolean;
|
||||
loading: boolean;
|
||||
@@ -54,24 +72,6 @@ export function renderConnections(props: ConnectionsProps) {
|
||||
const discord = props.snapshot?.discord ?? null;
|
||||
const signal = props.snapshot?.signal ?? null;
|
||||
const imessage = props.snapshot?.imessage ?? null;
|
||||
const discordActionOptions: Array<{ key: keyof DiscordActionForm; label: string }> =
|
||||
[
|
||||
{ key: "reactions", label: "Reactions" },
|
||||
{ key: "stickers", label: "Stickers" },
|
||||
{ key: "polls", label: "Polls" },
|
||||
{ key: "permissions", label: "Permissions" },
|
||||
{ key: "messages", label: "Messages" },
|
||||
{ key: "threads", label: "Threads" },
|
||||
{ key: "pins", label: "Pins" },
|
||||
{ key: "search", label: "Search" },
|
||||
{ key: "memberInfo", label: "Member info" },
|
||||
{ key: "roleInfo", label: "Role info" },
|
||||
{ key: "channelInfo", label: "Channel info" },
|
||||
{ key: "voiceStatus", label: "Voice status" },
|
||||
{ key: "events", label: "Events" },
|
||||
{ key: "roles", label: "Role changes" },
|
||||
{ key: "moderation", label: "Moderation" },
|
||||
];
|
||||
const providerOrder: ProviderKey[] = [
|
||||
"whatsapp",
|
||||
"telegram",
|
||||
@@ -500,6 +500,19 @@ function renderProvider(
|
||||
placeholder="123456789, username#1234"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>DMs enabled</span>
|
||||
<select
|
||||
.value=${props.discordForm.dmEnabled ? "yes" : "no"}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
dmEnabled: (e.target as HTMLSelectElement).value === "yes",
|
||||
})}
|
||||
>
|
||||
<option value="yes">Enabled</option>
|
||||
<option value="no">Disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Group DMs</span>
|
||||
<select
|
||||
@@ -546,6 +559,268 @@ function renderProvider(
|
||||
placeholder="20"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Text chunk limit</span>
|
||||
<input
|
||||
.value=${props.discordForm.textChunkLimit}
|
||||
@input=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
textChunkLimit: (e.target as HTMLInputElement).value,
|
||||
})}
|
||||
placeholder="2000"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reply to mode</span>
|
||||
<select
|
||||
.value=${props.discordForm.replyToMode}
|
||||
@change=${(e: Event) =>
|
||||
props.onDiscordChange({
|
||||
replyToMode: (e.target as HTMLSelectElement).value as
|
||||
| "off"
|
||||
| "first"
|
||||
| "all",
|
||||
})}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="first">First</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="field full">
|
||||
<span>Guilds</span>
|
||||
<div class="card-sub">
|
||||
Add each guild (id or slug) and optional channel rules. Empty channel
|
||||
entries still allow that channel.
|
||||
</div>
|
||||
<div class="list">
|
||||
${props.discordForm.guilds.map(
|
||||
(guild, guildIndex) => html`
|
||||
<div class="list-item">
|
||||
<div class="list-main">
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Guild id / slug</span>
|
||||
<input
|
||||
.value=${guild.key}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
key: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Slug</span>
|
||||
<input
|
||||
.value=${guild.slug}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
slug: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Require mention</span>
|
||||
<select
|
||||
.value=${guild.requireMention ? "yes" : "no"}
|
||||
@change=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
requireMention:
|
||||
(e.target as HTMLSelectElement).value === "yes",
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Users allowlist</span>
|
||||
<input
|
||||
.value=${guild.users}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
users: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
placeholder="123456789, username#1234"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
${guild.channels.length
|
||||
? html`
|
||||
<div class="form-grid" style="margin-top: 8px;">
|
||||
${guild.channels.map(
|
||||
(channel, channelIndex) => html`
|
||||
<label class="field">
|
||||
<span>Channel id / slug</span>
|
||||
<input
|
||||
.value=${channel.key}
|
||||
@input=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
];
|
||||
channels[channelIndex] = {
|
||||
...channels[channelIndex],
|
||||
key: (e.target as HTMLInputElement).value,
|
||||
};
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Allow</span>
|
||||
<select
|
||||
.value=${channel.allow ? "yes" : "no"}
|
||||
@change=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
];
|
||||
channels[channelIndex] = {
|
||||
...channels[channelIndex],
|
||||
allow:
|
||||
(e.target as HTMLSelectElement).value ===
|
||||
"yes",
|
||||
};
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Require mention</span>
|
||||
<select
|
||||
.value=${channel.requireMention ? "yes" : "no"}
|
||||
@change=${(e: Event) => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
];
|
||||
channels[channelIndex] = {
|
||||
...channels[channelIndex],
|
||||
requireMention:
|
||||
(e.target as HTMLSelectElement).value ===
|
||||
"yes",
|
||||
};
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span> </span>
|
||||
<button
|
||||
class="btn"
|
||||
@click=${() => {
|
||||
const next = [
|
||||
...props.discordForm.guilds,
|
||||
];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
];
|
||||
channels.splice(channelIndex, 1);
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</label>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="list-meta">
|
||||
<span>Channels</span>
|
||||
<button
|
||||
class="btn"
|
||||
@click=${() => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
const channels = [
|
||||
...(next[guildIndex].channels ?? []),
|
||||
{ key: "", allow: true, requireMention: false },
|
||||
];
|
||||
next[guildIndex] = {
|
||||
...next[guildIndex],
|
||||
channels,
|
||||
};
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
Add channel
|
||||
</button>
|
||||
<button
|
||||
class="btn danger"
|
||||
@click=${() => {
|
||||
const next = [...props.discordForm.guilds];
|
||||
next.splice(guildIndex, 1);
|
||||
props.onDiscordChange({ guilds: next });
|
||||
}}
|
||||
>
|
||||
Remove guild
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
class="btn"
|
||||
style="margin-top: 8px;"
|
||||
@click=${() =>
|
||||
props.onDiscordChange({
|
||||
guilds: [
|
||||
...props.discordForm.guilds,
|
||||
{
|
||||
key: "",
|
||||
slug: "",
|
||||
requireMention: false,
|
||||
users: "",
|
||||
channels: [],
|
||||
},
|
||||
],
|
||||
})}
|
||||
>
|
||||
Add guild
|
||||
</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Slash command</span>
|
||||
<select
|
||||
|
||||
Reference in new issue
Block a user