Compare commits

...
Author SHA1 Message Date
Peter Steinberger fc4affb949 docs: update changelog for gog skill 2026-01-03 11:18:08 +01:00
Mariano Belinky b219ad5fda docs: add Sheets/Docs examples to gog skill 2026-01-03 11:17:34 +01:00
Peter Steinberger c583e64bb7 chore: update changelog 2026-01-03 11:17:00 +01:00
Peter Steinberger 9df63b008d docs: move telegram chunking fix to beta6 2026-01-03 11:15:57 +01:00
Peter Steinberger 3daecc092c docs: add changelog entry for telegram block replies 2026-01-03 11:12:15 +01:00
Muhammed Mukhthar CM 4d42811ecf fix(telegram): add textLimit to block reply chunking
Block streaming replies were missing the textLimit parameter in
deliverReplies(), causing long messages to fail with 'message is too
long' error instead of being chunked properly.

The final reply path already included textLimit, but the onBlockReply
callback path did not.
2026-01-03 11:12:15 +01:00
Peter Steinberger 1bebcf8033 chore: update appcast and TUI streaming handling 2026-01-03 11:06:49 +01:00
Peter Steinberger 45c555a4bd fix: use x86_64 bun for relay builds 2026-01-03 11:06:49 +01:00
Peter Steinberger 5986a83e80 fix: skip duplicate arch merge for Sparkle 2026-01-03 11:06:49 +01:00
Peter Steinberger 732de4acf0 fix: make Sparkle builds numeric + universal 2026-01-03 11:06:48 +01:00
Shadow 7400c0946e Discord: update UIs to use the new config 2026-01-03 01:02:22 -06:00
Peter Steinberger 14ee2b2c11 FAQ: Add common questions from Discord (Jan 3)
- Linux/VPS installation without Homebrew
- Minimum system requirements (runs on 1GB RAM!)
- Enterprise OAuth status (not supported yet)
- Discord DM allowlist config
- Model switching with /model
- Message queue modes with /queue
2026-01-03 06:09:51 +00:00
Peter Steinberger c3e1b8cfd9 chore: update protocol swift models 2026-01-03 06:44:21 +01:00
Peter Steinberger 67a67df35a fix: avoid unsafe string coercion in tui 2026-01-03 06:44:17 +01:00
Peter Steinberger 0f0578b268 docs: check off tui gate 2026-01-03 06:37:44 +01:00
Peter Steinberger 662208949f fix: align sessions.patch and tui typing 2026-01-03 06:37:40 +01:00
Peter Steinberger e41821342b docs: refresh tui guide 2026-01-03 06:28:36 +01:00
Peter Steinberger d3458a4fc3 feat: overhaul tui controller 2026-01-03 06:27:38 +01:00
Peter Steinberger 32c91bbb25 feat: add tui ui kit 2026-01-03 06:22:20 +01:00
Peter Steinberger aee13507f9 feat: expand tui gateway client 2026-01-03 06:17:33 +01:00
Peter Steinberger 61b67f6301 feat: extend gateway session patch 2026-01-03 06:16:49 +01:00
Peter Steinberger b86619bcd0 docs: fix appcast to only ship beta5 2026-01-03 06:12:01 +01:00
39 changed files with 2465 additions and 420 deletions

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ vendor/
# Vendor build artifacts
vendor/a2ui/renderers/lit/dist/
.bundle.hash
# fastlane (iOS)
apps/ios/fastlane/README.md
+10 -1
View File
@@ -1,5 +1,13 @@
# Changelog
## 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
@@ -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).
+5 -13
View File
@@ -2,20 +2,11 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdis</title>
<item>
<title>2.0.0-beta4</title>
<pubDate>Sat, 03 Jan 2026 06:04:02 +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>
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta5/Clawdis-2.0.0-beta4.zip" length="70407894" type="application/octet-stream" sparkle:edSignature="HmuiC7TnUn80ZApnKfb6w+JGSrjc3uUOndMrtbTp42bkBSVifbttNVazqvJueGBo4MgoJV8CP+zQNzVmtVihAQ=="/>
</item>
<item>
<title>2.0.0-beta5</title>
<pubDate>Sat, 03 Jan 2026 06:04:02 +0100</pubDate>
<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-beta5</sparkle:version>
<sparkle:version>2765</sparkle:version>
<sparkle:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdis 2.0.0-beta5</h2>
@@ -71,7 +62,8 @@
<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: add <code>clawdis tui</code> gateway-connected terminal UI (local or remote).</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>
@@ -214,7 +206,7 @@
</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-beta5/Clawdis-2.0.0-beta5.zip" length="72078343" type="application/octet-stream" sparkle:edSignature="xSfMHcYYEktlE+EC8cnM7GJP0oqjfN4EK+oIIcTHvXtgXG7LcBqo4kMQEVrr1kIzCIqQ+7/R6HMd/rWtfkc3Cg=="/>
<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>
@@ -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"
}
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+26
View File
@@ -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
View File
@@ -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
+145 -17
View File
@@ -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
View File
@@ -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.
+63
View File
@@ -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?.({
+1 -1
View File
@@ -1 +1 @@
debc115fd2b264d3b3090e28b733551ca5f7532287d827c13fc579e5a7a95b9d
549aa1fff40f95b4a899940ad2cde0adc3991423136ba02c23bcdbd621765ad7
+1
View File
@@ -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"),
+10
View File
@@ -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",
+152 -1
View File
@@ -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";
@@ -2958,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) {
@@ -5504,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) {
+1
View File
@@ -217,6 +217,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
runtime,
bot,
replyToMode,
textLimit,
});
})
.catch((err) => {
+88
View File
@@ -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");
}
+19
View File
@@ -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);
}
}
+108
View File
@@ -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);
}
}
}
+66
View File
@@ -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);
}
}
+26
View File
@@ -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,
);
}
+130
View File
@@ -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);
}
}
}
+20
View File
@@ -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);
}
}
+63 -5
View File
@@ -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);
@@ -111,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) {
-46
View File
@@ -1,46 +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);
}
}
-83
View File
@@ -1,83 +0,0 @@
import crypto from "node:crypto";
import type { DefaultTextStyle, MarkdownTheme } from "@mariozechner/pi-tui";
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import { theme } from "./theme.js";
type MessageRole = "user" | "system" | "tool";
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: MessageRole, text: string, style: DefaultTextStyle) {
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));
}
}
-28
View File
@@ -1,28 +0,0 @@
import type { MarkdownTheme } from "@mariozechner/pi-tui";
import chalk from "chalk";
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),
};
+95
View File
@@ -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,
};
+600 -185
View File
@@ -1,17 +1,21 @@
import {
CombinedAutocompleteProvider,
type Component,
Input,
isCtrlC,
isEscape,
Container,
ProcessTerminal,
Text,
TUI,
} 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,110 +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,
@@ -143,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}`,
),
);
};
@@ -155,121 +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 "exit": {
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":
client.stop();
tui.stop();
process.exit(0);
break;
}
case "quit": {
client.stop();
tui.stop();
process.exit(0);
break;
}
default:
messages.addSystem(`unknown command: /${command}`);
chatLog.addSystem(`unknown command: /${name}`);
break;
}
tui.requestRender();
@@ -277,63 +642,112 @@ 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(
chatLog.addSystem(
`event gap: expected ${info.expected}, got ${info.received}`,
);
tui.requestRender();
@@ -341,7 +755,8 @@ export async function runTui(opts: TuiOptions) {
updateHeader();
setStatus("connecting");
messages.addSystem("connecting...");
updateFooter();
chatLog.addSystem("connecting...");
tui.start();
client.start();
}
+4
View File
@@ -210,6 +210,10 @@
gap: 6px;
}
.field.full {
grid-column: 1 / -1;
}
.field span {
color: var(--muted);
font-size: 11px;
+24 -8
View File
@@ -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;
}
+1 -1
View File
@@ -14,7 +14,7 @@ export type ChatState = {
lastError: string | null;
};
type ChatEventPayload = {
export type ChatEventPayload = {
runId: string;
sessionKey: string;
state: "delta" | "final" | "aborted" | "error";
+55
View File
@@ -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"),
+49
View File
@@ -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];
+18
View File
@@ -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;
+293 -18
View File
@@ -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>&nbsp;</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