Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4affb949 | ||
|
|
b219ad5fda | ||
|
|
c583e64bb7 | ||
|
|
9df63b008d | ||
|
|
3daecc092c | ||
|
|
4d42811ecf | ||
|
|
1bebcf8033 | ||
|
|
45c555a4bd | ||
|
|
5986a83e80 | ||
|
|
732de4acf0 | ||
|
|
7400c0946e | ||
|
|
14ee2b2c11 | ||
|
|
c3e1b8cfd9 | ||
|
|
67a67df35a | ||
|
|
0f0578b268 | ||
|
|
662208949f | ||
|
|
e41821342b | ||
|
|
d3458a4fc3 | ||
|
|
32c91bbb25 | ||
|
|
aee13507f9 | ||
|
|
61b67f6301 | ||
|
|
b86619bcd0 |
No files matched your search
@@ -30,6 +30,7 @@ vendor/
|
|||||||
|
|
||||||
# Vendor build artifacts
|
# Vendor build artifacts
|
||||||
vendor/a2ui/renderers/lit/dist/
|
vendor/a2ui/renderers/lit/dist/
|
||||||
|
.bundle.hash
|
||||||
|
|
||||||
# fastlane (iOS)
|
# fastlane (iOS)
|
||||||
apps/ios/fastlane/README.md
|
apps/ios/fastlane/README.md
|
||||||
|
|||||||
+10
-1
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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
|
## 2.0.0-beta5 — 2026-01-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -49,7 +57,8 @@
|
|||||||
- CLI: add `configure`, `doctor`, and `update` wizards for ongoing setup, health checks, and modernization.
|
- 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 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 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: 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 `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).
|
- Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).
|
||||||
|
|||||||
+5
-13
@@ -2,20 +2,11 @@
|
|||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Clawdis</title>
|
<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>
|
<item>
|
||||||
<title>2.0.0-beta5</title>
|
<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>
|
<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:shortVersionString>2.0.0-beta5</sparkle:shortVersionString>
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
<description><![CDATA[<h2>Clawdis 2.0.0-beta5</h2>
|
<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 <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 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 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: 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 <code>things-mac</code> (Things 3 CLI) for read/search plus add/update via URL scheme.</li>
|
||||||
<li>Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).</li>
|
<li>Skills: add Apple Notes + Reminders skills via memo CLI (thanks @tylerwince).</li>
|
||||||
@@ -214,7 +206,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p>
|
<p><a href="https://github.com/steipete/clawdis/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
]]></description>
|
]]></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>
|
</item>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -289,6 +289,12 @@ struct ConnectionsSettings: View {
|
|||||||
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
|
TextField("123456789, username#1234", text: self.$store.discordAllowFrom)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
}
|
}
|
||||||
|
GridRow {
|
||||||
|
self.gridLabel("DMs enabled")
|
||||||
|
Toggle("", isOn: self.$store.discordDmEnabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.toggleStyle(.checkbox)
|
||||||
|
}
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("Group DMs")
|
self.gridLabel("Group DMs")
|
||||||
Toggle("", isOn: self.$store.discordGroupEnabled)
|
Toggle("", isOn: self.$store.discordGroupEnabled)
|
||||||
@@ -310,6 +316,20 @@ struct ConnectionsSettings: View {
|
|||||||
TextField("20", text: self.$store.discordHistoryLimit)
|
TextField("20", text: self.$store.discordHistoryLimit)
|
||||||
.textFieldStyle(.roundedBorder)
|
.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 {
|
GridRow {
|
||||||
self.gridLabel("Slash command")
|
self.gridLabel("Slash command")
|
||||||
Toggle("", isOn: self.$store.discordSlashEnabled)
|
Toggle("", isOn: self.$store.discordSlashEnabled)
|
||||||
@@ -336,6 +356,79 @@ struct ConnectionsSettings: View {
|
|||||||
|
|
||||||
Divider().padding(.vertical, 2)
|
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")
|
Text("Tool actions")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -144,6 +144,42 @@ struct ConfigSnapshot: Codable {
|
|||||||
let issues: [Issue]?
|
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
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class ConnectionsStore {
|
final class ConnectionsStore {
|
||||||
@@ -169,11 +205,15 @@ final class ConnectionsStore {
|
|||||||
var telegramBusy = false
|
var telegramBusy = false
|
||||||
var discordEnabled = true
|
var discordEnabled = true
|
||||||
var discordToken: String = ""
|
var discordToken: String = ""
|
||||||
|
var discordDmEnabled = true
|
||||||
var discordAllowFrom: String = ""
|
var discordAllowFrom: String = ""
|
||||||
var discordGroupEnabled = false
|
var discordGroupEnabled = false
|
||||||
var discordGroupChannels: String = ""
|
var discordGroupChannels: String = ""
|
||||||
var discordMediaMaxMb: String = ""
|
var discordMediaMaxMb: String = ""
|
||||||
var discordHistoryLimit: String = ""
|
var discordHistoryLimit: String = ""
|
||||||
|
var discordTextChunkLimit: String = ""
|
||||||
|
var discordReplyToMode: String = "off"
|
||||||
|
var discordGuilds: [DiscordGuildForm] = []
|
||||||
var discordActionReactions = true
|
var discordActionReactions = true
|
||||||
var discordActionStickers = true
|
var discordActionStickers = true
|
||||||
var discordActionPolls = true
|
var discordActionPolls = true
|
||||||
@@ -401,6 +441,7 @@ final class ConnectionsStore {
|
|||||||
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
||||||
self.discordToken = discord?["token"]?.stringValue ?? ""
|
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||||
let discordDm = discord?["dm"]?.dictionaryValue
|
let discordDm = discord?["dm"]?.dictionaryValue
|
||||||
|
self.discordDmEnabled = discordDm?["enabled"]?.boolValue ?? true
|
||||||
if let allow = discordDm?["allowFrom"]?.arrayValue {
|
if let allow = discordDm?["allowFrom"]?.arrayValue {
|
||||||
let strings = allow.compactMap { entry -> String? in
|
let strings = allow.compactMap { entry -> String? in
|
||||||
if let str = entry.stringValue { return str }
|
if let str = entry.stringValue { return str }
|
||||||
@@ -434,6 +475,56 @@ final class ConnectionsStore {
|
|||||||
} else {
|
} else {
|
||||||
self.discordHistoryLimit = ""
|
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
|
let discordActions = discord?["actions"]?.dictionaryValue
|
||||||
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
|
self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true
|
||||||
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
|
self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true
|
||||||
@@ -625,6 +716,11 @@ final class ConnectionsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var dm: [String: Any] = (discord["dm"] as? [String: Any]) ?? [:]
|
var dm: [String: Any] = (discord["dm"] as? [String: Any]) ?? [:]
|
||||||
|
if self.discordDmEnabled {
|
||||||
|
dm.removeValue(forKey: "enabled")
|
||||||
|
} else {
|
||||||
|
dm["enabled"] = false
|
||||||
|
}
|
||||||
let allow = self.discordAllowFrom
|
let allow = self.discordAllowFrom
|
||||||
.split(separator: ",")
|
.split(separator: ",")
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
@@ -673,6 +769,53 @@ final class ConnectionsStore {
|
|||||||
discord.removeValue(forKey: "historyLimit")
|
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]) ?? [:]
|
var actions: [String: Any] = (discord["actions"] as? [String: Any]) ?? [:]
|
||||||
func setAction(_ key: String, value: Bool, defaultValue: Bool) {
|
func setAction(_ key: String, value: Bool, defaultValue: Bool) {
|
||||||
if value == defaultValue {
|
if value == defaultValue {
|
||||||
|
|||||||
@@ -613,23 +613,27 @@ public struct SessionsPatchParams: Codable {
|
|||||||
public let key: String
|
public let key: String
|
||||||
public let thinkinglevel: AnyCodable?
|
public let thinkinglevel: AnyCodable?
|
||||||
public let verboselevel: AnyCodable?
|
public let verboselevel: AnyCodable?
|
||||||
|
public let model: AnyCodable?
|
||||||
public let groupactivation: AnyCodable?
|
public let groupactivation: AnyCodable?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
key: String,
|
key: String,
|
||||||
thinkinglevel: AnyCodable?,
|
thinkinglevel: AnyCodable?,
|
||||||
verboselevel: AnyCodable?,
|
verboselevel: AnyCodable?,
|
||||||
|
model: AnyCodable?,
|
||||||
groupactivation: AnyCodable?
|
groupactivation: AnyCodable?
|
||||||
) {
|
) {
|
||||||
self.key = key
|
self.key = key
|
||||||
self.thinkinglevel = thinkinglevel
|
self.thinkinglevel = thinkinglevel
|
||||||
self.verboselevel = verboselevel
|
self.verboselevel = verboselevel
|
||||||
|
self.model = model
|
||||||
self.groupactivation = groupactivation
|
self.groupactivation = groupactivation
|
||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case key
|
case key
|
||||||
case thinkinglevel = "thinkingLevel"
|
case thinkinglevel = "thinkingLevel"
|
||||||
case verboselevel = "verboseLevel"
|
case verboselevel = "verboseLevel"
|
||||||
|
case model
|
||||||
case groupactivation = "groupActivation"
|
case groupactivation = "groupActivation"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] 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`).
|
- If notarizing, use the `clawdis-notary` keychain profile created from App Store Connect API env vars (see `docs/mac/release.md`).
|
||||||
|
|
||||||
6) **Publish (npm)**
|
6) **Publish (npm)**
|
||||||
|
|||||||
+109
-1
@@ -32,6 +32,33 @@ Some features are platform-specific:
|
|||||||
- **iMessage** — macOS only (uses `imsg` CLI)
|
- **iMessage** — macOS only (uses `imsg` CLI)
|
||||||
- **Clawdis.app** — macOS native app (optional, gateway works without it)
|
- **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
|
### I'm getting "unauthorized" errors on health check
|
||||||
|
|
||||||
You need a config file. Run the onboarding wizard:
|
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.
|
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.
|
**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 callback not working (containers/headless)?
|
||||||
|
|
||||||
OAuth needs the callback to reach the machine running the CLI. Options:
|
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)).
|
- **Signal** — Via `signal-cli` (see [signal.md](./signal.md)).
|
||||||
- **WebChat** — Browser-based chat UI.
|
- **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?
|
### 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.
|
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) |
|
| `/think <level>` | Set thinking level (off\|minimal\|low\|medium\|high) |
|
||||||
| `/verbose on\|off` | Toggle verbose mode |
|
| `/verbose on\|off` | Toggle verbose mode |
|
||||||
| `/activation mention\|always` | Group activation (owner-only) |
|
| `/activation mention\|always` | Group activation (owner-only) |
|
||||||
|
| `/model <name>` | Switch AI model (see below) |
|
||||||
|
| `/queue instant\|batch\|serial` | Message queuing mode |
|
||||||
|
|
||||||
|
### How do I switch models on the fly?
|
||||||
|
|
||||||
|
Use `/model` to switch without restarting:
|
||||||
|
|
||||||
|
```
|
||||||
|
/model sonnet
|
||||||
|
/model haiku
|
||||||
|
/model opus
|
||||||
|
```
|
||||||
|
|
||||||
|
**Setup:** Configure allowed models and aliases in `clawdis.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent": {
|
||||||
|
"model": "anthropic/claude-opus-4-5-20251022",
|
||||||
|
"allowedModels": [
|
||||||
|
"anthropic/claude-opus-4-5-20251022",
|
||||||
|
"anthropic/claude-sonnet-4-5-20251022",
|
||||||
|
"anthropic/claude-haiku-4-5-20251001"
|
||||||
|
],
|
||||||
|
"modelAliases": {
|
||||||
|
"opus": "anthropic/claude-opus-4-5-20251022",
|
||||||
|
"sonnet": "anthropic/claude-sonnet-4-5-20251022",
|
||||||
|
"haiku": "anthropic/claude-haiku-4-5-20251001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tip:** `/model` is processed at the gateway level — it works even if you're rate-limited (429) on the current model!
|
||||||
|
|
||||||
|
### How do rapid-fire messages work?
|
||||||
|
|
||||||
|
Use `/queue` to control how messages sent in quick succession are handled:
|
||||||
|
|
||||||
|
- **`/queue instant`** — New messages interrupt/steer the current response
|
||||||
|
- **`/queue batch`** — Messages queue up, processed after current turn
|
||||||
|
- **`/queue serial`** — One at a time, in order
|
||||||
|
|
||||||
|
If you tend to send multiple short messages, `/queue instant` feels most natural.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+8
-3
@@ -21,11 +21,16 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
|
|||||||
- Sparkle tools are fetched automatically via SwiftPM at `apps/macos/.build/artifacts/sparkle/Sparkle/bin/` (`sign_update`, `generate_appcast`, etc.).
|
- Sparkle tools are fetched automatically via SwiftPM at `apps/macos/.build/artifacts/sparkle/Sparkle/bin/` (`sign_update`, `generate_appcast`, etc.).
|
||||||
|
|
||||||
## Build & package
|
## 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
|
```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 \
|
BUNDLE_ID=com.steipete.clawdis \
|
||||||
APP_VERSION=0.1.0 \
|
APP_VERSION=0.1.0 \
|
||||||
APP_BUILD=0.1.0 \
|
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||||
BUILD_CONFIG=release \
|
BUILD_CONFIG=release \
|
||||||
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
||||||
scripts/package-mac-app.sh
|
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 \
|
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdis-notary \
|
||||||
BUNDLE_ID=com.steipete.clawdis \
|
BUNDLE_ID=com.steipete.clawdis \
|
||||||
APP_VERSION=0.1.0 \
|
APP_VERSION=0.1.0 \
|
||||||
APP_BUILD=0.1.0 \
|
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||||
BUILD_CONFIG=release \
|
BUILD_CONFIG=release \
|
||||||
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \
|
||||||
scripts/package-mac-dist.sh
|
scripts/package-mac-dist.sh
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
summary: "Refactor plan: Gateway TUI parity with pi-mono interactive UI"
|
||||||
|
read_when:
|
||||||
|
- Building or refactoring the Gateway TUI
|
||||||
|
- Syncing TUI slash commands with Clawdis behavior
|
||||||
|
---
|
||||||
|
# Gateway TUI refactor plan
|
||||||
|
|
||||||
|
Updated: 2026-01-03
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Match pi-mono interactive TUI feel (editor, streaming, tool cards, selectors).
|
||||||
|
- Keep Clawdis semantics: Gateway WS only, session store owns state, no branching/export.
|
||||||
|
- Work locally or remotely via Gateway URL/token.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
- Branching, export, OAuth flows, or hook UIs.
|
||||||
|
- File-system operations on the Gateway host from the TUI.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [x] Protocol + server: sessions.patch supports model overrides; agent events include tool results (text-only payloads).
|
||||||
|
- [x] Gateway TUI client: add session/model helpers + stricter typing.
|
||||||
|
- [x] TUI UI kit: theme + components (editor, message feed, tool cards, selectors).
|
||||||
|
- [x] TUI controller: keybindings + Clawdis slash commands + history/stream wiring.
|
||||||
|
- [x] Docs + changelog updated for the new TUI behavior.
|
||||||
|
- [x] Gate: lint, build, tests, docs list.
|
||||||
+18
-4
@@ -10,6 +10,7 @@ Updated: 2026-01-03
|
|||||||
|
|
||||||
## What it is
|
## What it is
|
||||||
- A terminal UI that connects to the Gateway WebSocket and speaks the same chat APIs as WebChat.
|
- 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.
|
- Works locally (loopback) or remotely (Tailscale/SSH tunnel) without running a separate agent process.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
@@ -28,7 +29,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
|
|||||||
- `--token <token>`: Gateway token (if required).
|
- `--token <token>`: Gateway token (if required).
|
||||||
- `--password <password>`: Gateway password (if required).
|
- `--password <password>`: Gateway password (if required).
|
||||||
- `--session <key>`: Session key (default: `session.mainKey` or `main`).
|
- `--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.
|
- `--thinking <level>`: Override thinking level for sends.
|
||||||
- `--timeout-ms <ms>`: Agent timeout in ms (default 30000).
|
- `--timeout-ms <ms>`: Agent timeout in ms (default 30000).
|
||||||
- `--history-limit <n>`: History entries to load (default 200).
|
- `--history-limit <n>`: History entries to load (default 200).
|
||||||
@@ -36,16 +37,29 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
|
|||||||
## Controls
|
## Controls
|
||||||
- Enter: send message
|
- Enter: send message
|
||||||
- Esc: abort active run
|
- 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
|
## Slash commands
|
||||||
- `/help`
|
- `/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`
|
- `/abort`
|
||||||
|
- `/settings`
|
||||||
- `/exit`
|
- `/exit`
|
||||||
|
|
||||||
## Notes
|
## 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.
|
- It registers as a Gateway client with `mode: "tui"` for presence and debugging.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|||||||
+145
-17
@@ -6,7 +6,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
APP_ROOT="$ROOT_DIR/dist/Clawdis.app"
|
APP_ROOT="$ROOT_DIR/dist/Clawdis.app"
|
||||||
BUILD_PATH="$ROOT_DIR/apps/macos/.build"
|
BUILD_ROOT="$ROOT_DIR/apps/macos/.build"
|
||||||
PRODUCT="Clawdis"
|
PRODUCT="Clawdis"
|
||||||
BUNDLE_ID="${BUNDLE_ID:-com.steipete.clawdis.debug}"
|
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")"
|
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_VERSION="${APP_VERSION:-$PKG_VERSION}"
|
||||||
APP_BUILD="${APP_BUILD:-$GIT_BUILD_NUMBER}"
|
APP_BUILD="${APP_BUILD:-$GIT_BUILD_NUMBER}"
|
||||||
BUILD_CONFIG="${BUILD_CONFIG:-debug}"
|
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_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY:-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=}"
|
||||||
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml}"
|
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml}"
|
||||||
AUTO_CHECKS=true
|
AUTO_CHECKS=true
|
||||||
@@ -23,6 +26,104 @@ if [[ "$BUNDLE_ID" == *.debug ]]; then
|
|||||||
SPARKLE_FEED_URL=""
|
SPARKLE_FEED_URL=""
|
||||||
AUTO_CHECKS=false
|
AUTO_CHECKS=false
|
||||||
fi
|
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)"
|
echo "📦 Ensuring deps (pnpm install)"
|
||||||
(cd "$ROOT_DIR" && pnpm install --no-frozen-lockfile --config.node-linker=hoisted)
|
(cd "$ROOT_DIR" && pnpm install --no-frozen-lockfile --config.node-linker=hoisted)
|
||||||
@@ -42,11 +143,14 @@ fi
|
|||||||
|
|
||||||
cd "$ROOT_DIR/apps/macos"
|
cd "$ROOT_DIR/apps/macos"
|
||||||
|
|
||||||
echo "🔨 Building $PRODUCT ($BUILD_CONFIG)"
|
echo "🔨 Building $PRODUCT ($BUILD_CONFIG) [${BUILD_ARCHS[*]}]"
|
||||||
swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --build-path "$BUILD_PATH" -Xlinker -rpath -Xlinker @executable_path/../Frameworks
|
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"
|
BIN_PRIMARY="$(bin_for_arch "$PRIMARY_ARCH")"
|
||||||
echo "pkg: binary $BIN" >&2
|
echo "pkg: binary $BIN_PRIMARY" >&2
|
||||||
echo "🧹 Cleaning old app bundle"
|
echo "🧹 Cleaning old app bundle"
|
||||||
rm -rf "$APP_ROOT"
|
rm -rf "$APP_ROOT"
|
||||||
mkdir -p "$APP_ROOT/Contents/MacOS"
|
mkdir -p "$APP_ROOT/Contents/MacOS"
|
||||||
@@ -77,15 +181,32 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🚚 Copying binary"
|
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"
|
chmod +x "$APP_ROOT/Contents/MacOS/Clawdis"
|
||||||
# SwiftPM outputs ad-hoc signed binaries; strip the signature before install_name_tool to avoid warnings.
|
# 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
|
/usr/bin/codesign --remove-signature "$APP_ROOT/Contents/MacOS/Clawdis" 2>/dev/null || true
|
||||||
|
|
||||||
SPARKLE_FRAMEWORK="$BUILD_PATH/$BUILD_CONFIG/Sparkle.framework"
|
SPARKLE_FRAMEWORK_PRIMARY="$(sparkle_framework_for_arch "$PRIMARY_ARCH")"
|
||||||
if [ -d "$SPARKLE_FRAMEWORK" ]; then
|
if [ -d "$SPARKLE_FRAMEWORK_PRIMARY" ]; then
|
||||||
echo "✨ Embedding Sparkle.framework"
|
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"
|
chmod -R a+rX "$APP_ROOT/Contents/Frameworks/Sparkle.framework"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -106,14 +227,21 @@ if [[ "${SKIP_GATEWAY_PACKAGE:-0}" != "1" ]]; then
|
|||||||
|
|
||||||
echo "🧰 Building bundled relay (bun --compile)"
|
echo "🧰 Building bundled relay (bun --compile)"
|
||||||
mkdir -p "$RELAY_DIR"
|
mkdir -p "$RELAY_DIR"
|
||||||
RELAY_OUT="$RELAY_DIR/clawdis"
|
RELAY_OUT="$RELAY_DIR/clawdis"
|
||||||
bun build "$ROOT_DIR/dist/macos/relay.js" \
|
RELAY_BUILD_DIR="$RELAY_DIR/.relay-build"
|
||||||
--compile \
|
rm -rf "$RELAY_BUILD_DIR"
|
||||||
--bytecode \
|
mkdir -p "$RELAY_BUILD_DIR"
|
||||||
--outfile "$RELAY_OUT" \
|
for arch in "${BUILD_ARCHS[@]}"; do
|
||||||
-e electron \
|
RELAY_ARCH_OUT="$RELAY_BUILD_DIR/clawdis-$arch"
|
||||||
--define "__CLAWDIS_VERSION__=\\\"$PKG_VERSION\\\""
|
build_relay_binary "$arch" "$RELAY_ARCH_OUT"
|
||||||
chmod +x "$RELAY_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"
|
echo "🎨 Copying gateway A2UI host assets"
|
||||||
rm -rf "$RELAY_DIR/a2ui"
|
rm -rf "$RELAY_DIR/a2ui"
|
||||||
|
|||||||
+15
-5
@@ -1,26 +1,36 @@
|
|||||||
---
|
---
|
||||||
name: gog
|
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
|
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)"}]}}
|
metadata: {"clawdis":{"emoji":"🎮","requires":{"bins":["gog"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gogcli","bins":["gog"],"label":"Install gog (brew)"}]}}
|
||||||
---
|
---
|
||||||
|
|
||||||
# gog
|
# 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)
|
Setup (once)
|
||||||
- `gog auth credentials /path/to/client_secret.json`
|
- `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`
|
- `gog auth list`
|
||||||
|
|
||||||
Common commands
|
Common commands
|
||||||
- Gmail search: `gog gmail search 'newer_than:7d' --max 10`
|
- Gmail search: `gog gmail search 'newer_than:7d' --max 10`
|
||||||
- Gmail send: `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
|
- Gmail send: `gog gmail send --to a@b.com --subject "Hi" --body "Hello"`
|
||||||
- Calendar: `gog calendar events <calendarId> --from <rfc3339> --to <rfc3339>`
|
- Calendar: `gog calendar events <calendarId> --from <iso> --to <iso>`
|
||||||
- Drive: `gog drive search "query" --max 10`
|
- Drive search: `gog drive search "query" --max 10`
|
||||||
- Contacts: `gog contacts list --max 20`
|
- 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
|
Notes
|
||||||
- Set `GOG_ACCOUNT=you@gmail.com` to avoid repeating `--account`.
|
- 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.
|
- Confirm before sending mail or creating events.
|
||||||
@@ -16,6 +16,36 @@ import {
|
|||||||
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
|
const THINKING_TAG_RE = /<\s*\/?\s*think(?:ing)?\s*>/gi;
|
||||||
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
|
const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
|
||||||
const THINKING_CLOSE_RE = /<\s*\/\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 {
|
function stripThinkingSegments(text: string): string {
|
||||||
if (!text || !THINKING_TAG_RE.test(text)) return text;
|
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") {
|
if (evt.type === "tool_execution_end") {
|
||||||
const toolName = String(
|
const toolName = String(
|
||||||
(evt as AgentEvent & { toolName: string }).toolName,
|
(evt as AgentEvent & { toolName: string }).toolName,
|
||||||
@@ -197,6 +257,8 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
const isError = Boolean(
|
const isError = Boolean(
|
||||||
(evt as AgentEvent & { isError: boolean }).isError,
|
(evt as AgentEvent & { isError: boolean }).isError,
|
||||||
);
|
);
|
||||||
|
const result = (evt as AgentEvent & { result?: unknown }).result;
|
||||||
|
const sanitizedResult = sanitizeToolResult(result);
|
||||||
const meta = toolMetaById.get(toolCallId);
|
const meta = toolMetaById.get(toolCallId);
|
||||||
toolMetas.push({ toolName, meta });
|
toolMetas.push({ toolName, meta });
|
||||||
toolDebouncer.push(toolName, meta);
|
toolDebouncer.push(toolName, meta);
|
||||||
@@ -210,6 +272,7 @@ export function subscribeEmbeddedPiSession(params: {
|
|||||||
toolCallId,
|
toolCallId,
|
||||||
meta,
|
meta,
|
||||||
isError,
|
isError,
|
||||||
|
result: sanitizedResult,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
params.onAgentEvent?.({
|
params.onAgentEvent?.({
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
debc115fd2b264d3b3090e28b733551ca5f7532287d827c13fc579e5a7a95b9d
|
549aa1fff40f95b4a899940ad2cde0adc3991423136ba02c23bcdbd621765ad7
|
||||||
@@ -297,6 +297,7 @@ export const SessionsPatchParamsSchema = Type.Object(
|
|||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
verboseLevel: 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(
|
groupActivation: Type.Optional(
|
||||||
Type.Union([
|
Type.Union([
|
||||||
Type.Literal("mention"),
|
Type.Literal("mention"),
|
||||||
|
|||||||
@@ -4074,6 +4074,16 @@ describe("gateway server", () => {
|
|||||||
expect(main2?.thinkingLevel).toBe("medium");
|
expect(main2?.thinkingLevel).toBe("medium");
|
||||||
expect(main2?.verboseLevel).toBeUndefined();
|
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 }>(
|
const compacted = await rpcReq<{ ok: true; compacted: boolean }>(
|
||||||
ws,
|
ws,
|
||||||
"sessions.compact",
|
"sessions.compact",
|
||||||
|
|||||||
+152
-1
@@ -20,7 +20,13 @@ import {
|
|||||||
type ModelCatalogEntry,
|
type ModelCatalogEntry,
|
||||||
resetModelCatalogCacheForTest,
|
resetModelCatalogCacheForTest,
|
||||||
} from "../agents/model-catalog.js";
|
} 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 { installSkill } from "../agents/skills-install.js";
|
||||||
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
||||||
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../agents/workspace.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) {
|
if ("groupActivation" in p) {
|
||||||
const raw = p.groupActivation;
|
const raw = p.groupActivation;
|
||||||
if (raw === null) {
|
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) {
|
if ("groupActivation" in p) {
|
||||||
const raw = p.groupActivation;
|
const raw = p.groupActivation;
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
runtime,
|
runtime,
|
||||||
bot,
|
bot,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
|
textLimit,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { SlashCommand } from "@mariozechner/pi-tui";
|
||||||
|
|
||||||
|
const THINK_LEVELS = ["off", "minimal", "low", "medium", "high"];
|
||||||
|
const VERBOSE_LEVELS = ["on", "off"];
|
||||||
|
const ACTIVATION_LEVELS = ["mention", "always"];
|
||||||
|
const TOGGLE = ["on", "off"];
|
||||||
|
|
||||||
|
export type ParsedCommand = {
|
||||||
|
name: string;
|
||||||
|
args: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseCommand(input: string): ParsedCommand {
|
||||||
|
const trimmed = input.replace(/^\//, "").trim();
|
||||||
|
if (!trimmed) return { name: "", args: "" };
|
||||||
|
const [name, ...rest] = trimmed.split(/\s+/);
|
||||||
|
return { name: name.toLowerCase(), args: rest.join(" ").trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSlashCommands(): SlashCommand[] {
|
||||||
|
return [
|
||||||
|
{ name: "help", description: "Show slash command help" },
|
||||||
|
{ name: "status", description: "Show gateway status summary" },
|
||||||
|
{ name: "session", description: "Switch session (or open picker)" },
|
||||||
|
{ name: "sessions", description: "Open session picker" },
|
||||||
|
{
|
||||||
|
name: "model",
|
||||||
|
description: "Set model (or open picker)",
|
||||||
|
},
|
||||||
|
{ name: "models", description: "Open model picker" },
|
||||||
|
{
|
||||||
|
name: "think",
|
||||||
|
description: "Set thinking level",
|
||||||
|
getArgumentCompletions: (prefix) =>
|
||||||
|
THINK_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||||
|
(value) => ({ value, label: value }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "verbose",
|
||||||
|
description: "Set verbose on/off",
|
||||||
|
getArgumentCompletions: (prefix) =>
|
||||||
|
VERBOSE_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||||
|
(value) => ({ value, label: value }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "activation",
|
||||||
|
description: "Set group activation",
|
||||||
|
getArgumentCompletions: (prefix) =>
|
||||||
|
ACTIVATION_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||||
|
(value) => ({ value, label: value }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deliver",
|
||||||
|
description: "Toggle delivery of assistant replies",
|
||||||
|
getArgumentCompletions: (prefix) =>
|
||||||
|
TOGGLE.filter((v) => v.startsWith(prefix.toLowerCase())).map(
|
||||||
|
(value) => ({ value, label: value }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ name: "abort", description: "Abort active run" },
|
||||||
|
{ name: "new", description: "Reset the session" },
|
||||||
|
{ name: "reset", description: "Reset the session" },
|
||||||
|
{ name: "settings", description: "Open settings" },
|
||||||
|
{ name: "exit", description: "Exit the TUI" },
|
||||||
|
{ name: "quit", description: "Exit the TUI" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function helpText(): string {
|
||||||
|
return [
|
||||||
|
"Slash commands:",
|
||||||
|
"/help",
|
||||||
|
"/status",
|
||||||
|
"/session <key> (or /sessions)",
|
||||||
|
"/model <provider/model> (or /models)",
|
||||||
|
"/think <off|minimal|low|medium|high>",
|
||||||
|
"/verbose <on|off>",
|
||||||
|
"/activation <mention|always>",
|
||||||
|
"/deliver <on|off>",
|
||||||
|
"/new or /reset",
|
||||||
|
"/abort",
|
||||||
|
"/settings",
|
||||||
|
"/exit",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
|
||||||
|
import { markdownTheme, theme } from "../theme/theme.js";
|
||||||
|
|
||||||
|
export class AssistantMessageComponent extends Container {
|
||||||
|
private body: Markdown;
|
||||||
|
|
||||||
|
constructor(text: string) {
|
||||||
|
super();
|
||||||
|
this.body = new Markdown(text, 1, 0, markdownTheme, {
|
||||||
|
color: (line) => theme.fg(line),
|
||||||
|
});
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
this.addChild(this.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
setText(text: string) {
|
||||||
|
this.body.setText(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Container, Spacer, Text } from "@mariozechner/pi-tui";
|
||||||
|
import { theme } from "../theme/theme.js";
|
||||||
|
import { AssistantMessageComponent } from "./assistant-message.js";
|
||||||
|
import { ToolExecutionComponent } from "./tool-execution.js";
|
||||||
|
import { UserMessageComponent } from "./user-message.js";
|
||||||
|
|
||||||
|
export class ChatLog extends Container {
|
||||||
|
private toolById = new Map<string, ToolExecutionComponent>();
|
||||||
|
private streamingAssistant: AssistantMessageComponent | null = null;
|
||||||
|
private streamingRunId: string | null = null;
|
||||||
|
private streamingText: string | null = null;
|
||||||
|
private toolsExpanded = false;
|
||||||
|
|
||||||
|
clearAll() {
|
||||||
|
this.clear();
|
||||||
|
this.toolById.clear();
|
||||||
|
this.streamingAssistant = null;
|
||||||
|
this.streamingRunId = null;
|
||||||
|
this.streamingText = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
addSystem(text: string) {
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
this.addChild(new Text(theme.system(text), 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
addUser(text: string) {
|
||||||
|
this.addChild(new UserMessageComponent(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
startAssistant(text: string, runId?: string) {
|
||||||
|
const component = new AssistantMessageComponent(text);
|
||||||
|
this.streamingAssistant = component;
|
||||||
|
this.streamingRunId = runId ?? null;
|
||||||
|
this.streamingText = text;
|
||||||
|
this.addChild(component);
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAssistant(text: string, runId?: string) {
|
||||||
|
if (
|
||||||
|
!this.streamingAssistant ||
|
||||||
|
(runId && this.streamingRunId && runId !== this.streamingRunId)
|
||||||
|
) {
|
||||||
|
this.startAssistant(text, runId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.streamingText = text;
|
||||||
|
this.streamingAssistant.setText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeAssistant(text: string, runId?: string) {
|
||||||
|
if (
|
||||||
|
this.streamingAssistant &&
|
||||||
|
(!runId || runId === this.streamingRunId || text === this.streamingText)
|
||||||
|
) {
|
||||||
|
this.streamingText = text;
|
||||||
|
this.streamingAssistant.setText(text);
|
||||||
|
} else {
|
||||||
|
this.startAssistant(text, runId);
|
||||||
|
}
|
||||||
|
this.streamingAssistant = null;
|
||||||
|
this.streamingRunId = null;
|
||||||
|
this.streamingText = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
startTool(toolCallId: string, toolName: string, args: unknown) {
|
||||||
|
const existing = this.toolById.get(toolCallId);
|
||||||
|
if (existing) {
|
||||||
|
existing.setArgs(args);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const component = new ToolExecutionComponent(toolName, args);
|
||||||
|
component.setExpanded(this.toolsExpanded);
|
||||||
|
this.toolById.set(toolCallId, component);
|
||||||
|
this.addChild(component);
|
||||||
|
return component;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateToolArgs(toolCallId: string, args: unknown) {
|
||||||
|
const existing = this.toolById.get(toolCallId);
|
||||||
|
if (!existing) return;
|
||||||
|
existing.setArgs(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateToolResult(
|
||||||
|
toolCallId: string,
|
||||||
|
result: unknown,
|
||||||
|
opts?: { isError?: boolean; partial?: boolean },
|
||||||
|
) {
|
||||||
|
const existing = this.toolById.get(toolCallId);
|
||||||
|
if (!existing) return;
|
||||||
|
if (opts?.partial) {
|
||||||
|
existing.setPartialResult(result as Record<string, unknown>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existing.setResult(result as Record<string, unknown>, {
|
||||||
|
isError: opts?.isError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setToolsExpanded(expanded: boolean) {
|
||||||
|
this.toolsExpanded = expanded;
|
||||||
|
for (const tool of this.toolById.values()) {
|
||||||
|
tool.setExpanded(expanded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Editor,
|
||||||
|
isAltEnter,
|
||||||
|
isCtrlC,
|
||||||
|
isCtrlD,
|
||||||
|
isCtrlL,
|
||||||
|
isCtrlO,
|
||||||
|
isCtrlP,
|
||||||
|
isCtrlT,
|
||||||
|
isEscape,
|
||||||
|
isShiftTab,
|
||||||
|
} from "@mariozechner/pi-tui";
|
||||||
|
|
||||||
|
export class CustomEditor extends Editor {
|
||||||
|
onEscape?: () => void;
|
||||||
|
onCtrlC?: () => void;
|
||||||
|
onCtrlD?: () => void;
|
||||||
|
onCtrlL?: () => void;
|
||||||
|
onCtrlO?: () => void;
|
||||||
|
onCtrlP?: () => void;
|
||||||
|
onCtrlT?: () => void;
|
||||||
|
onShiftTab?: () => void;
|
||||||
|
onAltEnter?: () => void;
|
||||||
|
|
||||||
|
handleInput(data: string): void {
|
||||||
|
if (isAltEnter(data) && this.onAltEnter) {
|
||||||
|
this.onAltEnter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCtrlL(data) && this.onCtrlL) {
|
||||||
|
this.onCtrlL();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCtrlO(data) && this.onCtrlO) {
|
||||||
|
this.onCtrlO();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCtrlP(data) && this.onCtrlP) {
|
||||||
|
this.onCtrlP();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCtrlT(data) && this.onCtrlT) {
|
||||||
|
this.onCtrlT();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isShiftTab(data) && this.onShiftTab) {
|
||||||
|
this.onShiftTab();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isEscape(data) && this.onEscape && !this.isShowingAutocomplete()) {
|
||||||
|
this.onEscape();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCtrlC(data) && this.onCtrlC) {
|
||||||
|
this.onCtrlC();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isCtrlD(data)) {
|
||||||
|
if (this.getText().length === 0 && this.onCtrlD) {
|
||||||
|
this.onCtrlD();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.handleInput(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
type SelectItem,
|
||||||
|
SelectList,
|
||||||
|
type SettingItem,
|
||||||
|
SettingsList,
|
||||||
|
} from "@mariozechner/pi-tui";
|
||||||
|
import { selectListTheme, settingsListTheme } from "../theme/theme.js";
|
||||||
|
|
||||||
|
export function createSelectList(items: SelectItem[], maxVisible = 7) {
|
||||||
|
return new SelectList(items, maxVisible, selectListTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSettingsList(
|
||||||
|
items: SettingItem[],
|
||||||
|
onChange: (id: string, value: string) => void,
|
||||||
|
onCancel: () => void,
|
||||||
|
maxVisible = 7,
|
||||||
|
) {
|
||||||
|
return new SettingsList(
|
||||||
|
items,
|
||||||
|
maxVisible,
|
||||||
|
settingsListTheme,
|
||||||
|
onChange,
|
||||||
|
onCancel,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||||
|
import { markdownTheme, theme } from "../theme/theme.js";
|
||||||
|
|
||||||
|
type ToolResultContent = {
|
||||||
|
type?: string;
|
||||||
|
text?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
bytes?: number;
|
||||||
|
omitted?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToolResult = {
|
||||||
|
content?: ToolResultContent[];
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREVIEW_LINES = 12;
|
||||||
|
|
||||||
|
function formatArgs(toolName: string, args: unknown): string {
|
||||||
|
if (!args || typeof args !== "object") return "";
|
||||||
|
const record = args as Record<string, unknown>;
|
||||||
|
if (toolName === "bash" && typeof record.command === "string") {
|
||||||
|
return record.command;
|
||||||
|
}
|
||||||
|
const path = typeof record.path === "string" ? record.path : undefined;
|
||||||
|
if (path) return path;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(args);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(result?: ToolResult): string {
|
||||||
|
if (!result?.content) return "";
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const entry of result.content) {
|
||||||
|
if (entry.type === "text" && entry.text) {
|
||||||
|
lines.push(entry.text);
|
||||||
|
} else if (entry.type === "image") {
|
||||||
|
const mime = entry.mimeType ?? "image";
|
||||||
|
const size = entry.bytes ? ` ${Math.round(entry.bytes / 1024)}kb` : "";
|
||||||
|
const omitted = entry.omitted ? " (omitted)" : "";
|
||||||
|
lines.push(`[${mime}${size}${omitted}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ToolExecutionComponent extends Container {
|
||||||
|
private box: Box;
|
||||||
|
private header: Text;
|
||||||
|
private argsLine: Text;
|
||||||
|
private output: Markdown;
|
||||||
|
private toolName: string;
|
||||||
|
private args: unknown;
|
||||||
|
private result?: ToolResult;
|
||||||
|
private expanded = false;
|
||||||
|
private isError = false;
|
||||||
|
private isPartial = true;
|
||||||
|
|
||||||
|
constructor(toolName: string, args: unknown) {
|
||||||
|
super();
|
||||||
|
this.toolName = toolName;
|
||||||
|
this.args = args;
|
||||||
|
this.box = new Box(1, 1, (line) => theme.toolPendingBg(line));
|
||||||
|
this.header = new Text("", 0, 0);
|
||||||
|
this.argsLine = new Text("", 0, 0);
|
||||||
|
this.output = new Markdown("", 0, 0, markdownTheme, {
|
||||||
|
color: (line) => theme.toolOutput(line),
|
||||||
|
});
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
this.addChild(this.box);
|
||||||
|
this.box.addChild(this.header);
|
||||||
|
this.box.addChild(this.argsLine);
|
||||||
|
this.box.addChild(this.output);
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
setArgs(args: unknown) {
|
||||||
|
this.args = args;
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpanded(expanded: boolean) {
|
||||||
|
this.expanded = expanded;
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult(result: ToolResult | undefined, opts?: { isError?: boolean }) {
|
||||||
|
this.result = result;
|
||||||
|
this.isPartial = false;
|
||||||
|
this.isError = Boolean(opts?.isError);
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPartialResult(result: ToolResult | undefined) {
|
||||||
|
this.result = result;
|
||||||
|
this.isPartial = true;
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh() {
|
||||||
|
const bg = this.isPartial
|
||||||
|
? theme.toolPendingBg
|
||||||
|
: this.isError
|
||||||
|
? theme.toolErrorBg
|
||||||
|
: theme.toolSuccessBg;
|
||||||
|
this.box.setBgFn((line) => bg(line));
|
||||||
|
|
||||||
|
const title = `${this.toolName}${this.isPartial ? " (running)" : ""}`;
|
||||||
|
this.header.setText(theme.toolTitle(theme.bold(title)));
|
||||||
|
|
||||||
|
const argLine = formatArgs(this.toolName, this.args);
|
||||||
|
this.argsLine.setText(argLine ? theme.dim(argLine) : theme.dim(" "));
|
||||||
|
|
||||||
|
const raw = extractText(this.result);
|
||||||
|
const text = raw || (this.isPartial ? "…" : "");
|
||||||
|
if (!this.expanded && text) {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
const preview =
|
||||||
|
lines.length > PREVIEW_LINES
|
||||||
|
? `${lines.slice(0, PREVIEW_LINES).join("\n")}\n…`
|
||||||
|
: text;
|
||||||
|
this.output.setText(preview);
|
||||||
|
} else {
|
||||||
|
this.output.setText(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
|
||||||
|
import { markdownTheme, theme } from "../theme/theme.js";
|
||||||
|
|
||||||
|
export class UserMessageComponent extends Container {
|
||||||
|
private body: Markdown;
|
||||||
|
|
||||||
|
constructor(text: string) {
|
||||||
|
super();
|
||||||
|
this.body = new Markdown(text, 1, 1, markdownTheme, {
|
||||||
|
bgColor: (line) => theme.userBg(line),
|
||||||
|
color: (line) => theme.userText(line),
|
||||||
|
});
|
||||||
|
this.addChild(new Spacer(1));
|
||||||
|
this.addChild(this.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
setText(text: string) {
|
||||||
|
this.body.setText(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-5
@@ -1,7 +1,12 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { GatewayClient } from "../gateway/client.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";
|
import { VERSION } from "../version.js";
|
||||||
|
|
||||||
export type GatewayConnectionOptions = {
|
export type GatewayConnectionOptions = {
|
||||||
@@ -21,6 +26,32 @@ export type ChatSendOptions = {
|
|||||||
export type GatewayEvent = {
|
export type GatewayEvent = {
|
||||||
event: string;
|
event: string;
|
||||||
payload?: unknown;
|
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 {
|
export class GatewayChatClient {
|
||||||
@@ -28,6 +59,7 @@ export class GatewayChatClient {
|
|||||||
private readyPromise: Promise<void>;
|
private readyPromise: Promise<void>;
|
||||||
private resolveReady?: () => void;
|
private resolveReady?: () => void;
|
||||||
readonly connection: { url: string; token?: string; password?: string };
|
readonly connection: { url: string; token?: string; password?: string };
|
||||||
|
hello?: HelloOk;
|
||||||
|
|
||||||
onEvent?: (evt: GatewayEvent) => void;
|
onEvent?: (evt: GatewayEvent) => void;
|
||||||
onConnected?: () => void;
|
onConnected?: () => void;
|
||||||
@@ -53,12 +85,17 @@ export class GatewayChatClient {
|
|||||||
instanceId: randomUUID(),
|
instanceId: randomUUID(),
|
||||||
minProtocol: PROTOCOL_VERSION,
|
minProtocol: PROTOCOL_VERSION,
|
||||||
maxProtocol: PROTOCOL_VERSION,
|
maxProtocol: PROTOCOL_VERSION,
|
||||||
onHelloOk: () => {
|
onHelloOk: (hello) => {
|
||||||
|
this.hello = hello;
|
||||||
this.resolveReady?.();
|
this.resolveReady?.();
|
||||||
this.onConnected?.();
|
this.onConnected?.();
|
||||||
},
|
},
|
||||||
onEvent: (evt) => {
|
onEvent: (evt) => {
|
||||||
this.onEvent?.({ event: evt.event, payload: evt.payload });
|
this.onEvent?.({
|
||||||
|
event: evt.event,
|
||||||
|
payload: evt.payload,
|
||||||
|
seq: evt.seq,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onClose: (_code, reason) => {
|
onClose: (_code, reason) => {
|
||||||
this.onDisconnected?.(reason);
|
this.onDisconnected?.(reason);
|
||||||
@@ -111,12 +148,33 @@ export class GatewayChatClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listSessions(opts?: { limit?: number; activeMinutes?: number }) {
|
async listSessions(opts?: SessionsListParams) {
|
||||||
return await this.client.request("sessions.list", {
|
return await this.client.request<GatewaySessionList>("sessions.list", {
|
||||||
limit: opts?.limit,
|
limit: opts?.limit,
|
||||||
activeMinutes: opts?.activeMinutes,
|
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) {
|
export function resolveGatewayConnection(opts: GatewayConnectionOptions) {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
@@ -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
@@ -1,17 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
|
CombinedAutocompleteProvider,
|
||||||
type Component,
|
type Component,
|
||||||
Input,
|
Container,
|
||||||
isCtrlC,
|
|
||||||
isEscape,
|
|
||||||
ProcessTerminal,
|
ProcessTerminal,
|
||||||
Text,
|
Text,
|
||||||
TUI,
|
TUI,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import { loadConfig } from "../config/config.js";
|
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 { GatewayChatClient } from "./gateway-chat.js";
|
||||||
import { ChatLayout } from "./layout.js";
|
import { editorTheme, theme } from "./theme/theme.js";
|
||||||
import { MessageList } from "./message-list.js";
|
|
||||||
import { markdownTheme, theme } from "./theme.js";
|
|
||||||
|
|
||||||
export type TuiOptions = {
|
export type TuiOptions = {
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -32,110 +36,88 @@ type ChatEvent = {
|
|||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class InputWrapper implements Component {
|
type AgentEvent = {
|
||||||
constructor(
|
runId: string;
|
||||||
private input: Input,
|
stream: string;
|
||||||
private onAbort: () => void,
|
data?: Record<string, unknown>;
|
||||||
private onExit: () => void,
|
};
|
||||||
) {}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
type SessionInfo = {
|
||||||
if (isCtrlC(data)) {
|
thinkingLevel?: string;
|
||||||
this.onExit();
|
verboseLevel?: string;
|
||||||
return;
|
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)) {
|
if (
|
||||||
this.onAbort();
|
opts?.includeThinking &&
|
||||||
return;
|
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();
|
return parts.join("\n").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHistoryEntry(
|
function extractTextFromMessage(
|
||||||
entry: unknown,
|
message: unknown,
|
||||||
): { role: "user" | "assistant"; text: string } | null {
|
opts?: { includeThinking?: boolean },
|
||||||
if (!entry || typeof entry !== "object") return null;
|
): string {
|
||||||
const record = entry as Record<string, unknown>;
|
if (!message || typeof message !== "object") return "";
|
||||||
const role =
|
const record = message as Record<string, unknown>;
|
||||||
record.role === "user" || record.role === "assistant" ? record.role : null;
|
return extractTextBlocks(record.content, opts);
|
||||||
if (!role) return null;
|
}
|
||||||
const text = extractText(record);
|
|
||||||
if (!text) return null;
|
function formatTokens(total?: number | null, context?: number | null) {
|
||||||
return { role, text };
|
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) {
|
export async function runTui(opts: TuiOptions) {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const defaultSession =
|
const defaultSession =
|
||||||
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main";
|
(opts.session ?? config.session?.mainKey ?? "main").trim() || "main";
|
||||||
let currentSession = defaultSession;
|
let currentSessionKey = defaultSession;
|
||||||
let activeRunId: string | null = null;
|
let currentSessionId: string | null = null;
|
||||||
let streamingMessageId: string | null = null;
|
let activeChatRunId: string | null = null;
|
||||||
|
const finalizedRuns = new Map<string, number>();
|
||||||
let historyLoaded = false;
|
let historyLoaded = false;
|
||||||
|
let isConnected = false;
|
||||||
const messages = new MessageList(markdownTheme, {
|
let toolsExpanded = false;
|
||||||
user: { color: theme.user },
|
let showThinking = false;
|
||||||
assistant: { color: theme.assistant },
|
let deliverDefault = Boolean(opts.deliver);
|
||||||
system: { color: theme.system, italic: true },
|
let sessionInfo: SessionInfo = {};
|
||||||
tool: { color: theme.dim, italic: true },
|
let lastCtrlCAt = 0;
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
const client = new GatewayChatClient({
|
const client = new GatewayChatClient({
|
||||||
url: opts.url,
|
url: opts.url,
|
||||||
@@ -143,10 +125,28 @@ export async function runTui(opts: TuiOptions) {
|
|||||||
password: opts.password,
|
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 = () => {
|
const updateHeader = () => {
|
||||||
header.setText(
|
header.setText(
|
||||||
theme.header(
|
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));
|
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 () => {
|
const loadHistory = async () => {
|
||||||
try {
|
try {
|
||||||
const history = await client.loadHistory({
|
const history = await client.loadHistory({
|
||||||
sessionKey: currentSession,
|
sessionKey: currentSessionKey,
|
||||||
limit: opts.historyLimit ?? 200,
|
limit: opts.historyLimit ?? 200,
|
||||||
});
|
});
|
||||||
const historyRecord = history as { messages?: unknown[] } | undefined;
|
const record = history as {
|
||||||
messages.clearAll();
|
messages?: unknown[];
|
||||||
messages.addSystem(`session ${currentSession}`);
|
sessionId?: string;
|
||||||
for (const entry of historyRecord?.messages ?? []) {
|
thinkingLevel?: string;
|
||||||
const parsed = renderHistoryEntry(entry);
|
};
|
||||||
if (!parsed) continue;
|
currentSessionId =
|
||||||
if (parsed.role === "user") messages.addUser(parsed.text);
|
typeof record.sessionId === "string" ? record.sessionId : null;
|
||||||
if (parsed.role === "assistant") messages.addAssistant(parsed.text);
|
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;
|
historyLoaded = true;
|
||||||
tui.requestRender();
|
|
||||||
} catch (err) {
|
} 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();
|
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) => {
|
const handleChatEvent = (payload: unknown) => {
|
||||||
if (!payload || typeof payload !== "object") return;
|
if (!payload || typeof payload !== "object") return;
|
||||||
const evt = payload as ChatEvent;
|
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") {
|
if (evt.state === "delta") {
|
||||||
const text = extractText(evt.message);
|
const text = extractTextFromMessage(evt.message, {
|
||||||
|
includeThinking: showThinking,
|
||||||
|
});
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (!streamingMessageId || activeRunId !== evt.runId) {
|
chatLog.updateAssistant(text, evt.runId);
|
||||||
streamingMessageId = messages.addAssistant(text, evt.runId);
|
|
||||||
activeRunId = evt.runId;
|
|
||||||
} else {
|
|
||||||
messages.updateAssistant(streamingMessageId, text);
|
|
||||||
}
|
|
||||||
setStatus("streaming");
|
setStatus("streaming");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.state === "final") {
|
if (evt.state === "final") {
|
||||||
const text = extractText(evt.message);
|
const text = extractTextFromMessage(evt.message, {
|
||||||
if (streamingMessageId && activeRunId === evt.runId) {
|
includeThinking: showThinking,
|
||||||
messages.updateAssistant(streamingMessageId, text || "(no output)");
|
});
|
||||||
} else if (text) {
|
chatLog.finalizeAssistant(text || "(no output)", evt.runId);
|
||||||
messages.addAssistant(text, evt.runId);
|
noteFinalizedRun(evt.runId);
|
||||||
}
|
activeChatRunId = null;
|
||||||
activeRunId = null;
|
|
||||||
streamingMessageId = null;
|
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.state === "aborted") {
|
if (evt.state === "aborted") {
|
||||||
messages.addSystem("run aborted");
|
chatLog.addSystem("run aborted");
|
||||||
activeRunId = null;
|
activeChatRunId = null;
|
||||||
streamingMessageId = null;
|
|
||||||
setStatus("aborted");
|
setStatus("aborted");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (evt.state === "error") {
|
if (evt.state === "error") {
|
||||||
messages.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
|
||||||
activeRunId = null;
|
activeChatRunId = null;
|
||||||
streamingMessageId = null;
|
|
||||||
setStatus("error");
|
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();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommand = async (raw: string) => {
|
const handleCommand = async (raw: string) => {
|
||||||
const [command, ...rest] = raw.slice(1).trim().split(/\s+/);
|
const { name, args } = parseCommand(raw);
|
||||||
const arg = rest.join(" ").trim();
|
if (!name) return;
|
||||||
switch (command) {
|
switch (name) {
|
||||||
case "help": {
|
case "help":
|
||||||
messages.addSystem("/help /session <key> /abort /exit");
|
chatLog.addSystem(helpText());
|
||||||
break;
|
break;
|
||||||
}
|
case "status":
|
||||||
case "session": {
|
try {
|
||||||
if (!arg) {
|
const status = await client.getStatus();
|
||||||
messages.addSystem("missing session key");
|
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;
|
break;
|
||||||
}
|
}
|
||||||
currentSession = arg;
|
try {
|
||||||
activeRunId = null;
|
await client.patchSession({
|
||||||
streamingMessageId = null;
|
key: currentSessionKey,
|
||||||
historyLoaded = false;
|
thinkingLevel: args,
|
||||||
updateHeader();
|
});
|
||||||
await loadHistory();
|
chatLog.addSystem(`thinking set to ${args}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`think failed: ${String(err)}`);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
case "verbose":
|
||||||
case "abort": {
|
if (!args) {
|
||||||
if (!activeRunId) {
|
chatLog.addSystem("usage: /verbose <on|off>");
|
||||||
messages.addSystem("no active run");
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await client.abortChat({
|
try {
|
||||||
sessionKey: currentSession,
|
await client.patchSession({
|
||||||
runId: activeRunId,
|
key: currentSessionKey,
|
||||||
});
|
verboseLevel: args,
|
||||||
|
});
|
||||||
|
chatLog.addSystem(`verbose set to ${args}`);
|
||||||
|
await refreshSessionInfo();
|
||||||
|
} catch (err) {
|
||||||
|
chatLog.addSystem(`verbose failed: ${String(err)}`);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
case "activation":
|
||||||
case "exit": {
|
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();
|
client.stop();
|
||||||
tui.stop();
|
tui.stop();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
case "quit": {
|
|
||||||
client.stop();
|
|
||||||
tui.stop();
|
|
||||||
process.exit(0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
messages.addSystem(`unknown command: /${command}`);
|
chatLog.addSystem(`unknown command: /${name}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
@@ -277,63 +642,112 @@ export async function runTui(opts: TuiOptions) {
|
|||||||
|
|
||||||
const sendMessage = async (text: string) => {
|
const sendMessage = async (text: string) => {
|
||||||
try {
|
try {
|
||||||
messages.addUser(text);
|
chatLog.addUser(text);
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
setStatus("sending");
|
setStatus("sending");
|
||||||
const { runId } = await client.sendChat({
|
const { runId } = await client.sendChat({
|
||||||
sessionKey: currentSession,
|
sessionKey: currentSessionKey,
|
||||||
message: text,
|
message: text,
|
||||||
thinking: opts.thinking,
|
thinking: opts.thinking,
|
||||||
deliver: opts.deliver,
|
deliver: deliverDefault,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
});
|
});
|
||||||
activeRunId = runId;
|
activeChatRunId = runId;
|
||||||
streamingMessageId = null;
|
|
||||||
setStatus("waiting");
|
setStatus("waiting");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
messages.addSystem(`send failed: ${String(err)}`);
|
chatLog.addSystem(`send failed: ${String(err)}`);
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
}
|
}
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
input.onSubmit = (value) => {
|
editor.setAutocompleteProvider(
|
||||||
const text = value.trim();
|
new CombinedAutocompleteProvider(getSlashCommands(), process.cwd()),
|
||||||
input.setValue("");
|
);
|
||||||
if (!text) return;
|
editor.onSubmit = (text) => {
|
||||||
if (text.startsWith("/")) {
|
const value = text.trim();
|
||||||
void handleCommand(text);
|
editor.setText("");
|
||||||
|
if (!value) return;
|
||||||
|
if (value.startsWith("/")) {
|
||||||
|
void handleCommand(value);
|
||||||
return;
|
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) => {
|
client.onEvent = (evt) => {
|
||||||
if (evt.event === "chat") handleChatEvent(evt.payload);
|
if (evt.event === "chat") handleChatEvent(evt.payload);
|
||||||
|
if (evt.event === "agent") handleAgentEvent(evt.payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
client.onConnected = () => {
|
client.onConnected = () => {
|
||||||
|
isConnected = true;
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
updateHeader();
|
updateHeader();
|
||||||
if (!historyLoaded) {
|
if (!historyLoaded) {
|
||||||
void loadHistory().then(() => {
|
void loadHistory().then(() => {
|
||||||
messages.addSystem("gateway connected");
|
chatLog.addSystem("gateway connected");
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
messages.addSystem("gateway reconnected");
|
chatLog.addSystem("gateway reconnected");
|
||||||
}
|
}
|
||||||
|
updateFooter();
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
client.onDisconnected = (reason) => {
|
client.onDisconnected = (reason) => {
|
||||||
messages.addSystem(`gateway disconnected: ${reason || "closed"}`);
|
isConnected = false;
|
||||||
|
chatLog.addSystem(`gateway disconnected: ${reason || "closed"}`);
|
||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
|
updateFooter();
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
client.onGap = (info) => {
|
client.onGap = (info) => {
|
||||||
messages.addSystem(
|
chatLog.addSystem(
|
||||||
`event gap: expected ${info.expected}, got ${info.received}`,
|
`event gap: expected ${info.expected}, got ${info.received}`,
|
||||||
);
|
);
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
@@ -341,7 +755,8 @@ export async function runTui(opts: TuiOptions) {
|
|||||||
|
|
||||||
updateHeader();
|
updateHeader();
|
||||||
setStatus("connecting");
|
setStatus("connecting");
|
||||||
messages.addSystem("connecting...");
|
updateFooter();
|
||||||
|
chatLog.addSystem("connecting...");
|
||||||
tui.start();
|
tui.start();
|
||||||
client.start();
|
client.start();
|
||||||
}
|
}
|
||||||
@@ -210,6 +210,10 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field.full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.field span {
|
.field span {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
+24
-8
@@ -34,7 +34,12 @@ import {
|
|||||||
type SignalForm,
|
type SignalForm,
|
||||||
type TelegramForm,
|
type TelegramForm,
|
||||||
} from "./ui-types";
|
} 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 { loadNodes } from "./controllers/nodes";
|
||||||
import { loadConfig } from "./controllers/config";
|
import { loadConfig } from "./controllers/config";
|
||||||
import {
|
import {
|
||||||
@@ -139,11 +144,15 @@ export class ClawdisApp extends LitElement {
|
|||||||
@state() discordForm: DiscordForm = {
|
@state() discordForm: DiscordForm = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
token: "",
|
token: "",
|
||||||
|
dmEnabled: true,
|
||||||
allowFrom: "",
|
allowFrom: "",
|
||||||
groupEnabled: false,
|
groupEnabled: false,
|
||||||
groupChannels: "",
|
groupChannels: "",
|
||||||
mediaMaxMb: "",
|
mediaMaxMb: "",
|
||||||
historyLimit: "",
|
historyLimit: "",
|
||||||
|
textChunkLimit: "",
|
||||||
|
replyToMode: "off",
|
||||||
|
guilds: [],
|
||||||
actions: { ...defaultDiscordActions },
|
actions: { ...defaultDiscordActions },
|
||||||
slashEnabled: false,
|
slashEnabled: false,
|
||||||
slashName: "",
|
slashName: "",
|
||||||
@@ -350,7 +359,8 @@ export class ClawdisApp extends LitElement {
|
|||||||
].slice(0, 250);
|
].slice(0, 250);
|
||||||
|
|
||||||
if (evt.event === "chat") {
|
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);
|
if (state === "final") void loadChatHistory(this);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -469,20 +479,26 @@ export class ClawdisApp extends LitElement {
|
|||||||
if (this.theme !== "system") return;
|
if (this.theme !== "system") return;
|
||||||
this.applyResolvedTheme(event.matches ? "dark" : "light");
|
this.applyResolvedTheme(event.matches ? "dark" : "light");
|
||||||
};
|
};
|
||||||
if ("addEventListener" in this.themeMedia) {
|
if (typeof this.themeMedia.addEventListener === "function") {
|
||||||
this.themeMedia.addEventListener("change", this.themeMediaHandler);
|
this.themeMedia.addEventListener("change", this.themeMediaHandler);
|
||||||
} else {
|
return;
|
||||||
this.themeMedia.addListener(this.themeMediaHandler);
|
|
||||||
}
|
}
|
||||||
|
const legacy = this.themeMedia as MediaQueryList & {
|
||||||
|
addListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||||
|
};
|
||||||
|
legacy.addListener(this.themeMediaHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
private detachThemeListener() {
|
private detachThemeListener() {
|
||||||
if (!this.themeMedia || !this.themeMediaHandler) return;
|
if (!this.themeMedia || !this.themeMediaHandler) return;
|
||||||
if ("removeEventListener" in this.themeMedia) {
|
if (typeof this.themeMedia.removeEventListener === "function") {
|
||||||
this.themeMedia.removeEventListener("change", this.themeMediaHandler);
|
this.themeMedia.removeEventListener("change", this.themeMediaHandler);
|
||||||
} else {
|
return;
|
||||||
this.themeMedia.removeListener(this.themeMediaHandler);
|
|
||||||
}
|
}
|
||||||
|
const legacy = this.themeMedia as MediaQueryList & {
|
||||||
|
removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
|
||||||
|
};
|
||||||
|
legacy.removeListener(this.themeMediaHandler);
|
||||||
this.themeMedia = null;
|
this.themeMedia = null;
|
||||||
this.themeMediaHandler = null;
|
this.themeMediaHandler = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export type ChatState = {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChatEventPayload = {
|
export type ChatEventPayload = {
|
||||||
runId: string;
|
runId: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
state: "delta" | "final" | "aborted" | "error";
|
state: "delta" | "final" | "aborted" | "error";
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
defaultDiscordActions,
|
defaultDiscordActions,
|
||||||
type DiscordActionForm,
|
type DiscordActionForm,
|
||||||
type DiscordForm,
|
type DiscordForm,
|
||||||
|
type DiscordGuildChannelForm,
|
||||||
|
type DiscordGuildForm,
|
||||||
type IMessageForm,
|
type IMessageForm,
|
||||||
type SignalForm,
|
type SignalForm,
|
||||||
type TelegramForm,
|
type TelegramForm,
|
||||||
@@ -96,6 +98,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
|||||||
const discordDm = (discord.dm ?? {}) as Record<string, unknown>;
|
const discordDm = (discord.dm ?? {}) as Record<string, unknown>;
|
||||||
const slash = (discord.slashCommand ?? {}) as Record<string, unknown>;
|
const slash = (discord.slashCommand ?? {}) as Record<string, unknown>;
|
||||||
const discordActions = (discord.actions ?? {}) as Record<string, unknown>;
|
const discordActions = (discord.actions ?? {}) as Record<string, unknown>;
|
||||||
|
const discordGuilds = discord.guilds;
|
||||||
const readAction = (key: keyof DiscordActionForm) =>
|
const readAction = (key: keyof DiscordActionForm) =>
|
||||||
typeof discordActions[key] === "boolean"
|
typeof discordActions[key] === "boolean"
|
||||||
? (discordActions[key] as boolean)
|
? (discordActions[key] as boolean)
|
||||||
@@ -103,6 +106,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
|||||||
state.discordForm = {
|
state.discordForm = {
|
||||||
enabled: typeof discord.enabled === "boolean" ? discord.enabled : true,
|
enabled: typeof discord.enabled === "boolean" ? discord.enabled : true,
|
||||||
token: typeof discord.token === "string" ? discord.token : "",
|
token: typeof discord.token === "string" ? discord.token : "",
|
||||||
|
dmEnabled: typeof discordDm.enabled === "boolean" ? discordDm.enabled : true,
|
||||||
allowFrom: toList(discordDm.allowFrom),
|
allowFrom: toList(discordDm.allowFrom),
|
||||||
groupEnabled:
|
groupEnabled:
|
||||||
typeof discordDm.groupEnabled === "boolean" ? discordDm.groupEnabled : false,
|
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) : "",
|
typeof discord.mediaMaxMb === "number" ? String(discord.mediaMaxMb) : "",
|
||||||
historyLimit:
|
historyLimit:
|
||||||
typeof discord.historyLimit === "number" ? String(discord.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: {
|
actions: {
|
||||||
reactions: readAction("reactions"),
|
reactions: readAction("reactions"),
|
||||||
stickers: readAction("stickers"),
|
stickers: readAction("stickers"),
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
defaultDiscordActions,
|
defaultDiscordActions,
|
||||||
type DiscordActionForm,
|
type DiscordActionForm,
|
||||||
type DiscordForm,
|
type DiscordForm,
|
||||||
|
type DiscordGuildChannelForm,
|
||||||
|
type DiscordGuildForm,
|
||||||
type IMessageForm,
|
type IMessageForm,
|
||||||
type SignalForm,
|
type SignalForm,
|
||||||
type TelegramForm,
|
type TelegramForm,
|
||||||
@@ -233,6 +235,8 @@ export async function saveDiscordConfig(state: ConnectionsState) {
|
|||||||
const allowFrom = parseList(form.allowFrom);
|
const allowFrom = parseList(form.allowFrom);
|
||||||
const groupChannels = parseList(form.groupChannels);
|
const groupChannels = parseList(form.groupChannels);
|
||||||
const dm = { ...(discord.dm ?? {}) } as Record<string, unknown>;
|
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;
|
if (allowFrom.length > 0) dm.allowFrom = allowFrom;
|
||||||
else delete dm.allowFrom;
|
else delete dm.allowFrom;
|
||||||
if (form.groupEnabled) dm.groupEnabled = true;
|
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 actions: Partial<DiscordActionForm> = {};
|
||||||
const applyAction = (key: keyof DiscordActionForm) => {
|
const applyAction = (key: keyof DiscordActionForm) => {
|
||||||
const value = form.actions[key];
|
const value = form.actions[key];
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ export type TelegramForm = {
|
|||||||
export type DiscordForm = {
|
export type DiscordForm = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
token: string;
|
token: string;
|
||||||
|
dmEnabled: boolean;
|
||||||
allowFrom: string;
|
allowFrom: string;
|
||||||
groupEnabled: boolean;
|
groupEnabled: boolean;
|
||||||
groupChannels: string;
|
groupChannels: string;
|
||||||
mediaMaxMb: string;
|
mediaMaxMb: string;
|
||||||
historyLimit: string;
|
historyLimit: string;
|
||||||
|
textChunkLimit: string;
|
||||||
|
replyToMode: "off" | "first" | "all";
|
||||||
|
guilds: DiscordGuildForm[];
|
||||||
actions: DiscordActionForm;
|
actions: DiscordActionForm;
|
||||||
slashEnabled: boolean;
|
slashEnabled: boolean;
|
||||||
slashName: string;
|
slashName: string;
|
||||||
@@ -23,6 +27,20 @@ export type DiscordForm = {
|
|||||||
slashEphemeral: boolean;
|
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 = {
|
export type DiscordActionForm = {
|
||||||
reactions: boolean;
|
reactions: boolean;
|
||||||
stickers: boolean;
|
stickers: boolean;
|
||||||
|
|||||||
+293
-18
@@ -10,6 +10,24 @@ import type {
|
|||||||
TelegramForm,
|
TelegramForm,
|
||||||
} from "../ui-types";
|
} 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 = {
|
export type ConnectionsProps = {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -54,24 +72,6 @@ export function renderConnections(props: ConnectionsProps) {
|
|||||||
const discord = props.snapshot?.discord ?? null;
|
const discord = props.snapshot?.discord ?? null;
|
||||||
const signal = props.snapshot?.signal ?? null;
|
const signal = props.snapshot?.signal ?? null;
|
||||||
const imessage = props.snapshot?.imessage ?? 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[] = [
|
const providerOrder: ProviderKey[] = [
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
"telegram",
|
"telegram",
|
||||||
@@ -500,6 +500,19 @@ function renderProvider(
|
|||||||
placeholder="123456789, username#1234"
|
placeholder="123456789, username#1234"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label class="field">
|
||||||
<span>Group DMs</span>
|
<span>Group DMs</span>
|
||||||
<select
|
<select
|
||||||
@@ -546,6 +559,268 @@ function renderProvider(
|
|||||||
placeholder="20"
|
placeholder="20"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Text chunk limit</span>
|
||||||
|
<input
|
||||||
|
.value=${props.discordForm.textChunkLimit}
|
||||||
|
@input=${(e: Event) =>
|
||||||
|
props.onDiscordChange({
|
||||||
|
textChunkLimit: (e.target as HTMLInputElement).value,
|
||||||
|
})}
|
||||||
|
placeholder="2000"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Reply to mode</span>
|
||||||
|
<select
|
||||||
|
.value=${props.discordForm.replyToMode}
|
||||||
|
@change=${(e: Event) =>
|
||||||
|
props.onDiscordChange({
|
||||||
|
replyToMode: (e.target as HTMLSelectElement).value as
|
||||||
|
| "off"
|
||||||
|
| "first"
|
||||||
|
| "all",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<option value="off">Off</option>
|
||||||
|
<option value="first">First</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="field full">
|
||||||
|
<span>Guilds</span>
|
||||||
|
<div class="card-sub">
|
||||||
|
Add each guild (id or slug) and optional channel rules. Empty channel
|
||||||
|
entries still allow that channel.
|
||||||
|
</div>
|
||||||
|
<div class="list">
|
||||||
|
${props.discordForm.guilds.map(
|
||||||
|
(guild, guildIndex) => html`
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-main">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>Guild id / slug</span>
|
||||||
|
<input
|
||||||
|
.value=${guild.key}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
const next = [...props.discordForm.guilds];
|
||||||
|
next[guildIndex] = {
|
||||||
|
...next[guildIndex],
|
||||||
|
key: (e.target as HTMLInputElement).value,
|
||||||
|
};
|
||||||
|
props.onDiscordChange({ guilds: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Slug</span>
|
||||||
|
<input
|
||||||
|
.value=${guild.slug}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
const next = [...props.discordForm.guilds];
|
||||||
|
next[guildIndex] = {
|
||||||
|
...next[guildIndex],
|
||||||
|
slug: (e.target as HTMLInputElement).value,
|
||||||
|
};
|
||||||
|
props.onDiscordChange({ guilds: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Require mention</span>
|
||||||
|
<select
|
||||||
|
.value=${guild.requireMention ? "yes" : "no"}
|
||||||
|
@change=${(e: Event) => {
|
||||||
|
const next = [...props.discordForm.guilds];
|
||||||
|
next[guildIndex] = {
|
||||||
|
...next[guildIndex],
|
||||||
|
requireMention:
|
||||||
|
(e.target as HTMLSelectElement).value === "yes",
|
||||||
|
};
|
||||||
|
props.onDiscordChange({ guilds: next });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
<option value="no">No</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Users allowlist</span>
|
||||||
|
<input
|
||||||
|
.value=${guild.users}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
const next = [...props.discordForm.guilds];
|
||||||
|
next[guildIndex] = {
|
||||||
|
...next[guildIndex],
|
||||||
|
users: (e.target as HTMLInputElement).value,
|
||||||
|
};
|
||||||
|
props.onDiscordChange({ guilds: next });
|
||||||
|
}}
|
||||||
|
placeholder="123456789, username#1234"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
${guild.channels.length
|
||||||
|
? html`
|
||||||
|
<div class="form-grid" style="margin-top: 8px;">
|
||||||
|
${guild.channels.map(
|
||||||
|
(channel, channelIndex) => html`
|
||||||
|
<label class="field">
|
||||||
|
<span>Channel id / slug</span>
|
||||||
|
<input
|
||||||
|
.value=${channel.key}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
const next = [...props.discordForm.guilds];
|
||||||
|
const channels = [
|
||||||
|
...(next[guildIndex].channels ?? []),
|
||||||
|
];
|
||||||
|
channels[channelIndex] = {
|
||||||
|
...channels[channelIndex],
|
||||||
|
key: (e.target as HTMLInputElement).value,
|
||||||
|
};
|
||||||
|
next[guildIndex] = {
|
||||||
|
...next[guildIndex],
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
props.onDiscordChange({ guilds: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Allow</span>
|
||||||
|
<select
|
||||||
|
.value=${channel.allow ? "yes" : "no"}
|
||||||
|
@change=${(e: Event) => {
|
||||||
|
const next = [...props.discordForm.guilds];
|
||||||
|
const channels = [
|
||||||
|
...(next[guildIndex].channels ?? []),
|
||||||
|
];
|
||||||
|
channels[channelIndex] = {
|
||||||
|
...channels[channelIndex],
|
||||||
|
allow:
|
||||||
|
(e.target as HTMLSelectElement).value ===
|
||||||
|
"yes",
|
||||||
|
};
|
||||||
|
next[guildIndex] = {
|
||||||
|
...next[guildIndex],
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
props.onDiscordChange({ guilds: next });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
<option value="no">No</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Require mention</span>
|
||||||
|
<select
|
||||||
|
.value=${channel.requireMention ? "yes" : "no"}
|
||||||
|
@change=${(e: Event) => {
|
||||||
|
const next = [...props.discordForm.guilds];
|
||||||
|
const channels = [
|
||||||
|
...(next[guildIndex].channels ?? []),
|
||||||
|
];
|
||||||
|
channels[channelIndex] = {
|
||||||
|
...channels[channelIndex],
|
||||||
|
requireMention:
|
||||||
|
(e.target as HTMLSelectElement).value ===
|
||||||
|
"yes",
|
||||||
|
};
|
||||||
|
next[guildIndex] = {
|
||||||
|
...next[guildIndex],
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
props.onDiscordChange({ guilds: next });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
<option value="no">No</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span> </span>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
@click=${() => {
|
||||||
|
const next = [
|
||||||
|
...props.discordForm.guilds,
|
||||||
|
];
|
||||||
|
const channels = [
|
||||||
|
...(next[guildIndex].channels ?? []),
|
||||||
|
];
|
||||||
|
channels.splice(channelIndex, 1);
|
||||||
|
next[guildIndex] = {
|
||||||
|
...next[guildIndex],
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
props.onDiscordChange({ guilds: next });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
<div class="list-meta">
|
||||||
|
<span>Channels</span>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
@click=${() => {
|
||||||
|
const next = [...props.discordForm.guilds];
|
||||||
|
const channels = [
|
||||||
|
...(next[guildIndex].channels ?? []),
|
||||||
|
{ key: "", allow: true, requireMention: false },
|
||||||
|
];
|
||||||
|
next[guildIndex] = {
|
||||||
|
...next[guildIndex],
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
props.onDiscordChange({ guilds: next });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add channel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn danger"
|
||||||
|
@click=${() => {
|
||||||
|
const next = [...props.discordForm.guilds];
|
||||||
|
next.splice(guildIndex, 1);
|
||||||
|
props.onDiscordChange({ guilds: next });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove guild
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
style="margin-top: 8px;"
|
||||||
|
@click=${() =>
|
||||||
|
props.onDiscordChange({
|
||||||
|
guilds: [
|
||||||
|
...props.discordForm.guilds,
|
||||||
|
{
|
||||||
|
key: "",
|
||||||
|
slug: "",
|
||||||
|
requireMention: false,
|
||||||
|
users: "",
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Add guild
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Slash command</span>
|
<span>Slash command</span>
|
||||||
<select
|
<select
|
||||||
|
|||||||
Reference in new issue
Block a user