agent-mockingbird 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/btca-cli/SKILL.md +64 -0
- package/.agents/skills/btca-cli/agents/openai.yaml +3 -0
- package/.agents/skills/frontend-design/SKILL.md +42 -0
- package/.agents/skills/frontend-design/agents/openai.yaml +3 -0
- package/.env.example +36 -0
- package/.githooks/pre-commit +33 -0
- package/.github/workflows/ci.yml +309 -0
- package/.opencode/bun.lock +18 -0
- package/.opencode/package.json +5 -0
- package/.opencode/tools/agent_type_manager.ts +100 -0
- package/.opencode/tools/config_manager.ts +87 -0
- package/.opencode/tools/cron_manager.ts +145 -0
- package/.opencode/tools/memory_get.ts +43 -0
- package/.opencode/tools/memory_remember.ts +53 -0
- package/.opencode/tools/memory_search.ts +48 -0
- package/AGENTS.md +126 -0
- package/MEMORY.md +2 -0
- package/README.md +451 -0
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/agent-mockingbird.config.example.json +135 -0
- package/apps/server/package.json +32 -0
- package/apps/server/src/backend/agents/bootstrapContext.ts +362 -0
- package/apps/server/src/backend/agents/openclawImport.test.ts +133 -0
- package/apps/server/src/backend/agents/openclawImport.ts +797 -0
- package/apps/server/src/backend/agents/opencodeConfig.ts +428 -0
- package/apps/server/src/backend/agents/service.ts +10 -0
- package/apps/server/src/backend/config/example-config.test.ts +20 -0
- package/apps/server/src/backend/config/orchestration.ts +243 -0
- package/apps/server/src/backend/config/policy.ts +158 -0
- package/apps/server/src/backend/config/schema.test.ts +15 -0
- package/apps/server/src/backend/config/schema.ts +391 -0
- package/apps/server/src/backend/config/semantic.test.ts +34 -0
- package/apps/server/src/backend/config/semantic.ts +149 -0
- package/apps/server/src/backend/config/service.test.ts +75 -0
- package/apps/server/src/backend/config/service.ts +207 -0
- package/apps/server/src/backend/config/smoke.ts +77 -0
- package/apps/server/src/backend/config/store.test.ts +123 -0
- package/apps/server/src/backend/config/store.ts +581 -0
- package/apps/server/src/backend/config/testFixtures.ts +5 -0
- package/apps/server/src/backend/config/types.ts +56 -0
- package/apps/server/src/backend/contracts/events.ts +320 -0
- package/apps/server/src/backend/contracts/runtime.ts +111 -0
- package/apps/server/src/backend/cron/executor.ts +435 -0
- package/apps/server/src/backend/cron/repository.ts +170 -0
- package/apps/server/src/backend/cron/service.ts +660 -0
- package/apps/server/src/backend/cron/storage.ts +92 -0
- package/apps/server/src/backend/cron/types.ts +138 -0
- package/apps/server/src/backend/cron/utils.ts +351 -0
- package/apps/server/src/backend/db/client.ts +20 -0
- package/apps/server/src/backend/db/migrate.ts +40 -0
- package/apps/server/src/backend/db/repository.ts +1762 -0
- package/apps/server/src/backend/db/schema.ts +113 -0
- package/apps/server/src/backend/db/usageDashboard.test.ts +102 -0
- package/apps/server/src/backend/db/wipe.ts +13 -0
- package/apps/server/src/backend/defaults.ts +32 -0
- package/apps/server/src/backend/env.ts +48 -0
- package/apps/server/src/backend/heartbeat/activeHours.ts +45 -0
- package/apps/server/src/backend/heartbeat/defaultJob.ts +88 -0
- package/apps/server/src/backend/heartbeat/heartbeat.test.ts +110 -0
- package/apps/server/src/backend/heartbeat/runtimeService.ts +190 -0
- package/apps/server/src/backend/heartbeat/service.ts +176 -0
- package/apps/server/src/backend/heartbeat/state.test.ts +63 -0
- package/apps/server/src/backend/heartbeat/state.ts +167 -0
- package/apps/server/src/backend/heartbeat/types.ts +54 -0
- package/apps/server/src/backend/http/boundedQueue.test.ts +49 -0
- package/apps/server/src/backend/http/boundedQueue.ts +92 -0
- package/apps/server/src/backend/http/parsers.ts +40 -0
- package/apps/server/src/backend/http/router.ts +61 -0
- package/apps/server/src/backend/http/routes/agentRoutes.ts +67 -0
- package/apps/server/src/backend/http/routes/backgroundRoutes.ts +203 -0
- package/apps/server/src/backend/http/routes/chatRoutes.ts +107 -0
- package/apps/server/src/backend/http/routes/configRoutes.ts +602 -0
- package/apps/server/src/backend/http/routes/cronRoutes.ts +221 -0
- package/apps/server/src/backend/http/routes/dashboardRoutes.ts +308 -0
- package/apps/server/src/backend/http/routes/eventRoutes.ts +7 -0
- package/apps/server/src/backend/http/routes/heartbeatRoutes.test.ts +41 -0
- package/apps/server/src/backend/http/routes/heartbeatRoutes.ts +28 -0
- package/apps/server/src/backend/http/routes/index.ts +101 -0
- package/apps/server/src/backend/http/routes/mcpRoutes.ts +213 -0
- package/apps/server/src/backend/http/routes/memoryRoutes.ts +154 -0
- package/apps/server/src/backend/http/routes/runRoutes.ts +310 -0
- package/apps/server/src/backend/http/routes/runtimeRoutes.ts +197 -0
- package/apps/server/src/backend/http/routes/skillRoutes.ts +112 -0
- package/apps/server/src/backend/http/routes/uiRoutes.test.ts +161 -0
- package/apps/server/src/backend/http/routes/uiRoutes.ts +177 -0
- package/apps/server/src/backend/http/routes/usageRoutes.test.ts +104 -0
- package/apps/server/src/backend/http/routes/usageRoutes.ts +767 -0
- package/apps/server/src/backend/http/schemas.ts +64 -0
- package/apps/server/src/backend/http/sse.ts +144 -0
- package/apps/server/src/backend/integration/backend-core.test.ts +2316 -0
- package/apps/server/src/backend/logging/logger.ts +64 -0
- package/apps/server/src/backend/mcp/service.ts +326 -0
- package/apps/server/src/backend/memory/cli.ts +170 -0
- package/apps/server/src/backend/memory/conceptExpansion.test.ts +28 -0
- package/apps/server/src/backend/memory/conceptExpansion.ts +80 -0
- package/apps/server/src/backend/memory/qmdPort.test.ts +54 -0
- package/apps/server/src/backend/memory/qmdPort.ts +61 -0
- package/apps/server/src/backend/memory/records.test.ts +66 -0
- package/apps/server/src/backend/memory/records.ts +229 -0
- package/apps/server/src/backend/memory/service.ts +2012 -0
- package/apps/server/src/backend/memory/sqliteVec.ts +58 -0
- package/apps/server/src/backend/memory/types.ts +104 -0
- package/apps/server/src/backend/opencode/agentMockingbirdPlugin.test.ts +396 -0
- package/apps/server/src/backend/opencode/client.ts +98 -0
- package/apps/server/src/backend/opencode/models.ts +41 -0
- package/apps/server/src/backend/opencode/systemPrompt.test.ts +146 -0
- package/apps/server/src/backend/opencode/systemPrompt.ts +284 -0
- package/apps/server/src/backend/paths.ts +57 -0
- package/apps/server/src/backend/prompts/service.ts +100 -0
- package/apps/server/src/backend/queue/queue.test.ts +189 -0
- package/apps/server/src/backend/queue/service.ts +177 -0
- package/apps/server/src/backend/queue/types.ts +39 -0
- package/apps/server/src/backend/run/service.ts +576 -0
- package/apps/server/src/backend/run/storage.ts +47 -0
- package/apps/server/src/backend/run/types.ts +44 -0
- package/apps/server/src/backend/runtime/errors.ts +61 -0
- package/apps/server/src/backend/runtime/index.ts +72 -0
- package/apps/server/src/backend/runtime/memoryPromptDedup.test.ts +153 -0
- package/apps/server/src/backend/runtime/memoryPromptDedup.ts +76 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/backgroundMethods.ts +765 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/coreMethods.ts +705 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/eventMethods.ts +503 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/memoryMethods.ts +462 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/promptMethods.ts +1167 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/shared.ts +254 -0
- package/apps/server/src/backend/runtime/opencodeRuntime.test.ts +2899 -0
- package/apps/server/src/backend/runtime/opencodeRuntime.ts +135 -0
- package/apps/server/src/backend/runtime/sessionScope.ts +45 -0
- package/apps/server/src/backend/skills/service.ts +442 -0
- package/apps/server/src/backend/workspace/resolve.ts +27 -0
- package/apps/server/src/cli/agent-mockingbird.mjs +2522 -0
- package/apps/server/src/cli/agent-mockingbird.test.ts +68 -0
- package/apps/server/src/cli/runtime-assets.mjs +269 -0
- package/apps/server/src/cli/runtime-assets.test.ts +52 -0
- package/apps/server/src/cli/runtime-layout.mjs +75 -0
- package/apps/server/src/cli/standaloneBuild.test.ts +19 -0
- package/apps/server/src/cli/standaloneBuild.ts +19 -0
- package/apps/server/src/cli/standaloneCronBinary.test.ts +187 -0
- package/apps/server/src/index.ts +178 -0
- package/apps/server/tsconfig.json +12 -0
- package/backlog.md +5 -0
- package/bin/agent-mockingbird +2522 -0
- package/bin/runtime-layout.mjs +75 -0
- package/build-bin.ts +34 -0
- package/build-cli.mjs +37 -0
- package/build.ts +40 -0
- package/bun-env.d.ts +11 -0
- package/bun.lock +888 -0
- package/bunfig.toml +2 -0
- package/components.json +21 -0
- package/config.json +130 -0
- package/deploy/RELEASE_INSTALL.md +112 -0
- package/deploy/docker-compose.yml +42 -0
- package/deploy/systemd/README.md +46 -0
- package/deploy/systemd/agent-mockingbird.service +28 -0
- package/deploy/systemd/opencode.service +25 -0
- package/docs/legacy-config-ui-reference.md +51 -0
- package/docs/memory-e2e-trace-2026-03-04.md +63 -0
- package/docs/memory-ops.md +96 -0
- package/docs/memory-runtime-contract.md +42 -0
- package/docs/memory-tuning-remote-2026-03-04.md +59 -0
- package/docs/opencode-rebase-workflow-plan.md +614 -0
- package/docs/opencode-startup-sync-plan.md +94 -0
- package/docs/vendor-opencode.md +41 -0
- package/drizzle/0000_famous_turbo.sql +49 -0
- package/drizzle/0001_cron_memory_aux.sql +160 -0
- package/drizzle/0002_runtime_session_bindings.sql +28 -0
- package/drizzle/0003_background_runs.sql +27 -0
- package/drizzle/0004_memory_open_write.sql +63 -0
- package/drizzle/0005_signal_channel.sql +47 -0
- package/drizzle/0006_usage_event_dimensions.sql +7 -0
- package/drizzle/meta/0000_snapshot.json +341 -0
- package/drizzle/meta/_journal.json +55 -0
- package/drizzle.config.ts +14 -0
- package/eslint.config.mjs +77 -0
- package/knip.json +18 -0
- package/memory/2026-03-04.md +4 -0
- package/opencode.lock.json +16 -0
- package/package.json +67 -0
- package/packages/agent-mockingbird-installer/README.md +31 -0
- package/packages/agent-mockingbird-installer/bin/agent-mockingbird-installer.mjs +44 -0
- package/packages/agent-mockingbird-installer/opencode.lock.json +16 -0
- package/packages/agent-mockingbird-installer/package.json +23 -0
- package/packages/contracts/package.json +19 -0
- package/packages/contracts/src/agentTypes.ts +122 -0
- package/packages/contracts/src/cron.ts +146 -0
- package/packages/contracts/src/dashboard.ts +378 -0
- package/packages/contracts/src/index.ts +3 -0
- package/packages/contracts/tsconfig.json +4 -0
- package/patches/opencode/0001-Wafflebot-OpenCode-baseline.patch +2341 -0
- package/patches/opencode/0002-Fix-OpenCode-web-entry-and-settings-icons.patch +104 -0
- package/patches/opencode/0003-fix-app-remove-duplicate-sidebar-mount.patch +32 -0
- package/patches/opencode/0004-Add-heartbeat-settings-and-usage-nav.patch +506 -0
- package/patches/opencode/0005-Use-chart-icon-for-usage-nav.patch +38 -0
- package/patches/opencode/0006-Modernize-cron-settings.patch +399 -0
- package/patches/opencode/0007-Rename-waffle-namespaces-to-mockingbird.patch +1110 -0
- package/patches/opencode/0008-Remove-cron-contract-section.patch +178 -0
- package/patches/opencode/0009-Rework-cron-tab-as-operations-console.patch +414 -0
- package/patches/opencode/0010-Refine-heartbeat-settings-controls.patch +208 -0
- package/runtime-assets/opencode-config/opencode.jsonc +25 -0
- package/runtime-assets/opencode-config/package.json +5 -0
- package/runtime-assets/opencode-config/plugins/agent-mockingbird.ts +715 -0
- package/runtime-assets/workspace/.agents/skills/config-auditor/SKILL.md +25 -0
- package/runtime-assets/workspace/.agents/skills/config-editor/SKILL.md +24 -0
- package/runtime-assets/workspace/.agents/skills/cron-manager/SKILL.md +57 -0
- package/runtime-assets/workspace/.agents/skills/memory-ops/SKILL.md +120 -0
- package/runtime-assets/workspace/.agents/skills/runtime-diagnose/SKILL.md +25 -0
- package/runtime-assets/workspace/AGENTS.md +56 -0
- package/runtime-assets/workspace/MEMORY.md +4 -0
- package/scripts/build-release-bundle.sh +66 -0
- package/scripts/check-ship.ts +383 -0
- package/scripts/dev-opencode.sh +17 -0
- package/scripts/dev-stack-opencode.sh +15 -0
- package/scripts/dev-stack.sh +61 -0
- package/scripts/install-systemd.sh +87 -0
- package/scripts/memory-e2e.sh +76 -0
- package/scripts/memory-trace-e2e.sh +141 -0
- package/scripts/migrate-opencode-env.ts +108 -0
- package/scripts/onboard/bootstrap.sh +32 -0
- package/scripts/opencode-swap.ts +78 -0
- package/scripts/opencode-sync.ts +715 -0
- package/scripts/runtime-assets-sync.mjs +83 -0
- package/scripts/setup-git-hooks.ts +39 -0
- package/tsconfig.json +45 -0
- package/tui.json +98 -0
- package/turbo.json +36 -0
- package/vendor/OPENCODE_VENDOR.md +13 -0
|
@@ -0,0 +1,2341 @@
|
|
|
1
|
+
From a8c5e7494994dfd5ba79be9777a67dd9ddb6257d Mon Sep 17 00:00:00 2001
|
|
2
|
+
From: Matt Campbell <matt@battleshopper.com>
|
|
3
|
+
Date: Mon, 16 Mar 2026 14:05:14 -0500
|
|
4
|
+
Subject: [PATCH 01/10] Wafflebot OpenCode baseline
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
packages/app/src/app.tsx | 7 +-
|
|
8
|
+
.../app/src/components/dialog-settings.tsx | 46 +++
|
|
9
|
+
.../app/src/components/settings-agents.tsx | 147 ++++++++
|
|
10
|
+
packages/app/src/components/settings-cron.tsx | 319 ++++++++++++++++++
|
|
11
|
+
packages/app/src/components/settings-mcp.tsx | 203 +++++++++++
|
|
12
|
+
.../app/src/components/settings-runtime.tsx | 120 +++++++
|
|
13
|
+
.../app/src/components/settings-skills.tsx | 256 ++++++++++++++
|
|
14
|
+
.../src/components/settings-waffle-shared.tsx | 116 +++++++
|
|
15
|
+
.../app/src/components/status-popover.tsx | 48 +--
|
|
16
|
+
packages/app/src/context/server.tsx | 17 +-
|
|
17
|
+
packages/app/src/entry.tsx | 31 +-
|
|
18
|
+
packages/app/src/i18n/en.ts | 8 +-
|
|
19
|
+
packages/app/src/pages/directory-layout.tsx | 11 +-
|
|
20
|
+
packages/app/src/pages/home.tsx | 140 ++------
|
|
21
|
+
packages/app/src/pages/layout.tsx | 89 ++---
|
|
22
|
+
.../app/src/pages/layout/sidebar-project.tsx | 23 --
|
|
23
|
+
.../app/src/pages/layout/sidebar-shell.tsx | 25 +-
|
|
24
|
+
packages/app/src/utils/waffle.ts | 61 ++++
|
|
25
|
+
packages/opencode/src/cli/cmd/providers.ts | 7 +-
|
|
26
|
+
packages/opencode/src/plugin/index.ts | 39 ++-
|
|
27
|
+
packages/opencode/src/provider/provider.ts | 1 +
|
|
28
|
+
packages/opencode/src/server/server.ts | 6 +-
|
|
29
|
+
.../test/cli/plugin-auth-picker.test.ts | 10 +
|
|
30
|
+
.../test/plugin/module-exports.test.ts | 39 +++
|
|
31
|
+
24 files changed, 1463 insertions(+), 306 deletions(-)
|
|
32
|
+
create mode 100644 packages/app/src/components/settings-agents.tsx
|
|
33
|
+
create mode 100644 packages/app/src/components/settings-cron.tsx
|
|
34
|
+
create mode 100644 packages/app/src/components/settings-mcp.tsx
|
|
35
|
+
create mode 100644 packages/app/src/components/settings-runtime.tsx
|
|
36
|
+
create mode 100644 packages/app/src/components/settings-skills.tsx
|
|
37
|
+
create mode 100644 packages/app/src/components/settings-waffle-shared.tsx
|
|
38
|
+
create mode 100644 packages/app/src/utils/waffle.ts
|
|
39
|
+
create mode 100644 packages/opencode/test/plugin/module-exports.test.ts
|
|
40
|
+
|
|
41
|
+
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
|
|
42
|
+
index e37086221..3dd67839b 100644
|
|
43
|
+
--- a/packages/app/src/app.tsx
|
|
44
|
+
+++ b/packages/app/src/app.tsx
|
|
45
|
+
@@ -269,11 +269,16 @@ export function AppInterface(props: {
|
|
46
|
+
children?: JSX.Element
|
|
47
|
+
defaultServer: ServerConnection.Key
|
|
48
|
+
servers?: Array<ServerConnection.Any>
|
|
49
|
+
+ lockServerSelection?: boolean
|
|
50
|
+
router?: Component<BaseRouterProps>
|
|
51
|
+
disableHealthCheck?: boolean
|
|
52
|
+
}) {
|
|
53
|
+
return (
|
|
54
|
+
- <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
|
55
|
+
+ <ServerProvider
|
|
56
|
+
+ defaultServer={props.defaultServer}
|
|
57
|
+
+ servers={props.servers}
|
|
58
|
+
+ locked={props.lockServerSelection}
|
|
59
|
+
+ >
|
|
60
|
+
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
|
61
|
+
<GlobalSDKProvider>
|
|
62
|
+
<GlobalSyncProvider>
|
|
63
|
+
diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx
|
|
64
|
+
index 83cea131f..005920ccb 100644
|
|
65
|
+
--- a/packages/app/src/components/dialog-settings.tsx
|
|
66
|
+
+++ b/packages/app/src/components/dialog-settings.tsx
|
|
67
|
+
@@ -8,6 +8,11 @@ import { SettingsGeneral } from "./settings-general"
|
|
68
|
+
import { SettingsKeybinds } from "./settings-keybinds"
|
|
69
|
+
import { SettingsProviders } from "./settings-providers"
|
|
70
|
+
import { SettingsModels } from "./settings-models"
|
|
71
|
+
+import { SettingsAgents } from "./settings-agents"
|
|
72
|
+
+import { SettingsMcp } from "./settings-mcp"
|
|
73
|
+
+import { SettingsSkills } from "./settings-skills"
|
|
74
|
+
+import { SettingsRuntime } from "./settings-runtime"
|
|
75
|
+
+import { SettingsCron } from "./settings-cron"
|
|
76
|
+
|
|
77
|
+
export const DialogSettings: Component = () => {
|
|
78
|
+
const language = useLanguage()
|
|
79
|
+
@@ -47,6 +52,32 @@ export const DialogSettings: Component = () => {
|
|
80
|
+
</Tabs.Trigger>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
+
|
|
84
|
+
+ <div class="flex flex-col gap-1.5">
|
|
85
|
+
+ <Tabs.SectionTitle>Agent Mockingbird</Tabs.SectionTitle>
|
|
86
|
+
+ <div class="flex flex-col gap-1.5 w-full">
|
|
87
|
+
+ <Tabs.Trigger value="agents">
|
|
88
|
+
+ <Icon name="sparkles" />
|
|
89
|
+
+ {language.t("settings.agents.title")}
|
|
90
|
+
+ </Tabs.Trigger>
|
|
91
|
+
+ <Tabs.Trigger value="mcp">
|
|
92
|
+
+ <Icon name="plug-2" />
|
|
93
|
+
+ {language.t("settings.mcp.title")}
|
|
94
|
+
+ </Tabs.Trigger>
|
|
95
|
+
+ <Tabs.Trigger value="skills">
|
|
96
|
+
+ <Icon name="book-open" />
|
|
97
|
+
+ {language.t("settings.skills.title")}
|
|
98
|
+
+ </Tabs.Trigger>
|
|
99
|
+
+ <Tabs.Trigger value="runtime">
|
|
100
|
+
+ <Icon name="terminal-square" />
|
|
101
|
+
+ {language.t("settings.runtime.title")}
|
|
102
|
+
+ </Tabs.Trigger>
|
|
103
|
+
+ <Tabs.Trigger value="cron">
|
|
104
|
+
+ <Icon name="clock-3" />
|
|
105
|
+
+ {language.t("settings.cron.title")}
|
|
106
|
+
+ </Tabs.Trigger>
|
|
107
|
+
+ </div>
|
|
108
|
+
+ </div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<div class="flex flex-col gap-1 pl-1 py-1 text-12-medium text-text-weak">
|
|
112
|
+
@@ -67,6 +98,21 @@ export const DialogSettings: Component = () => {
|
|
113
|
+
<Tabs.Content value="models" class="no-scrollbar">
|
|
114
|
+
<SettingsModels />
|
|
115
|
+
</Tabs.Content>
|
|
116
|
+
+ <Tabs.Content value="agents" class="no-scrollbar">
|
|
117
|
+
+ <SettingsAgents />
|
|
118
|
+
+ </Tabs.Content>
|
|
119
|
+
+ <Tabs.Content value="mcp" class="no-scrollbar">
|
|
120
|
+
+ <SettingsMcp />
|
|
121
|
+
+ </Tabs.Content>
|
|
122
|
+
+ <Tabs.Content value="skills" class="no-scrollbar">
|
|
123
|
+
+ <SettingsSkills />
|
|
124
|
+
+ </Tabs.Content>
|
|
125
|
+
+ <Tabs.Content value="runtime" class="no-scrollbar">
|
|
126
|
+
+ <SettingsRuntime />
|
|
127
|
+
+ </Tabs.Content>
|
|
128
|
+
+ <Tabs.Content value="cron" class="no-scrollbar">
|
|
129
|
+
+ <SettingsCron />
|
|
130
|
+
+ </Tabs.Content>
|
|
131
|
+
</Tabs>
|
|
132
|
+
</Dialog>
|
|
133
|
+
)
|
|
134
|
+
diff --git a/packages/app/src/components/settings-agents.tsx b/packages/app/src/components/settings-agents.tsx
|
|
135
|
+
new file mode 100644
|
|
136
|
+
index 000000000..dda48a075
|
|
137
|
+
--- /dev/null
|
|
138
|
+
+++ b/packages/app/src/components/settings-agents.tsx
|
|
139
|
+
@@ -0,0 +1,147 @@
|
|
140
|
+
+import { Button } from "@opencode-ai/ui/button"
|
|
141
|
+
+import { showToast } from "@opencode-ai/ui/toast"
|
|
142
|
+
+import { onMount, Show, type Component } from "solid-js"
|
|
143
|
+
+import { createStore } from "solid-js/store"
|
|
144
|
+
+import { prettyJson, waffleJson } from "@/utils/waffle"
|
|
145
|
+
+import { WaffleCard, WaffleMetaRow, WaffleNotice, WaffleSettingsPage, WaffleSettingsSection, WaffleTextArea, WaffleToolbar } from "./settings-waffle-shared"
|
|
146
|
+
+
|
|
147
|
+
+type AgentType = {
|
|
148
|
+
+ id: string
|
|
149
|
+
+ name?: string
|
|
150
|
+
+ description?: string
|
|
151
|
+
+ prompt?: string
|
|
152
|
+
+ model?: string
|
|
153
|
+
+ variant?: string
|
|
154
|
+
+ mode?: string
|
|
155
|
+
+ hidden?: boolean
|
|
156
|
+
+ disable?: boolean
|
|
157
|
+
+ temperature?: number
|
|
158
|
+
+ topP?: number
|
|
159
|
+
+ steps?: number
|
|
160
|
+
+ permission?: Record<string, unknown>
|
|
161
|
+
+ options?: Record<string, unknown>
|
|
162
|
+
+}
|
|
163
|
+
+
|
|
164
|
+
+type AgentsPayload = {
|
|
165
|
+
+ agentTypes: AgentType[]
|
|
166
|
+
+ hash: string
|
|
167
|
+
+ storage?: {
|
|
168
|
+
+ directory: string
|
|
169
|
+
+ configFilePath: string
|
|
170
|
+
+ persistenceMode: string
|
|
171
|
+
+ }
|
|
172
|
+
+}
|
|
173
|
+
+
|
|
174
|
+
+export const SettingsAgents: Component = () => {
|
|
175
|
+
+ const [state, setState] = createStore({
|
|
176
|
+
+ loading: true,
|
|
177
|
+
+ saving: false,
|
|
178
|
+
+ error: "",
|
|
179
|
+
+ payload: null as AgentsPayload | null,
|
|
180
|
+
+ text: "[]",
|
|
181
|
+
+ })
|
|
182
|
+
+
|
|
183
|
+
+ async function load() {
|
|
184
|
+
+ setState("loading", true)
|
|
185
|
+
+ setState("error", "")
|
|
186
|
+
+ try {
|
|
187
|
+
+ const payload = await waffleJson<AgentsPayload>("/api/waffle/agents")
|
|
188
|
+
+ setState("payload", payload)
|
|
189
|
+
+ setState("text", prettyJson(payload.agentTypes))
|
|
190
|
+
+ } catch (error) {
|
|
191
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to load agent definitions")
|
|
192
|
+
+ } finally {
|
|
193
|
+
+ setState("loading", false)
|
|
194
|
+
+ }
|
|
195
|
+
+ }
|
|
196
|
+
+
|
|
197
|
+
+ async function save() {
|
|
198
|
+
+ let parsed: AgentType[]
|
|
199
|
+
+ try {
|
|
200
|
+
+ parsed = JSON.parse(state.text) as AgentType[]
|
|
201
|
+
+ } catch (error) {
|
|
202
|
+
+ setState("error", error instanceof Error ? error.message : "Invalid agent JSON")
|
|
203
|
+
+ return
|
|
204
|
+
+ }
|
|
205
|
+
+
|
|
206
|
+
+ if (!Array.isArray(parsed)) {
|
|
207
|
+
+ setState("error", "Agent payload must be a JSON array.")
|
|
208
|
+
+ return
|
|
209
|
+
+ }
|
|
210
|
+
+
|
|
211
|
+
+ const previous = state.payload?.agentTypes ?? []
|
|
212
|
+
+ const previousIDs = new Set(previous.map((item) => item.id))
|
|
213
|
+
+ const nextIDs = new Set(parsed.map((item) => item.id))
|
|
214
|
+
+ const deletes = [...previousIDs].filter((id) => !nextIDs.has(id))
|
|
215
|
+
+
|
|
216
|
+
+ setState("saving", true)
|
|
217
|
+
+ setState("error", "")
|
|
218
|
+
+ try {
|
|
219
|
+
+ const payload = await waffleJson<AgentsPayload>("/api/waffle/agents", {
|
|
220
|
+
+ method: "PATCH",
|
|
221
|
+
+ body: JSON.stringify({
|
|
222
|
+
+ upserts: parsed,
|
|
223
|
+
+ deletes,
|
|
224
|
+
+ expectedHash: state.payload?.hash,
|
|
225
|
+
+ }),
|
|
226
|
+
+ })
|
|
227
|
+
+ setState("payload", payload)
|
|
228
|
+
+ setState("text", prettyJson(payload.agentTypes))
|
|
229
|
+
+ showToast({
|
|
230
|
+
+ variant: "success",
|
|
231
|
+
+ icon: "circle-check",
|
|
232
|
+
+ title: "Agent definitions saved",
|
|
233
|
+
+ })
|
|
234
|
+
+ } catch (error) {
|
|
235
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to save agent definitions")
|
|
236
|
+
+ } finally {
|
|
237
|
+
+ setState("saving", false)
|
|
238
|
+
+ }
|
|
239
|
+
+ }
|
|
240
|
+
+
|
|
241
|
+
+ onMount(() => {
|
|
242
|
+
+ void load()
|
|
243
|
+
+ })
|
|
244
|
+
+
|
|
245
|
+
+ return (
|
|
246
|
+
+ <WaffleSettingsPage
|
|
247
|
+
+ title="Agents"
|
|
248
|
+
+ description="Manage OpenCode agent definitions stored in the pinned workspace's .opencode/opencode.jsonc."
|
|
249
|
+
+ actions={
|
|
250
|
+
+ <WaffleToolbar>
|
|
251
|
+
+ <Button variant="ghost" size="large" onClick={() => void load()} disabled={state.loading || state.saving}>
|
|
252
|
+
+ Refresh
|
|
253
|
+
+ </Button>
|
|
254
|
+
+ <Button size="large" onClick={() => void save()} disabled={state.loading || state.saving}>
|
|
255
|
+
+ Save
|
|
256
|
+
+ </Button>
|
|
257
|
+
+ </WaffleToolbar>
|
|
258
|
+
+ }
|
|
259
|
+
+ >
|
|
260
|
+
+ <Show when={state.error}>
|
|
261
|
+
+ <WaffleNotice tone="error">{state.error}</WaffleNotice>
|
|
262
|
+
+ </Show>
|
|
263
|
+
+
|
|
264
|
+
+ <Show when={state.payload?.storage}>
|
|
265
|
+
+ {(storage) => (
|
|
266
|
+
+ <WaffleSettingsSection title="Storage">
|
|
267
|
+
+ <WaffleMetaRow label="Workspace directory" value={storage().directory} />
|
|
268
|
+
+ <WaffleMetaRow label="Config file" value={storage().configFilePath} />
|
|
269
|
+
+ <WaffleMetaRow label="Persistence mode" value={storage().persistenceMode} />
|
|
270
|
+
+ </WaffleSettingsSection>
|
|
271
|
+
+ )}
|
|
272
|
+
+ </Show>
|
|
273
|
+
+
|
|
274
|
+
+ <WaffleSettingsSection title="Agent editor" description="Edit the current agent type array as JSON.">
|
|
275
|
+
+ <WaffleCard>
|
|
276
|
+
+ <WaffleTextArea
|
|
277
|
+
+ rows={22}
|
|
278
|
+
+ value={state.text}
|
|
279
|
+
+ onInput={(event) => setState("text", event.currentTarget.value)}
|
|
280
|
+
+ spellcheck={false}
|
|
281
|
+
+ />
|
|
282
|
+
+ </WaffleCard>
|
|
283
|
+
+ </WaffleSettingsSection>
|
|
284
|
+
+ </WaffleSettingsPage>
|
|
285
|
+
+ )
|
|
286
|
+
+}
|
|
287
|
+
diff --git a/packages/app/src/components/settings-cron.tsx b/packages/app/src/components/settings-cron.tsx
|
|
288
|
+
new file mode 100644
|
|
289
|
+
index 000000000..8bda2bf9c
|
|
290
|
+
--- /dev/null
|
|
291
|
+
+++ b/packages/app/src/components/settings-cron.tsx
|
|
292
|
+
@@ -0,0 +1,319 @@
|
|
293
|
+
+import { Button } from "@opencode-ai/ui/button"
|
|
294
|
+
+import { showToast } from "@opencode-ai/ui/toast"
|
|
295
|
+
+import { createSignal, For, onMount, Show, type Component } from "solid-js"
|
|
296
|
+
+import { createStore } from "solid-js/store"
|
|
297
|
+
+import { prettyJson, waffleJson } from "@/utils/waffle"
|
|
298
|
+
+import { WaffleCard, WaffleMetaRow, WaffleNotice, WaffleSettingsPage, WaffleSettingsSection, WaffleTextArea, WaffleToolbar } from "./settings-waffle-shared"
|
|
299
|
+
+
|
|
300
|
+
+type CronJobDefinition = {
|
|
301
|
+
+ id: string
|
|
302
|
+
+ name: string
|
|
303
|
+
+ enabled: boolean
|
|
304
|
+
+ scheduleKind: "at" | "every" | "cron"
|
|
305
|
+
+ scheduleExpr: string | null
|
|
306
|
+
+ everyMs: number | null
|
|
307
|
+
+ atIso: string | null
|
|
308
|
+
+ timezone: string | null
|
|
309
|
+
+ runMode: "background" | "conditional_agent" | "agent"
|
|
310
|
+
+ handlerKey: string | null
|
|
311
|
+
+ conditionModulePath: string | null
|
|
312
|
+
+ conditionDescription: string | null
|
|
313
|
+
+ agentPromptTemplate: string | null
|
|
314
|
+
+ agentModelOverride: string | null
|
|
315
|
+
+ maxAttempts: number
|
|
316
|
+
+ retryBackoffMs: number
|
|
317
|
+
+ payload: Record<string, unknown>
|
|
318
|
+
+}
|
|
319
|
+
+
|
|
320
|
+
+type CronHealthSnapshot = {
|
|
321
|
+
+ enabled: boolean
|
|
322
|
+
+ jobs: {
|
|
323
|
+
+ total: number
|
|
324
|
+
+ enabled: number
|
|
325
|
+
+ }
|
|
326
|
+
+}
|
|
327
|
+
+
|
|
328
|
+
+const DEFAULT_JOB = prettyJson({
|
|
329
|
+
+ name: "Daily summary",
|
|
330
|
+
+ enabled: true,
|
|
331
|
+
+ scheduleKind: "cron",
|
|
332
|
+
+ scheduleExpr: "0 9 * * *",
|
|
333
|
+
+ timezone: "America/Phoenix",
|
|
334
|
+
+ runMode: "background",
|
|
335
|
+
+ handlerKey: "daily_summary",
|
|
336
|
+
+ payload: {},
|
|
337
|
+
+})
|
|
338
|
+
+
|
|
339
|
+
+export const SettingsCron: Component = () => {
|
|
340
|
+
+ const [state, setState] = createStore({
|
|
341
|
+
+ loading: true,
|
|
342
|
+
+ saving: false,
|
|
343
|
+
+ busyId: "",
|
|
344
|
+
+ error: "",
|
|
345
|
+
+ jobs: [] as CronJobDefinition[],
|
|
346
|
+
+ handlers: [] as string[],
|
|
347
|
+
+ health: null as CronHealthSnapshot | null,
|
|
348
|
+
+ createText: DEFAULT_JOB,
|
|
349
|
+
+ editingId: "",
|
|
350
|
+
+ editText: "",
|
|
351
|
+
+ })
|
|
352
|
+
+ const [refreshTick, setRefreshTick] = createSignal(0)
|
|
353
|
+
+
|
|
354
|
+
+ async function load() {
|
|
355
|
+
+ refreshTick()
|
|
356
|
+
+ setState("loading", true)
|
|
357
|
+
+ setState("error", "")
|
|
358
|
+
+ try {
|
|
359
|
+
+ const [jobs, handlers, health] = await Promise.all([
|
|
360
|
+
+ waffleJson<{ jobs: CronJobDefinition[] }>("/api/waffle/cron/jobs"),
|
|
361
|
+
+ waffleJson<{ handlers: string[] }>("/api/waffle/cron/handlers"),
|
|
362
|
+
+ waffleJson<{ health: CronHealthSnapshot }>("/api/waffle/cron/health"),
|
|
363
|
+
+ ])
|
|
364
|
+
+ setState("jobs", jobs.jobs)
|
|
365
|
+
+ setState("handlers", handlers.handlers)
|
|
366
|
+
+ setState("health", health.health)
|
|
367
|
+
+ } catch (error) {
|
|
368
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to load cron jobs")
|
|
369
|
+
+ } finally {
|
|
370
|
+
+ setState("loading", false)
|
|
371
|
+
+ }
|
|
372
|
+
+ }
|
|
373
|
+
+
|
|
374
|
+
+ async function refresh() {
|
|
375
|
+
+ setRefreshTick((value) => value + 1)
|
|
376
|
+
+ await load()
|
|
377
|
+
+ }
|
|
378
|
+
+
|
|
379
|
+
+ async function createJob() {
|
|
380
|
+
+ let payload: unknown
|
|
381
|
+
+ try {
|
|
382
|
+
+ payload = JSON.parse(state.createText)
|
|
383
|
+
+ } catch (error) {
|
|
384
|
+
+ setState("error", error instanceof Error ? error.message : "Invalid cron job JSON")
|
|
385
|
+
+ return
|
|
386
|
+
+ }
|
|
387
|
+
+
|
|
388
|
+
+ setState("saving", true)
|
|
389
|
+
+ setState("error", "")
|
|
390
|
+
+ try {
|
|
391
|
+
+ await waffleJson("/api/waffle/cron/jobs", {
|
|
392
|
+
+ method: "POST",
|
|
393
|
+
+ body: JSON.stringify(payload),
|
|
394
|
+
+ })
|
|
395
|
+
+ showToast({
|
|
396
|
+
+ variant: "success",
|
|
397
|
+
+ icon: "circle-check",
|
|
398
|
+
+ title: "Cron job created",
|
|
399
|
+
+ })
|
|
400
|
+
+ await refresh()
|
|
401
|
+
+ } catch (error) {
|
|
402
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to create cron job")
|
|
403
|
+
+ } finally {
|
|
404
|
+
+ setState("saving", false)
|
|
405
|
+
+ }
|
|
406
|
+
+ }
|
|
407
|
+
+
|
|
408
|
+
+ async function saveEdit() {
|
|
409
|
+
+ if (!state.editingId) return
|
|
410
|
+
+
|
|
411
|
+
+ let payload: unknown
|
|
412
|
+
+ try {
|
|
413
|
+
+ payload = JSON.parse(state.editText)
|
|
414
|
+
+ } catch (error) {
|
|
415
|
+
+ setState("error", error instanceof Error ? error.message : "Invalid cron patch JSON")
|
|
416
|
+
+ return
|
|
417
|
+
+ }
|
|
418
|
+
+
|
|
419
|
+
+ setState("busyId", state.editingId)
|
|
420
|
+
+ setState("error", "")
|
|
421
|
+
+ try {
|
|
422
|
+
+ await waffleJson(`/api/waffle/cron/jobs/${encodeURIComponent(state.editingId)}`, {
|
|
423
|
+
+ method: "PATCH",
|
|
424
|
+
+ body: JSON.stringify(payload),
|
|
425
|
+
+ })
|
|
426
|
+
+ setState("editingId", "")
|
|
427
|
+
+ setState("editText", "")
|
|
428
|
+
+ await refresh()
|
|
429
|
+
+ } catch (error) {
|
|
430
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to update cron job")
|
|
431
|
+
+ } finally {
|
|
432
|
+
+ setState("busyId", "")
|
|
433
|
+
+ }
|
|
434
|
+
+ }
|
|
435
|
+
+
|
|
436
|
+
+ async function runNow(id: string) {
|
|
437
|
+
+ setState("busyId", id)
|
|
438
|
+
+ try {
|
|
439
|
+
+ await waffleJson(`/api/waffle/cron/jobs/${encodeURIComponent(id)}/run`, {
|
|
440
|
+
+ method: "POST",
|
|
441
|
+
+ })
|
|
442
|
+
+ showToast({
|
|
443
|
+
+ variant: "success",
|
|
444
|
+
+ icon: "circle-check",
|
|
445
|
+
+ title: "Cron run queued",
|
|
446
|
+
+ })
|
|
447
|
+
+ } catch (error) {
|
|
448
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to queue cron run")
|
|
449
|
+
+ } finally {
|
|
450
|
+
+ setState("busyId", "")
|
|
451
|
+
+ }
|
|
452
|
+
+ }
|
|
453
|
+
+
|
|
454
|
+
+ async function remove(id: string) {
|
|
455
|
+
+ setState("busyId", id)
|
|
456
|
+
+ try {
|
|
457
|
+
+ await waffleJson(`/api/waffle/cron/jobs/${encodeURIComponent(id)}`, {
|
|
458
|
+
+ method: "DELETE",
|
|
459
|
+
+ })
|
|
460
|
+
+ await refresh()
|
|
461
|
+
+ } catch (error) {
|
|
462
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to delete cron job")
|
|
463
|
+
+ } finally {
|
|
464
|
+
+ setState("busyId", "")
|
|
465
|
+
+ }
|
|
466
|
+
+ }
|
|
467
|
+
+
|
|
468
|
+
+ onMount(() => {
|
|
469
|
+
+ void load()
|
|
470
|
+
+ })
|
|
471
|
+
+
|
|
472
|
+
+ return (
|
|
473
|
+
+ <WaffleSettingsPage
|
|
474
|
+
+ title="Cron"
|
|
475
|
+
+ description="Inspect cron health, create jobs, update jobs with JSON patches, and run them immediately."
|
|
476
|
+
+ actions={
|
|
477
|
+
+ <WaffleToolbar>
|
|
478
|
+
+ <Button variant="ghost" size="large" onClick={() => void refresh()} disabled={state.loading || state.saving}>
|
|
479
|
+
+ Refresh
|
|
480
|
+
+ </Button>
|
|
481
|
+
+ </WaffleToolbar>
|
|
482
|
+
+ }
|
|
483
|
+
+ >
|
|
484
|
+
+ <Show when={state.error}>
|
|
485
|
+
+ <WaffleNotice tone="error">{state.error}</WaffleNotice>
|
|
486
|
+
+ </Show>
|
|
487
|
+
+
|
|
488
|
+
+ <Show when={state.health}>
|
|
489
|
+
+ {(health) => (
|
|
490
|
+
+ <WaffleSettingsSection title="Cron health">
|
|
491
|
+
+ <WaffleMetaRow label="Enabled" value={health().enabled ? "Yes" : "No"} />
|
|
492
|
+
+ <WaffleMetaRow label="Total jobs" value={String(health().jobs.total)} />
|
|
493
|
+
+ <WaffleMetaRow label="Enabled jobs" value={String(health().jobs.enabled)} />
|
|
494
|
+
+ </WaffleSettingsSection>
|
|
495
|
+
+ )}
|
|
496
|
+
+ </Show>
|
|
497
|
+
+
|
|
498
|
+
+ <WaffleSettingsSection title="Registered handlers">
|
|
499
|
+
+ <div class="px-4 py-3 text-13-regular text-text-strong break-words">
|
|
500
|
+
+ <Show when={state.handlers.length > 0} fallback={<span class="text-text-weak">No handlers registered.</span>}>
|
|
501
|
+
+ {state.handlers.join(", ")}
|
|
502
|
+
+ </Show>
|
|
503
|
+
+ </div>
|
|
504
|
+
+ </WaffleSettingsSection>
|
|
505
|
+
+
|
|
506
|
+
+ <WaffleSettingsSection title="Jobs">
|
|
507
|
+
+ <Show
|
|
508
|
+
+ when={state.jobs.length > 0}
|
|
509
|
+
+ fallback={<div class="px-4 py-4 text-14-regular text-text-weak">No cron jobs configured.</div>}
|
|
510
|
+
+ >
|
|
511
|
+
+ <For each={state.jobs}>
|
|
512
|
+
+ {(job) => (
|
|
513
|
+
+ <div class="px-4 py-4 border-b border-border-weak-base last:border-none flex flex-col gap-3">
|
|
514
|
+
+ <div class="flex flex-wrap items-start justify-between gap-3">
|
|
515
|
+
+ <div class="flex flex-col gap-1">
|
|
516
|
+
+ <div class="text-14-medium text-text-strong">{job.name}</div>
|
|
517
|
+
+ <div class="text-12-regular text-text-weak">
|
|
518
|
+
+ {job.id} | {job.scheduleKind} | {job.runMode}
|
|
519
|
+
+ </div>
|
|
520
|
+
+ <div class="text-12-regular text-text-weak">
|
|
521
|
+
+ {job.scheduleExpr ?? job.everyMs ?? job.atIso ?? "No schedule"}
|
|
522
|
+
+ </div>
|
|
523
|
+
+ </div>
|
|
524
|
+
+ <div class="flex flex-wrap items-center gap-2">
|
|
525
|
+
+ <Button
|
|
526
|
+
+ variant="ghost"
|
|
527
|
+
+ size="large"
|
|
528
|
+
+ onClick={() => {
|
|
529
|
+
+ setState("editingId", state.editingId === job.id ? "" : job.id)
|
|
530
|
+
+ setState(
|
|
531
|
+
+ "editText",
|
|
532
|
+
+ state.editingId === job.id
|
|
533
|
+
+ ? ""
|
|
534
|
+
+ : prettyJson({
|
|
535
|
+
+ name: job.name,
|
|
536
|
+
+ enabled: job.enabled,
|
|
537
|
+
+ scheduleKind: job.scheduleKind,
|
|
538
|
+
+ scheduleExpr: job.scheduleExpr,
|
|
539
|
+
+ everyMs: job.everyMs,
|
|
540
|
+
+ atIso: job.atIso,
|
|
541
|
+
+ timezone: job.timezone,
|
|
542
|
+
+ runMode: job.runMode,
|
|
543
|
+
+ handlerKey: job.handlerKey,
|
|
544
|
+
+ conditionModulePath: job.conditionModulePath,
|
|
545
|
+
+ conditionDescription: job.conditionDescription,
|
|
546
|
+
+ agentPromptTemplate: job.agentPromptTemplate,
|
|
547
|
+
+ agentModelOverride: job.agentModelOverride,
|
|
548
|
+
+ maxAttempts: job.maxAttempts,
|
|
549
|
+
+ retryBackoffMs: job.retryBackoffMs,
|
|
550
|
+
+ payload: job.payload,
|
|
551
|
+
+ }),
|
|
552
|
+
+ )
|
|
553
|
+
+ }}
|
|
554
|
+
+ >
|
|
555
|
+
+ {state.editingId === job.id ? "Close editor" : "Edit JSON"}
|
|
556
|
+
+ </Button>
|
|
557
|
+
+ <Button
|
|
558
|
+
+ variant="ghost"
|
|
559
|
+
+ size="large"
|
|
560
|
+
+ onClick={() => void runNow(job.id)}
|
|
561
|
+
+ disabled={state.busyId === job.id}
|
|
562
|
+
+ >
|
|
563
|
+
+ Run now
|
|
564
|
+
+ </Button>
|
|
565
|
+
+ <Button
|
|
566
|
+
+ variant="ghost"
|
|
567
|
+
+ size="large"
|
|
568
|
+
+ onClick={() => void remove(job.id)}
|
|
569
|
+
+ disabled={state.busyId === job.id}
|
|
570
|
+
+ >
|
|
571
|
+
+ Delete
|
|
572
|
+
+ </Button>
|
|
573
|
+
+ </div>
|
|
574
|
+
+ </div>
|
|
575
|
+
+
|
|
576
|
+
+ <Show when={state.editingId === job.id}>
|
|
577
|
+
+ <div class="flex flex-col gap-3">
|
|
578
|
+
+ <WaffleTextArea
|
|
579
|
+
+ rows={12}
|
|
580
|
+
+ value={state.editText}
|
|
581
|
+
+ onInput={(event) => setState("editText", event.currentTarget.value)}
|
|
582
|
+
+ />
|
|
583
|
+
+ <div class="flex justify-end">
|
|
584
|
+
+ <Button size="large" onClick={() => void saveEdit()} disabled={state.busyId === job.id}>
|
|
585
|
+
+ Save patch
|
|
586
|
+
+ </Button>
|
|
587
|
+
+ </div>
|
|
588
|
+
+ </div>
|
|
589
|
+
+ </Show>
|
|
590
|
+
+ </div>
|
|
591
|
+
+ )}
|
|
592
|
+
+ </For>
|
|
593
|
+
+ </Show>
|
|
594
|
+
+ </WaffleSettingsSection>
|
|
595
|
+
+
|
|
596
|
+
+ <WaffleSettingsSection title="Create job" description="Post a complete cron job definition as JSON.">
|
|
597
|
+
+ <WaffleCard
|
|
598
|
+
+ footer={
|
|
599
|
+
+ <div class="flex justify-end">
|
|
600
|
+
+ <Button size="large" onClick={() => void createJob()} disabled={state.saving}>
|
|
601
|
+
+ Create job
|
|
602
|
+
+ </Button>
|
|
603
|
+
+ </div>
|
|
604
|
+
+ }
|
|
605
|
+
+ >
|
|
606
|
+
+ <WaffleTextArea rows={16} value={state.createText} onInput={(event) => setState("createText", event.currentTarget.value)} />
|
|
607
|
+
+ </WaffleCard>
|
|
608
|
+
+ </WaffleSettingsSection>
|
|
609
|
+
+ </WaffleSettingsPage>
|
|
610
|
+
+ )
|
|
611
|
+
+}
|
|
612
|
+
diff --git a/packages/app/src/components/settings-mcp.tsx b/packages/app/src/components/settings-mcp.tsx
|
|
613
|
+
new file mode 100644
|
|
614
|
+
index 000000000..88abc0a06
|
|
615
|
+
--- /dev/null
|
|
616
|
+
+++ b/packages/app/src/components/settings-mcp.tsx
|
|
617
|
+
@@ -0,0 +1,203 @@
|
|
618
|
+
+import { Button } from "@opencode-ai/ui/button"
|
|
619
|
+
+import { showToast } from "@opencode-ai/ui/toast"
|
|
620
|
+
+import { For, onMount, Show, type Component } from "solid-js"
|
|
621
|
+
+import { createStore } from "solid-js/store"
|
|
622
|
+
+import { prettyJson, waffleJson } from "@/utils/waffle"
|
|
623
|
+
+import { WaffleCard, WaffleNotice, WaffleSettingsPage, WaffleSettingsSection, WaffleTextArea, WaffleToolbar } from "./settings-waffle-shared"
|
|
624
|
+
+
|
|
625
|
+
+type McpServer =
|
|
626
|
+
+ | {
|
|
627
|
+
+ id: string
|
|
628
|
+
+ type: "remote"
|
|
629
|
+
+ enabled: boolean
|
|
630
|
+
+ url: string
|
|
631
|
+
+ headers?: Record<string, string>
|
|
632
|
+
+ oauth?: "auto" | "off"
|
|
633
|
+
+ timeoutMs?: number
|
|
634
|
+
+ }
|
|
635
|
+
+ | {
|
|
636
|
+
+ id: string
|
|
637
|
+
+ type: "local"
|
|
638
|
+
+ enabled: boolean
|
|
639
|
+
+ command: string[]
|
|
640
|
+
+ environment?: Record<string, string>
|
|
641
|
+
+ timeoutMs?: number
|
|
642
|
+
+ }
|
|
643
|
+
+
|
|
644
|
+
+type McpStatusMap = Record<
|
|
645
|
+
+ string,
|
|
646
|
+
+ {
|
|
647
|
+
+ status?: "connected" | "disabled" | "failed" | "needs_auth" | "needs_client_registration" | "unknown"
|
|
648
|
+
+ error?: string
|
|
649
|
+
+ }
|
|
650
|
+
+>
|
|
651
|
+
+
|
|
652
|
+
+type McpPayload = {
|
|
653
|
+
+ servers: McpServer[]
|
|
654
|
+
+ status: McpStatusMap
|
|
655
|
+
+}
|
|
656
|
+
+
|
|
657
|
+
+export const SettingsMcp: Component = () => {
|
|
658
|
+
+ const [state, setState] = createStore({
|
|
659
|
+
+ loading: true,
|
|
660
|
+
+ saving: false,
|
|
661
|
+
+ busyId: "",
|
|
662
|
+
+ error: "",
|
|
663
|
+
+ payload: null as McpPayload | null,
|
|
664
|
+
+ text: "[]",
|
|
665
|
+
+ })
|
|
666
|
+
+
|
|
667
|
+
+ async function load() {
|
|
668
|
+
+ setState("loading", true)
|
|
669
|
+
+ setState("error", "")
|
|
670
|
+
+ try {
|
|
671
|
+
+ const payload = await waffleJson<McpPayload>("/api/waffle/mcp")
|
|
672
|
+
+ setState("payload", payload)
|
|
673
|
+
+ setState("text", prettyJson(payload.servers))
|
|
674
|
+
+ } catch (error) {
|
|
675
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to load MCP config")
|
|
676
|
+
+ } finally {
|
|
677
|
+
+ setState("loading", false)
|
|
678
|
+
+ }
|
|
679
|
+
+ }
|
|
680
|
+
+
|
|
681
|
+
+ async function save() {
|
|
682
|
+
+ let parsed: McpServer[]
|
|
683
|
+
+ try {
|
|
684
|
+
+ parsed = JSON.parse(state.text) as McpServer[]
|
|
685
|
+
+ } catch (error) {
|
|
686
|
+
+ setState("error", error instanceof Error ? error.message : "Invalid MCP JSON")
|
|
687
|
+
+ return
|
|
688
|
+
+ }
|
|
689
|
+
+
|
|
690
|
+
+ if (!Array.isArray(parsed)) {
|
|
691
|
+
+ setState("error", "MCP payload must be a JSON array.")
|
|
692
|
+
+ return
|
|
693
|
+
+ }
|
|
694
|
+
+
|
|
695
|
+
+ setState("saving", true)
|
|
696
|
+
+ setState("error", "")
|
|
697
|
+
+ try {
|
|
698
|
+
+ const payload = await waffleJson<McpPayload>("/api/waffle/mcp", {
|
|
699
|
+
+ method: "PUT",
|
|
700
|
+
+ body: JSON.stringify({ servers: parsed }),
|
|
701
|
+
+ })
|
|
702
|
+
+ setState("payload", payload)
|
|
703
|
+
+ setState("text", prettyJson(payload.servers))
|
|
704
|
+
+ showToast({
|
|
705
|
+
+ variant: "success",
|
|
706
|
+
+ icon: "circle-check",
|
|
707
|
+
+ title: "MCP config saved",
|
|
708
|
+
+ })
|
|
709
|
+
+ } catch (error) {
|
|
710
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to save MCP config")
|
|
711
|
+
+ } finally {
|
|
712
|
+
+ setState("saving", false)
|
|
713
|
+
+ }
|
|
714
|
+
+ }
|
|
715
|
+
+
|
|
716
|
+
+ async function runAction(id: string, action: "connect" | "disconnect" | "auth/start" | "auth/remove") {
|
|
717
|
+
+ setState("busyId", id)
|
|
718
|
+
+ try {
|
|
719
|
+
+ const payload = await waffleJson<{ status: McpStatusMap; authorizationUrl?: string }>(
|
|
720
|
+
+ `/api/waffle/mcp/${encodeURIComponent(id)}/${action}`,
|
|
721
|
+
+ {
|
|
722
|
+
+ method: "POST",
|
|
723
|
+
+ },
|
|
724
|
+
+ )
|
|
725
|
+
+ if (payload.authorizationUrl) {
|
|
726
|
+
+ window.open(payload.authorizationUrl, "_blank", "noopener,noreferrer")
|
|
727
|
+
+ }
|
|
728
|
+
+ setState("payload", (current) => (current ? { ...current, status: payload.status } : current))
|
|
729
|
+
+ } catch (error) {
|
|
730
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to update MCP server")
|
|
731
|
+
+ } finally {
|
|
732
|
+
+ setState("busyId", "")
|
|
733
|
+
+ }
|
|
734
|
+
+ }
|
|
735
|
+
+
|
|
736
|
+
+ onMount(() => {
|
|
737
|
+
+ void load()
|
|
738
|
+
+ })
|
|
739
|
+
+
|
|
740
|
+
+ const statusFor = (id: string) => state.payload?.status?.[id]?.status ?? "unknown"
|
|
741
|
+
+
|
|
742
|
+
+ return (
|
|
743
|
+
+ <WaffleSettingsPage
|
|
744
|
+
+ title="MCP"
|
|
745
|
+
+ description="Edit MCP server definitions and drive connection or auth flows through Agent Mockingbird's same-origin APIs."
|
|
746
|
+
+ actions={
|
|
747
|
+
+ <WaffleToolbar>
|
|
748
|
+
+ <Button variant="ghost" size="large" onClick={() => void load()} disabled={state.loading || state.saving}>
|
|
749
|
+
+ Refresh
|
|
750
|
+
+ </Button>
|
|
751
|
+
+ <Button size="large" onClick={() => void save()} disabled={state.loading || state.saving}>
|
|
752
|
+
+ Save
|
|
753
|
+
+ </Button>
|
|
754
|
+
+ </WaffleToolbar>
|
|
755
|
+
+ }
|
|
756
|
+
+ >
|
|
757
|
+
+ <Show when={state.error}>
|
|
758
|
+
+ <WaffleNotice tone="error">{state.error}</WaffleNotice>
|
|
759
|
+
+ </Show>
|
|
760
|
+
+
|
|
761
|
+
+ <WaffleSettingsSection title="Configured servers" description="Connection status and auth actions for configured MCP servers.">
|
|
762
|
+
+ <Show
|
|
763
|
+
+ when={(state.payload?.servers.length ?? 0) > 0}
|
|
764
|
+
+ fallback={<div class="px-4 py-4 text-14-regular text-text-weak">No MCP servers configured.</div>}
|
|
765
|
+
+ >
|
|
766
|
+
+ <For each={state.payload?.servers ?? []}>
|
|
767
|
+
+ {(server) => (
|
|
768
|
+
+ <div class="px-4 py-3 border-b border-border-weak-base last:border-none flex flex-wrap items-center justify-between gap-4">
|
|
769
|
+
+ <div class="flex flex-col gap-1 min-w-0">
|
|
770
|
+
+ <div class="text-14-medium text-text-strong">{server.id}</div>
|
|
771
|
+
+ <div class="text-12-regular text-text-weak">
|
|
772
|
+
+ {server.type === "remote" ? server.url : server.command.join(" ")}
|
|
773
|
+
+ </div>
|
|
774
|
+
+ <div class="text-12-regular text-text-weak">Status: {statusFor(server.id)}</div>
|
|
775
|
+
+ </div>
|
|
776
|
+
+ <div class="flex flex-wrap items-center gap-2">
|
|
777
|
+
+ <Button
|
|
778
|
+
+ variant="ghost"
|
|
779
|
+
+ size="large"
|
|
780
|
+
+ disabled={state.busyId === server.id}
|
|
781
|
+
+ onClick={() => void runAction(server.id, statusFor(server.id) === "connected" ? "disconnect" : "connect")}
|
|
782
|
+
+ >
|
|
783
|
+
+ {statusFor(server.id) === "connected" ? "Disconnect" : "Connect"}
|
|
784
|
+
+ </Button>
|
|
785
|
+
+ <Button
|
|
786
|
+
+ variant="ghost"
|
|
787
|
+
+ size="large"
|
|
788
|
+
+ disabled={state.busyId === server.id}
|
|
789
|
+
+ onClick={() => void runAction(server.id, "auth/start")}
|
|
790
|
+
+ >
|
|
791
|
+
+ Start auth
|
|
792
|
+
+ </Button>
|
|
793
|
+
+ <Button
|
|
794
|
+
+ variant="ghost"
|
|
795
|
+
+ size="large"
|
|
796
|
+
+ disabled={state.busyId === server.id}
|
|
797
|
+
+ onClick={() => void runAction(server.id, "auth/remove")}
|
|
798
|
+
+ >
|
|
799
|
+
+ Remove auth
|
|
800
|
+
+ </Button>
|
|
801
|
+
+ </div>
|
|
802
|
+
+ </div>
|
|
803
|
+
+ )}
|
|
804
|
+
+ </For>
|
|
805
|
+
+ </Show>
|
|
806
|
+
+ </WaffleSettingsSection>
|
|
807
|
+
+
|
|
808
|
+
+ <WaffleSettingsSection title="Server editor" description="Edit the full MCP server array as JSON.">
|
|
809
|
+
+ <WaffleCard>
|
|
810
|
+
+ <WaffleTextArea
|
|
811
|
+
+ rows={22}
|
|
812
|
+
+ value={state.text}
|
|
813
|
+
+ onInput={(event) => setState("text", event.currentTarget.value)}
|
|
814
|
+
+ spellcheck={false}
|
|
815
|
+
+ />
|
|
816
|
+
+ </WaffleCard>
|
|
817
|
+
+ </WaffleSettingsSection>
|
|
818
|
+
+ </WaffleSettingsPage>
|
|
819
|
+
+ )
|
|
820
|
+
+}
|
|
821
|
+
diff --git a/packages/app/src/components/settings-runtime.tsx b/packages/app/src/components/settings-runtime.tsx
|
|
822
|
+
new file mode 100644
|
|
823
|
+
index 000000000..fd091697e
|
|
824
|
+
--- /dev/null
|
|
825
|
+
+++ b/packages/app/src/components/settings-runtime.tsx
|
|
826
|
+
@@ -0,0 +1,120 @@
|
|
827
|
+
+import { Button } from "@opencode-ai/ui/button"
|
|
828
|
+
+import { showToast } from "@opencode-ai/ui/toast"
|
|
829
|
+
+import { onMount, Show, type Component } from "solid-js"
|
|
830
|
+
+import { createStore } from "solid-js/store"
|
|
831
|
+
+import { prettyJson, waffleJson } from "@/utils/waffle"
|
|
832
|
+
+import { WaffleCard, WaffleMetaRow, WaffleNotice, WaffleSettingsPage, WaffleSettingsSection, WaffleTextArea, WaffleToolbar } from "./settings-waffle-shared"
|
|
833
|
+
+
|
|
834
|
+
+type RuntimePayload = {
|
|
835
|
+
+ hash: string
|
|
836
|
+
+ path: string
|
|
837
|
+
+ config: {
|
|
838
|
+
+ workspace: {
|
|
839
|
+
+ pinnedDirectory: string
|
|
840
|
+
+ }
|
|
841
|
+
+ runtime: Record<string, unknown>
|
|
842
|
+
+ }
|
|
843
|
+
+}
|
|
844
|
+
+
|
|
845
|
+
+export const SettingsRuntime: Component = () => {
|
|
846
|
+
+ const [state, setState] = createStore({
|
|
847
|
+
+ loading: true,
|
|
848
|
+
+ saving: false,
|
|
849
|
+
+ error: "",
|
|
850
|
+
+ payload: null as RuntimePayload | null,
|
|
851
|
+
+ text: "{\n \"workspace\": {\n \"pinnedDirectory\": \"\"\n },\n \"runtime\": {}\n}",
|
|
852
|
+
+ })
|
|
853
|
+
+
|
|
854
|
+
+ async function load() {
|
|
855
|
+
+ setState("loading", true)
|
|
856
|
+
+ setState("error", "")
|
|
857
|
+
+ try {
|
|
858
|
+
+ const payload = await waffleJson<RuntimePayload>("/api/waffle/runtime/config")
|
|
859
|
+
+ setState("payload", payload)
|
|
860
|
+
+ setState("text", prettyJson(payload.config))
|
|
861
|
+
+ } catch (error) {
|
|
862
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to load runtime config")
|
|
863
|
+
+ } finally {
|
|
864
|
+
+ setState("loading", false)
|
|
865
|
+
+ }
|
|
866
|
+
+ }
|
|
867
|
+
+
|
|
868
|
+
+ async function save() {
|
|
869
|
+
+ let parsed: unknown
|
|
870
|
+
+ try {
|
|
871
|
+
+ parsed = JSON.parse(state.text)
|
|
872
|
+
+ } catch (error) {
|
|
873
|
+
+ setState("error", error instanceof Error ? error.message : "Invalid JSON")
|
|
874
|
+
+ return
|
|
875
|
+
+ }
|
|
876
|
+
+
|
|
877
|
+
+ setState("saving", true)
|
|
878
|
+
+ setState("error", "")
|
|
879
|
+
+ try {
|
|
880
|
+
+ const payload = await waffleJson<RuntimePayload>("/api/waffle/runtime/config/replace", {
|
|
881
|
+
+ method: "POST",
|
|
882
|
+
+ body: JSON.stringify({
|
|
883
|
+
+ config: parsed,
|
|
884
|
+
+ expectedHash: state.payload?.hash,
|
|
885
|
+
+ }),
|
|
886
|
+
+ })
|
|
887
|
+
+ setState("payload", payload)
|
|
888
|
+
+ setState("text", prettyJson(payload.config))
|
|
889
|
+
+ showToast({
|
|
890
|
+
+ variant: "success",
|
|
891
|
+
+ icon: "circle-check",
|
|
892
|
+
+ title: "Runtime config saved",
|
|
893
|
+
+ })
|
|
894
|
+
+ } catch (error) {
|
|
895
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to save runtime config")
|
|
896
|
+
+ } finally {
|
|
897
|
+
+ setState("saving", false)
|
|
898
|
+
+ }
|
|
899
|
+
+ }
|
|
900
|
+
+
|
|
901
|
+
+ onMount(() => {
|
|
902
|
+
+ void load()
|
|
903
|
+
+ })
|
|
904
|
+
+
|
|
905
|
+
+ return (
|
|
906
|
+
+ <WaffleSettingsPage
|
|
907
|
+
+ title="Runtime"
|
|
908
|
+
+ description="Edit Agent Mockingbird runtime settings owned outside OpenCode's workspace config."
|
|
909
|
+
+ actions={
|
|
910
|
+
+ <WaffleToolbar>
|
|
911
|
+
+ <Button variant="ghost" size="large" onClick={() => void load()} disabled={state.loading || state.saving}>
|
|
912
|
+
+ Refresh
|
|
913
|
+
+ </Button>
|
|
914
|
+
+ <Button size="large" onClick={() => void save()} disabled={state.loading || state.saving}>
|
|
915
|
+
+ Save
|
|
916
|
+
+ </Button>
|
|
917
|
+
+ </WaffleToolbar>
|
|
918
|
+
+ }
|
|
919
|
+
+ >
|
|
920
|
+
+ <Show when={state.error}>
|
|
921
|
+
+ <WaffleNotice tone="error">{state.error}</WaffleNotice>
|
|
922
|
+
+ </Show>
|
|
923
|
+
+
|
|
924
|
+
+ <Show when={state.payload}>
|
|
925
|
+
+ {(payload) => (
|
|
926
|
+
+ <WaffleSettingsSection title="Config metadata">
|
|
927
|
+
+ <WaffleMetaRow label="Config file" value={payload().path} />
|
|
928
|
+
+ <WaffleMetaRow label="Pinned workspace" value={payload().config.workspace.pinnedDirectory} />
|
|
929
|
+
+ <WaffleMetaRow label="Revision hash" value={payload().hash} />
|
|
930
|
+
+ </WaffleSettingsSection>
|
|
931
|
+
+ )}
|
|
932
|
+
+ </Show>
|
|
933
|
+
+
|
|
934
|
+
+ <WaffleSettingsSection title="Config editor" description="Replace the current Agent Mockingbird config payload with JSON.">
|
|
935
|
+
+ <WaffleCard>
|
|
936
|
+
+ <WaffleTextArea
|
|
937
|
+
+ rows={22}
|
|
938
|
+
+ value={state.text}
|
|
939
|
+
+ onInput={(event) => setState("text", event.currentTarget.value)}
|
|
940
|
+
+ spellcheck={false}
|
|
941
|
+
+ />
|
|
942
|
+
+ </WaffleCard>
|
|
943
|
+
+ </WaffleSettingsSection>
|
|
944
|
+
+ </WaffleSettingsPage>
|
|
945
|
+
+ )
|
|
946
|
+
+}
|
|
947
|
+
diff --git a/packages/app/src/components/settings-skills.tsx b/packages/app/src/components/settings-skills.tsx
|
|
948
|
+
new file mode 100644
|
|
949
|
+
index 000000000..2254d384c
|
|
950
|
+
--- /dev/null
|
|
951
|
+
+++ b/packages/app/src/components/settings-skills.tsx
|
|
952
|
+
@@ -0,0 +1,256 @@
|
|
953
|
+
+import { Button } from "@opencode-ai/ui/button"
|
|
954
|
+
+import { Switch } from "@opencode-ai/ui/switch"
|
|
955
|
+
+import { showToast } from "@opencode-ai/ui/toast"
|
|
956
|
+
+import { createSignal, For, onMount, Show, type Component } from "solid-js"
|
|
957
|
+
+import { createStore } from "solid-js/store"
|
|
958
|
+
+import { WaffleCard, WaffleInput, WaffleMetaRow, WaffleNotice, WaffleSettingsPage, WaffleSettingsSection, WaffleTextArea, WaffleToolbar } from "./settings-waffle-shared"
|
|
959
|
+
+import { waffleJson } from "@/utils/waffle"
|
|
960
|
+
+
|
|
961
|
+
+type SkillEntry = {
|
|
962
|
+
+ id: string
|
|
963
|
+
+ name: string
|
|
964
|
+
+ description: string
|
|
965
|
+
+ location: string
|
|
966
|
+
+ enabled: boolean
|
|
967
|
+
+ managed: boolean
|
|
968
|
+
+}
|
|
969
|
+
+
|
|
970
|
+
+type SkillsPayload = {
|
|
971
|
+
+ skills: SkillEntry[]
|
|
972
|
+
+ enabled: string[]
|
|
973
|
+
+ disabled: string[]
|
|
974
|
+
+ invalid: Array<{
|
|
975
|
+
+ id?: string
|
|
976
|
+
+ location: string
|
|
977
|
+
+ reason: string
|
|
978
|
+
+ }>
|
|
979
|
+
+ hash: string
|
|
980
|
+
+ revision: string
|
|
981
|
+
+ managedPath: string
|
|
982
|
+
+ disabledPath: string
|
|
983
|
+
+}
|
|
984
|
+
+
|
|
985
|
+
+export const SettingsSkills: Component = () => {
|
|
986
|
+
+ const [state, setState] = createStore({
|
|
987
|
+
+ loading: true,
|
|
988
|
+
+ busyId: "",
|
|
989
|
+
+ error: "",
|
|
990
|
+
+ catalog: null as SkillsPayload | null,
|
|
991
|
+
+ importId: "",
|
|
992
|
+
+ importEnabled: true,
|
|
993
|
+
+ importContent:
|
|
994
|
+
+ "---\nname: example-skill\ndescription: Example managed skill\n---\n\nAdd instructions here.\n",
|
|
995
|
+
+ })
|
|
996
|
+
+ const [refreshTick, setRefreshTick] = createSignal(0)
|
|
997
|
+
+
|
|
998
|
+
+ async function load() {
|
|
999
|
+
+ refreshTick()
|
|
1000
|
+
+ setState("loading", true)
|
|
1001
|
+
+ setState("error", "")
|
|
1002
|
+
+ try {
|
|
1003
|
+
+ const catalog = await waffleJson<SkillsPayload>("/api/waffle/skills")
|
|
1004
|
+
+ setState("catalog", catalog)
|
|
1005
|
+
+ } catch (error) {
|
|
1006
|
+
+ const message = error instanceof Error ? error.message : "Failed to load skills"
|
|
1007
|
+
+ setState("error", message)
|
|
1008
|
+
+ } finally {
|
|
1009
|
+
+ setState("loading", false)
|
|
1010
|
+
+ }
|
|
1011
|
+
+ }
|
|
1012
|
+
+
|
|
1013
|
+
+ async function refresh() {
|
|
1014
|
+
+ setRefreshTick((value) => value + 1)
|
|
1015
|
+
+ await load()
|
|
1016
|
+
+ }
|
|
1017
|
+
+
|
|
1018
|
+
+ async function toggleSkill(id: string, enabled: boolean) {
|
|
1019
|
+
+ setState("busyId", id)
|
|
1020
|
+
+ try {
|
|
1021
|
+
+ const next = await waffleJson<SkillsPayload>(`/api/waffle/skills/${encodeURIComponent(id)}`, {
|
|
1022
|
+
+ method: "PATCH",
|
|
1023
|
+
+ body: JSON.stringify({ enabled }),
|
|
1024
|
+
+ })
|
|
1025
|
+
+ setState("catalog", next)
|
|
1026
|
+
+ } catch (error) {
|
|
1027
|
+
+ showToast({
|
|
1028
|
+
+ title: "Failed to update skill",
|
|
1029
|
+
+ description: error instanceof Error ? error.message : String(error),
|
|
1030
|
+
+ })
|
|
1031
|
+
+ } finally {
|
|
1032
|
+
+ setState("busyId", "")
|
|
1033
|
+
+ }
|
|
1034
|
+
+ }
|
|
1035
|
+
+
|
|
1036
|
+
+ async function removeSkill(id: string) {
|
|
1037
|
+
+ setState("busyId", id)
|
|
1038
|
+
+ try {
|
|
1039
|
+
+ const next = await waffleJson<SkillsPayload>(`/api/waffle/skills/${encodeURIComponent(id)}`, {
|
|
1040
|
+
+ method: "DELETE",
|
|
1041
|
+
+ })
|
|
1042
|
+
+ setState("catalog", next)
|
|
1043
|
+
+ } catch (error) {
|
|
1044
|
+
+ showToast({
|
|
1045
|
+
+ title: "Failed to remove skill",
|
|
1046
|
+
+ description: error instanceof Error ? error.message : String(error),
|
|
1047
|
+
+ })
|
|
1048
|
+
+ } finally {
|
|
1049
|
+
+ setState("busyId", "")
|
|
1050
|
+
+ }
|
|
1051
|
+
+ }
|
|
1052
|
+
+
|
|
1053
|
+
+ async function importSkill() {
|
|
1054
|
+
+ if (!state.importId.trim() || !state.importContent.trim()) {
|
|
1055
|
+
+ setState("error", "Skill id and SKILL.md content are required.")
|
|
1056
|
+
+ return
|
|
1057
|
+
+ }
|
|
1058
|
+
+
|
|
1059
|
+
+ setState("error", "")
|
|
1060
|
+
+ setState("busyId", "import")
|
|
1061
|
+
+ try {
|
|
1062
|
+
+ await waffleJson("/api/waffle/skills/import", {
|
|
1063
|
+
+ method: "POST",
|
|
1064
|
+
+ body: JSON.stringify({
|
|
1065
|
+
+ id: state.importId,
|
|
1066
|
+
+ content: state.importContent,
|
|
1067
|
+
+ enable: state.importEnabled,
|
|
1068
|
+
+ expectedHash: state.catalog?.hash,
|
|
1069
|
+
+ }),
|
|
1070
|
+
+ })
|
|
1071
|
+
+ showToast({
|
|
1072
|
+
+ variant: "success",
|
|
1073
|
+
+ icon: "circle-check",
|
|
1074
|
+
+ title: "Skill imported",
|
|
1075
|
+
+ })
|
|
1076
|
+
+ setState("importId", "")
|
|
1077
|
+
+ await refresh()
|
|
1078
|
+
+ } catch (error) {
|
|
1079
|
+
+ setState("error", error instanceof Error ? error.message : "Failed to import skill")
|
|
1080
|
+
+ } finally {
|
|
1081
|
+
+ setState("busyId", "")
|
|
1082
|
+
+ }
|
|
1083
|
+
+ }
|
|
1084
|
+
+
|
|
1085
|
+
+ onMount(() => {
|
|
1086
|
+
+ void load()
|
|
1087
|
+
+ })
|
|
1088
|
+
+
|
|
1089
|
+
+ return (
|
|
1090
|
+
+ <WaffleSettingsPage
|
|
1091
|
+
+ title="Skills"
|
|
1092
|
+
+ description="Manage the workspace skill catalog and import managed skills for the pinned workspace."
|
|
1093
|
+
+ actions={
|
|
1094
|
+
+ <WaffleToolbar>
|
|
1095
|
+
+ <Button variant="ghost" size="large" onClick={() => void refresh()} disabled={state.loading}>
|
|
1096
|
+
+ Refresh
|
|
1097
|
+
+ </Button>
|
|
1098
|
+
+ </WaffleToolbar>
|
|
1099
|
+
+ }
|
|
1100
|
+
+ >
|
|
1101
|
+
+ <Show when={state.error}>
|
|
1102
|
+
+ <WaffleNotice tone="error">{state.error}</WaffleNotice>
|
|
1103
|
+
+ </Show>
|
|
1104
|
+
+
|
|
1105
|
+
+ <Show when={state.catalog}>
|
|
1106
|
+
+ {(catalog) => (
|
|
1107
|
+
+ <>
|
|
1108
|
+
+ <WaffleSettingsSection title="Catalog paths">
|
|
1109
|
+
+ <WaffleMetaRow label="Managed skills path" value={catalog().managedPath} />
|
|
1110
|
+
+ <WaffleMetaRow label="Disabled skills path" value={catalog().disabledPath} />
|
|
1111
|
+
+ </WaffleSettingsSection>
|
|
1112
|
+
+
|
|
1113
|
+
+ <WaffleSettingsSection title="Installed skills" description="Enable, disable, or remove managed skills.">
|
|
1114
|
+
+ <Show
|
|
1115
|
+
+ when={catalog().skills.length > 0}
|
|
1116
|
+
+ fallback={<div class="px-4 py-4 text-14-regular text-text-weak">No managed skills found.</div>}
|
|
1117
|
+
+ >
|
|
1118
|
+
+ <For each={catalog().skills}>
|
|
1119
|
+
+ {(skill) => (
|
|
1120
|
+
+ <div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
|
|
1121
|
+
+ <div class="min-w-0 flex flex-col gap-1">
|
|
1122
|
+
+ <div class="text-14-medium text-text-strong">{skill.name}</div>
|
|
1123
|
+
+ <div class="text-12-regular text-text-weak">{skill.description}</div>
|
|
1124
|
+
+ <div class="text-11-regular text-text-weak break-all">{skill.location}</div>
|
|
1125
|
+
+ </div>
|
|
1126
|
+
+ <div class="flex items-center gap-2">
|
|
1127
|
+
+ <Switch
|
|
1128
|
+
+ checked={skill.enabled}
|
|
1129
|
+
+ disabled={state.busyId === skill.id}
|
|
1130
|
+
+ onChange={(enabled) => void toggleSkill(skill.id, enabled)}
|
|
1131
|
+
+ hideLabel
|
|
1132
|
+
+ >
|
|
1133
|
+
+ {skill.name}
|
|
1134
|
+
+ </Switch>
|
|
1135
|
+
+ <Button
|
|
1136
|
+
+ variant="ghost"
|
|
1137
|
+
+ size="large"
|
|
1138
|
+
+ disabled={state.busyId === skill.id}
|
|
1139
|
+
+ onClick={() => void removeSkill(skill.id)}
|
|
1140
|
+
+ >
|
|
1141
|
+
+ Remove
|
|
1142
|
+
+ </Button>
|
|
1143
|
+
+ </div>
|
|
1144
|
+
+ </div>
|
|
1145
|
+
+ )}
|
|
1146
|
+
+ </For>
|
|
1147
|
+
+ </Show>
|
|
1148
|
+
+ </WaffleSettingsSection>
|
|
1149
|
+
+
|
|
1150
|
+
+ <Show when={catalog().invalid.length > 0}>
|
|
1151
|
+
+ <WaffleSettingsSection title="Invalid skills">
|
|
1152
|
+
+ <For each={catalog().invalid}>
|
|
1153
|
+
+ {(issue) => (
|
|
1154
|
+
+ <div class="px-4 py-3 border-b border-border-weak-base last:border-none">
|
|
1155
|
+
+ <div class="text-13-medium text-text-strong">{issue.id ?? "unknown-skill"}</div>
|
|
1156
|
+
+ <div class="text-12-regular text-text-weak">{issue.reason}</div>
|
|
1157
|
+
+ <div class="text-11-regular text-text-weak break-all">{issue.location}</div>
|
|
1158
|
+
+ </div>
|
|
1159
|
+
+ )}
|
|
1160
|
+
+ </For>
|
|
1161
|
+
+ </WaffleSettingsSection>
|
|
1162
|
+
+ </Show>
|
|
1163
|
+
+ </>
|
|
1164
|
+
+ )}
|
|
1165
|
+
+ </Show>
|
|
1166
|
+
+
|
|
1167
|
+
+ <WaffleSettingsSection
|
|
1168
|
+
+ title="Import skill"
|
|
1169
|
+
+ description="Paste a managed SKILL.md and import it into the pinned workspace."
|
|
1170
|
+
+ >
|
|
1171
|
+
+ <WaffleCard
|
|
1172
|
+
+ footer={
|
|
1173
|
+
+ <div class="flex flex-wrap items-center justify-between gap-3">
|
|
1174
|
+
+ <label class="flex items-center gap-2 text-13-regular text-text-strong">
|
|
1175
|
+
+ <input
|
|
1176
|
+
+ type="checkbox"
|
|
1177
|
+
+ checked={state.importEnabled}
|
|
1178
|
+
+ onChange={(event) => setState("importEnabled", event.currentTarget.checked)}
|
|
1179
|
+
+ />
|
|
1180
|
+
+ Enable after import
|
|
1181
|
+
+ </label>
|
|
1182
|
+
+ <Button size="large" onClick={() => void importSkill()} disabled={state.busyId === "import"}>
|
|
1183
|
+
+ Import skill
|
|
1184
|
+
+ </Button>
|
|
1185
|
+
+ </div>
|
|
1186
|
+
+ }
|
|
1187
|
+
+ >
|
|
1188
|
+
+ <div class="flex flex-col gap-2">
|
|
1189
|
+
+ <span class="text-12-medium text-text-weak">Skill id</span>
|
|
1190
|
+
+ <WaffleInput
|
|
1191
|
+
+ value={state.importId}
|
|
1192
|
+
+ placeholder="example-skill"
|
|
1193
|
+
+ onInput={(event) => setState("importId", event.currentTarget.value)}
|
|
1194
|
+
+ />
|
|
1195
|
+
+ </div>
|
|
1196
|
+
+ <div class="flex flex-col gap-2">
|
|
1197
|
+
+ <span class="text-12-medium text-text-weak">SKILL.md content</span>
|
|
1198
|
+
+ <WaffleTextArea
|
|
1199
|
+
+ rows={14}
|
|
1200
|
+
+ value={state.importContent}
|
|
1201
|
+
+ onInput={(event) => setState("importContent", event.currentTarget.value)}
|
|
1202
|
+
+ />
|
|
1203
|
+
+ </div>
|
|
1204
|
+
+ </WaffleCard>
|
|
1205
|
+
+ </WaffleSettingsSection>
|
|
1206
|
+
+ </WaffleSettingsPage>
|
|
1207
|
+
+ )
|
|
1208
|
+
+}
|
|
1209
|
+
diff --git a/packages/app/src/components/settings-waffle-shared.tsx b/packages/app/src/components/settings-waffle-shared.tsx
|
|
1210
|
+
new file mode 100644
|
|
1211
|
+
index 000000000..64ece4b04
|
|
1212
|
+
--- /dev/null
|
|
1213
|
+
+++ b/packages/app/src/components/settings-waffle-shared.tsx
|
|
1214
|
+
@@ -0,0 +1,116 @@
|
|
1215
|
+
+import { type JSX, type ParentProps, Show } from "solid-js"
|
|
1216
|
+
+
|
|
1217
|
+
+export function WaffleSettingsPage(
|
|
1218
|
+
+ props: ParentProps<{
|
|
1219
|
+
+ title: string
|
|
1220
|
+
+ description?: string
|
|
1221
|
+
+ actions?: JSX.Element
|
|
1222
|
+
+ }>,
|
|
1223
|
+
+) {
|
|
1224
|
+
+ return (
|
|
1225
|
+
+ <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
|
1226
|
+
+ <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
|
1227
|
+
+ <div class="flex flex-wrap items-start justify-between gap-4 pt-6 pb-6 max-w-[760px]">
|
|
1228
|
+
+ <div class="flex flex-col gap-1 min-w-0">
|
|
1229
|
+
+ <h2 class="text-16-medium text-text-strong">{props.title}</h2>
|
|
1230
|
+
+ <Show when={props.description}>
|
|
1231
|
+
+ <p class="text-14-regular text-text-weak max-w-[640px]">{props.description}</p>
|
|
1232
|
+
+ </Show>
|
|
1233
|
+
+ </div>
|
|
1234
|
+
+ <Show when={props.actions}>
|
|
1235
|
+
+ <div class="flex items-center gap-2">{props.actions}</div>
|
|
1236
|
+
+ </Show>
|
|
1237
|
+
+ </div>
|
|
1238
|
+
+ </div>
|
|
1239
|
+
+
|
|
1240
|
+
+ <div class="flex flex-col gap-8 max-w-[760px]">{props.children}</div>
|
|
1241
|
+
+ </div>
|
|
1242
|
+
+ )
|
|
1243
|
+
+}
|
|
1244
|
+
+
|
|
1245
|
+
+export function WaffleSettingsSection(
|
|
1246
|
+
+ props: ParentProps<{
|
|
1247
|
+
+ title: string
|
|
1248
|
+
+ description?: string
|
|
1249
|
+
+ actions?: JSX.Element
|
|
1250
|
+
+ }>,
|
|
1251
|
+
+) {
|
|
1252
|
+
+ return (
|
|
1253
|
+
+ <section class="flex flex-col gap-3">
|
|
1254
|
+
+ <div class="flex flex-wrap items-start justify-between gap-3">
|
|
1255
|
+
+ <div class="flex flex-col gap-1">
|
|
1256
|
+
+ <h3 class="text-14-medium text-text-strong">{props.title}</h3>
|
|
1257
|
+
+ <Show when={props.description}>
|
|
1258
|
+
+ <p class="text-12-regular text-text-weak">{props.description}</p>
|
|
1259
|
+
+ </Show>
|
|
1260
|
+
+ </div>
|
|
1261
|
+
+ <Show when={props.actions}>
|
|
1262
|
+
+ <div class="flex items-center gap-2">{props.actions}</div>
|
|
1263
|
+
+ </Show>
|
|
1264
|
+
+ </div>
|
|
1265
|
+
+ <div class="bg-surface-raised-base rounded-lg border border-border-weak-base">{props.children}</div>
|
|
1266
|
+
+ </section>
|
|
1267
|
+
+ )
|
|
1268
|
+
+}
|
|
1269
|
+
+
|
|
1270
|
+
+export function WaffleMetaRow(props: { label: string; value: string | JSX.Element }) {
|
|
1271
|
+
+ return (
|
|
1272
|
+
+ <div class="flex flex-col gap-1 px-4 py-3 border-b border-border-weak-base last:border-none">
|
|
1273
|
+
+ <span class="text-12-medium text-text-weak">{props.label}</span>
|
|
1274
|
+
+ <div class="text-13-regular text-text-strong break-all">{props.value}</div>
|
|
1275
|
+
+ </div>
|
|
1276
|
+
+ )
|
|
1277
|
+
+}
|
|
1278
|
+
+
|
|
1279
|
+
+export function WaffleToolbar(props: ParentProps) {
|
|
1280
|
+
+ return <div class="flex flex-wrap items-center gap-2">{props.children}</div>
|
|
1281
|
+
+}
|
|
1282
|
+
+
|
|
1283
|
+
+export function WaffleNotice(props: { tone?: "error" | "success" | "info"; children: JSX.Element }) {
|
|
1284
|
+
+ const tone = () => props.tone ?? "info"
|
|
1285
|
+
+ return (
|
|
1286
|
+
+ <div
|
|
1287
|
+
+ classList={{
|
|
1288
|
+
+ "rounded-lg px-3 py-2 text-13-regular border": true,
|
|
1289
|
+
+ "bg-surface-base text-text-base border-border-weak-base": tone() === "info",
|
|
1290
|
+
+ "bg-background-base text-text-strong border-border-strong-base": tone() === "success",
|
|
1291
|
+
+ "bg-icon-critical-base/8 text-text-strong border-icon-critical-base/20": tone() === "error",
|
|
1292
|
+
+ }}
|
|
1293
|
+
+ >
|
|
1294
|
+
+ {props.children}
|
|
1295
|
+
+ </div>
|
|
1296
|
+
+ )
|
|
1297
|
+
+}
|
|
1298
|
+
+
|
|
1299
|
+
+export function WaffleInput(props: JSX.InputHTMLAttributes<HTMLInputElement>) {
|
|
1300
|
+
+ return (
|
|
1301
|
+
+ <input
|
|
1302
|
+
+ {...props}
|
|
1303
|
+
+ class={`w-full rounded-lg border border-border-weak-base bg-surface-base px-3 py-2 text-13-regular text-text-strong outline-none placeholder:text-text-weak focus:border-border-strong-base ${
|
|
1304
|
+
+ props.class ?? ""
|
|
1305
|
+
+ }`}
|
|
1306
|
+
+ />
|
|
1307
|
+
+ )
|
|
1308
|
+
+}
|
|
1309
|
+
+
|
|
1310
|
+
+export function WaffleTextArea(props: JSX.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
|
1311
|
+
+ return (
|
|
1312
|
+
+ <textarea
|
|
1313
|
+
+ {...props}
|
|
1314
|
+
+ class={`w-full rounded-lg border border-border-weak-base bg-surface-base px-3 py-2 text-12-mono text-text-strong outline-none placeholder:text-text-weak focus:border-border-strong-base ${
|
|
1315
|
+
+ props.class ?? ""
|
|
1316
|
+
+ }`}
|
|
1317
|
+
+ />
|
|
1318
|
+
+ )
|
|
1319
|
+
+}
|
|
1320
|
+
+
|
|
1321
|
+
+export function WaffleCard(props: ParentProps<{ footer?: JSX.Element }>) {
|
|
1322
|
+
+ return (
|
|
1323
|
+
+ <div class="rounded-lg border border-border-weak-base bg-background-base overflow-hidden">
|
|
1324
|
+
+ <div class="p-4 flex flex-col gap-4">{props.children}</div>
|
|
1325
|
+
+ <Show when={props.footer}>
|
|
1326
|
+
+ <div class="px-4 py-3 border-t border-border-weak-base bg-surface-base">{props.footer}</div>
|
|
1327
|
+
+ </Show>
|
|
1328
|
+
+ </div>
|
|
1329
|
+
+ )
|
|
1330
|
+
+}
|
|
1331
|
+
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
|
|
1332
|
+
index 61facb84e..30f360fb9 100644
|
|
1333
|
+
--- a/packages/app/src/components/status-popover.tsx
|
|
1334
|
+
+++ b/packages/app/src/components/status-popover.tsx
|
|
1335
|
+
@@ -1,11 +1,9 @@
|
|
1336
|
+
import { Button } from "@opencode-ai/ui/button"
|
|
1337
|
+
-import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
1338
|
+
import { Icon } from "@opencode-ai/ui/icon"
|
|
1339
|
+
import { Popover } from "@opencode-ai/ui/popover"
|
|
1340
|
+
import { Switch } from "@opencode-ai/ui/switch"
|
|
1341
|
+
import { Tabs } from "@opencode-ai/ui/tabs"
|
|
1342
|
+
import { showToast } from "@opencode-ai/ui/toast"
|
|
1343
|
+
-import { useNavigate } from "@solidjs/router"
|
|
1344
|
+
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
|
1345
|
+
import { createStore, reconcile } from "solid-js/store"
|
|
1346
|
+
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
|
1347
|
+
@@ -167,9 +165,7 @@ export function StatusPopover() {
|
|
1348
|
+
const sdk = useSDK()
|
|
1349
|
+
const server = useServer()
|
|
1350
|
+
const platform = usePlatform()
|
|
1351
|
+
- const dialog = useDialog()
|
|
1352
|
+
const language = useLanguage()
|
|
1353
|
+
- const navigate = useNavigate()
|
|
1354
|
+
|
|
1355
|
+
const [shown, setShown] = createSignal(false)
|
|
1356
|
+
const servers = createMemo(() => {
|
|
1357
|
+
@@ -236,13 +232,12 @@ export function StatusPopover() {
|
|
1358
|
+
aria-label={language.t("status.popover.ariaLabel")}
|
|
1359
|
+
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
|
1360
|
+
data-component="tabs"
|
|
1361
|
+
- data-active="servers"
|
|
1362
|
+
- defaultValue="servers"
|
|
1363
|
+
+ data-active="server"
|
|
1364
|
+
+ defaultValue="server"
|
|
1365
|
+
variant="alt"
|
|
1366
|
+
>
|
|
1367
|
+
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
|
1368
|
+
- <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
|
1369
|
+
- {sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
|
1370
|
+
+ <Tabs.Trigger value="server" data-slot="tab" class="text-12-regular">
|
|
1371
|
+
{language.t("status.popover.tab.servers")}
|
|
1372
|
+
</Tabs.Trigger>
|
|
1373
|
+
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
|
1374
|
+
@@ -259,61 +254,32 @@ export function StatusPopover() {
|
|
1375
|
+
</Tabs.Trigger>
|
|
1376
|
+
</Tabs.List>
|
|
1377
|
+
|
|
1378
|
+
- <Tabs.Content value="servers">
|
|
1379
|
+
+ <Tabs.Content value="server">
|
|
1380
|
+
<div class="flex flex-col px-2 pb-2">
|
|
1381
|
+
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
|
1382
|
+
<For each={sortedServers()}>
|
|
1383
|
+
{(s) => {
|
|
1384
|
+
const key = ServerConnection.key(s)
|
|
1385
|
+
- const isBlocked = () => health[key]?.healthy === false
|
|
1386
|
+
return (
|
|
1387
|
+
- <button
|
|
1388
|
+
- type="button"
|
|
1389
|
+
- class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
|
1390
|
+
- classList={{
|
|
1391
|
+
- "hover:bg-surface-raised-base-hover": !isBlocked(),
|
|
1392
|
+
- "cursor-not-allowed": isBlocked(),
|
|
1393
|
+
- }}
|
|
1394
|
+
- aria-disabled={isBlocked()}
|
|
1395
|
+
- onClick={() => {
|
|
1396
|
+
- if (isBlocked()) return
|
|
1397
|
+
- server.setActive(key)
|
|
1398
|
+
- navigate("/")
|
|
1399
|
+
- }}
|
|
1400
|
+
- >
|
|
1401
|
+
+ <div class="flex items-center gap-2 w-full pl-3 pr-1.5 py-2 rounded-md">
|
|
1402
|
+
<ServerHealthIndicator health={health[key]} />
|
|
1403
|
+
<ServerRow
|
|
1404
|
+
conn={s}
|
|
1405
|
+
- dimmed={isBlocked()}
|
|
1406
|
+
+ dimmed={health[key]?.healthy === false}
|
|
1407
|
+
status={health[key]}
|
|
1408
|
+
class="flex items-center gap-2 w-full min-w-0"
|
|
1409
|
+
nameClass="text-14-regular text-text-base truncate"
|
|
1410
|
+
versionClass="text-12-regular text-text-weak truncate"
|
|
1411
|
+
- badge={
|
|
1412
|
+
- <Show when={key === defaultServer.key()}>
|
|
1413
|
+
- <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
|
1414
|
+
- {language.t("common.default")}
|
|
1415
|
+
- </span>
|
|
1416
|
+
- </Show>
|
|
1417
|
+
- }
|
|
1418
|
+
>
|
|
1419
|
+
<div class="flex-1" />
|
|
1420
|
+
<Show when={server.current && key === ServerConnection.key(server.current)}>
|
|
1421
|
+
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
|
1422
|
+
</Show>
|
|
1423
|
+
</ServerRow>
|
|
1424
|
+
- </button>
|
|
1425
|
+
+ </div>
|
|
1426
|
+
)
|
|
1427
|
+
}}
|
|
1428
|
+
</For>
|
|
1429
|
+
-
|
|
1430
|
+
- <Button
|
|
1431
|
+
- variant="secondary"
|
|
1432
|
+
- class="mt-3 self-start h-8 px-3 py-1.5"
|
|
1433
|
+
- onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
|
|
1434
|
+
- >
|
|
1435
|
+
- {language.t("status.popover.action.manageServers")}
|
|
1436
|
+
- </Button>
|
|
1437
|
+
</div>
|
|
1438
|
+
</div>
|
|
1439
|
+
</Tabs.Content>
|
|
1440
|
+
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
|
|
1441
|
+
index 1171ca905..5126d03a2 100644
|
|
1442
|
+
--- a/packages/app/src/context/server.tsx
|
|
1443
|
+
+++ b/packages/app/src/context/server.tsx
|
|
1444
|
+
@@ -94,7 +94,11 @@ export namespace ServerConnection {
|
|
1445
|
+
|
|
1446
|
+
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
|
|
1447
|
+
name: "Server",
|
|
1448
|
+
- init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
|
|
1449
|
+
+ init: (props: {
|
|
1450
|
+
+ defaultServer: ServerConnection.Key
|
|
1451
|
+
+ servers?: Array<ServerConnection.Any>
|
|
1452
|
+
+ locked?: boolean
|
|
1453
|
+
+ }) => {
|
|
1454
|
+
const checkServerHealth = useCheckServerHealth()
|
|
1455
|
+
|
|
1456
|
+
const [store, setStore, _, ready] = persisted(
|
|
1457
|
+
@@ -109,6 +113,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|
1458
|
+
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
|
|
1459
|
+
|
|
1460
|
+
const allServers = createMemo((): Array<ServerConnection.Any> => {
|
|
1461
|
+
+ if (props.locked) return props.servers ?? []
|
|
1462
|
+
const servers = [
|
|
1463
|
+
...(props.servers ?? []),
|
|
1464
|
+
...store.list.map((value) =>
|
|
1465
|
+
@@ -164,10 +169,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function setActive(input: ServerConnection.Key) {
|
|
1469
|
+
+ if (!allServers().some((server) => ServerConnection.key(server) === input)) return
|
|
1470
|
+
if (state.active !== input) setState("active", input)
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function add(input: ServerConnection.Http) {
|
|
1474
|
+
+ if (props.locked) return
|
|
1475
|
+
const url_ = normalizeServerUrl(input.http.url)
|
|
1476
|
+
if (!url_) return
|
|
1477
|
+
const conn = { ...input, http: { ...input.http, url: url_ } }
|
|
1478
|
+
@@ -184,6 +191,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function remove(key: ServerConnection.Key) {
|
|
1482
|
+
+ if (props.locked) return
|
|
1483
|
+
const list = store.list.filter((x) => url(x) !== key)
|
|
1484
|
+
batch(() => {
|
|
1485
|
+
setStore("list", list)
|
|
1486
|
+
@@ -198,6 +206,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|
1487
|
+
|
|
1488
|
+
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
|
|
1489
|
+
|
|
1490
|
+
+ createEffect(() => {
|
|
1491
|
+
+ const available = allServers()
|
|
1492
|
+
+ if (available.length === 0) return
|
|
1493
|
+
+ if (available.some((server) => ServerConnection.key(server) === state.active)) return
|
|
1494
|
+
+ setState("active", ServerConnection.key(available[0]!))
|
|
1495
|
+
+ })
|
|
1496
|
+
+
|
|
1497
|
+
createEffect(() => {
|
|
1498
|
+
const current_ = current()
|
|
1499
|
+
if (!current_) return
|
|
1500
|
+
diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx
|
|
1501
|
+
index b5cbed6e7..2198a7bc1 100644
|
|
1502
|
+
--- a/packages/app/src/entry.tsx
|
|
1503
|
+
+++ b/packages/app/src/entry.tsx
|
|
1504
|
+
@@ -9,8 +9,6 @@ import { handleNotificationClick } from "@/utils/notification-click"
|
|
1505
|
+
import pkg from "../package.json"
|
|
1506
|
+
import { ServerConnection } from "./context/server"
|
|
1507
|
+
|
|
1508
|
+
-const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
|
1509
|
+
-
|
|
1510
|
+
const getLocale = () => {
|
|
1511
|
+
if (typeof navigator !== "object") return "en" as const
|
|
1512
|
+
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
|
1513
|
+
@@ -27,31 +25,6 @@ const getRootNotFoundError = () => {
|
|
1514
|
+
return locale === "zh" ? (zh[key] ?? en[key]) : en[key]
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
-const getStorage = (key: string) => {
|
|
1518
|
+
- if (typeof localStorage === "undefined") return null
|
|
1519
|
+
- try {
|
|
1520
|
+
- return localStorage.getItem(key)
|
|
1521
|
+
- } catch {
|
|
1522
|
+
- return null
|
|
1523
|
+
- }
|
|
1524
|
+
-}
|
|
1525
|
+
-
|
|
1526
|
+
-const setStorage = (key: string, value: string | null) => {
|
|
1527
|
+
- if (typeof localStorage === "undefined") return
|
|
1528
|
+
- try {
|
|
1529
|
+
- if (value !== null) {
|
|
1530
|
+
- localStorage.setItem(key, value)
|
|
1531
|
+
- return
|
|
1532
|
+
- }
|
|
1533
|
+
- localStorage.removeItem(key)
|
|
1534
|
+
- } catch {
|
|
1535
|
+
- return
|
|
1536
|
+
- }
|
|
1537
|
+
-}
|
|
1538
|
+
-
|
|
1539
|
+
-const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
|
|
1540
|
+
-const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
|
|
1541
|
+
-
|
|
1542
|
+
const notify: Platform["notify"] = async (title, description, href) => {
|
|
1543
|
+
if (!("Notification" in window)) return
|
|
1544
|
+
|
|
1545
|
+
@@ -124,7 +97,6 @@ const platform: Platform = {
|
|
1546
|
+
},
|
|
1547
|
+
setDefaultServer: writeDefaultServerUrl,
|
|
1548
|
+
}
|
|
1549
|
+
-
|
|
1550
|
+
if (root instanceof HTMLElement) {
|
|
1551
|
+
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
|
|
1552
|
+
render(
|
|
1553
|
+
@@ -132,8 +104,9 @@ if (root instanceof HTMLElement) {
|
|
1554
|
+
<PlatformProvider value={platform}>
|
|
1555
|
+
<AppBaseProviders>
|
|
1556
|
+
<AppInterface
|
|
1557
|
+
- defaultServer={ServerConnection.Key.make(getDefaultUrl())}
|
|
1558
|
+
+ defaultServer={ServerConnection.key(server)}
|
|
1559
|
+
servers={[server]}
|
|
1560
|
+
+ lockServerSelection
|
|
1561
|
+
disableHealthCheck
|
|
1562
|
+
/>
|
|
1563
|
+
</AppBaseProviders>
|
|
1564
|
+
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
|
|
1565
|
+
index ad12e1e0d..0bb31bab0 100644
|
|
1566
|
+
--- a/packages/app/src/i18n/en.ts
|
|
1567
|
+
+++ b/packages/app/src/i18n/en.ts
|
|
1568
|
+
@@ -703,6 +703,7 @@ export const dict = {
|
|
1569
|
+
|
|
1570
|
+
"settings.section.desktop": "Desktop",
|
|
1571
|
+
"settings.section.server": "Server",
|
|
1572
|
+
+ "settings.section.wafflebot": "Wafflebot",
|
|
1573
|
+
"settings.tab.general": "General",
|
|
1574
|
+
"settings.tab.shortcuts": "Shortcuts",
|
|
1575
|
+
"settings.desktop.section.wsl": "WSL",
|
|
1576
|
+
@@ -862,11 +863,14 @@ export const dict = {
|
|
1577
|
+
"settings.models.title": "Models",
|
|
1578
|
+
"settings.models.description": "Model settings will be configurable here.",
|
|
1579
|
+
"settings.agents.title": "Agents",
|
|
1580
|
+
- "settings.agents.description": "Agent settings will be configurable here.",
|
|
1581
|
+
+ "settings.agents.description": "Edit pinned-workspace agent definitions.",
|
|
1582
|
+
"settings.commands.title": "Commands",
|
|
1583
|
+
"settings.commands.description": "Command settings will be configurable here.",
|
|
1584
|
+
"settings.mcp.title": "MCP",
|
|
1585
|
+
- "settings.mcp.description": "MCP settings will be configurable here.",
|
|
1586
|
+
+ "settings.mcp.description": "Configure pinned-workspace MCP servers.",
|
|
1587
|
+
+ "settings.skills.title": "Skills",
|
|
1588
|
+
+ "settings.runtime.title": "Runtime",
|
|
1589
|
+
+ "settings.cron.title": "Cron",
|
|
1590
|
+
|
|
1591
|
+
"settings.permissions.title": "Permissions",
|
|
1592
|
+
"settings.permissions.description": "Control what tools the server can use by default.",
|
|
1593
|
+
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
|
|
1594
|
+
index f993ffcd8..50b6fa5ad 100644
|
|
1595
|
+
--- a/packages/app/src/pages/directory-layout.tsx
|
|
1596
|
+
+++ b/packages/app/src/pages/directory-layout.tsx
|
|
1597
|
+
@@ -1,4 +1,4 @@
|
|
1598
|
+
-import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
|
|
1599
|
+
+import { batch, createEffect, createMemo, createResource, Show, type ParentProps } from "solid-js"
|
|
1600
|
+
import { createStore } from "solid-js/store"
|
|
1601
|
+
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
|
1602
|
+
import { SDKProvider } from "@/context/sdk"
|
|
1603
|
+
@@ -11,6 +11,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
|
1604
|
+
import { decode64 } from "@/utils/base64"
|
|
1605
|
+
import { showToast } from "@opencode-ai/ui/toast"
|
|
1606
|
+
import { useLanguage } from "@/context/language"
|
|
1607
|
+
+import { readPinnedWorkspace, workspaceHref } from "@/utils/waffle"
|
|
1608
|
+
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
|
1609
|
+
const navigate = useNavigate()
|
|
1610
|
+
const sync = useSync()
|
|
1611
|
+
@@ -35,8 +36,16 @@ export default function Layout(props: ParentProps) {
|
|
1612
|
+
const language = useLanguage()
|
|
1613
|
+
const globalSDK = useGlobalSDK()
|
|
1614
|
+
const directory = createMemo(() => decode64(params.dir) ?? "")
|
|
1615
|
+
+ const [pinnedWorkspace] = createResource(readPinnedWorkspace)
|
|
1616
|
+
const [state, setState] = createStore({ invalid: "", resolved: "" })
|
|
1617
|
+
|
|
1618
|
+
+ createEffect(() => {
|
|
1619
|
+
+ const current = directory()
|
|
1620
|
+
+ const pinned = pinnedWorkspace()?.directory?.trim()
|
|
1621
|
+
+ if (!current || !pinned || current === pinned) return
|
|
1622
|
+
+ navigate(workspaceHref(pinned), { replace: true })
|
|
1623
|
+
+ })
|
|
1624
|
+
+
|
|
1625
|
+
createEffect(() => {
|
|
1626
|
+
if (!params.dir) return
|
|
1627
|
+
const raw = directory()
|
|
1628
|
+
diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx
|
|
1629
|
+
index ba3a2b942..ab5c84329 100644
|
|
1630
|
+
--- a/packages/app/src/pages/home.tsx
|
|
1631
|
+
+++ b/packages/app/src/pages/home.tsx
|
|
1632
|
+
@@ -1,131 +1,33 @@
|
|
1633
|
+
-import { createMemo, For, Match, Switch } from "solid-js"
|
|
1634
|
+
-import { Button } from "@opencode-ai/ui/button"
|
|
1635
|
+
-import { Logo } from "@opencode-ai/ui/logo"
|
|
1636
|
+
-import { useLayout } from "@/context/layout"
|
|
1637
|
+
import { useNavigate } from "@solidjs/router"
|
|
1638
|
+
-import { base64Encode } from "@opencode-ai/util/encode"
|
|
1639
|
+
-import { Icon } from "@opencode-ai/ui/icon"
|
|
1640
|
+
-import { usePlatform } from "@/context/platform"
|
|
1641
|
+
-import { DateTime } from "luxon"
|
|
1642
|
+
-import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
1643
|
+
-import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
|
1644
|
+
-import { DialogSelectServer } from "@/components/dialog-select-server"
|
|
1645
|
+
-import { useServer } from "@/context/server"
|
|
1646
|
+
-import { useGlobalSync } from "@/context/global-sync"
|
|
1647
|
+
-import { useLanguage } from "@/context/language"
|
|
1648
|
+
+import { createSignal, onMount, Show } from "solid-js"
|
|
1649
|
+
+import { readPinnedWorkspace, workspaceHref } from "@/utils/waffle"
|
|
1650
|
+
|
|
1651
|
+
export default function Home() {
|
|
1652
|
+
- const sync = useGlobalSync()
|
|
1653
|
+
- const layout = useLayout()
|
|
1654
|
+
- const platform = usePlatform()
|
|
1655
|
+
- const dialog = useDialog()
|
|
1656
|
+
const navigate = useNavigate()
|
|
1657
|
+
- const server = useServer()
|
|
1658
|
+
- const language = useLanguage()
|
|
1659
|
+
- const homedir = createMemo(() => sync.data.path.home)
|
|
1660
|
+
- const recent = createMemo(() => {
|
|
1661
|
+
- return sync.data.project
|
|
1662
|
+
- .slice()
|
|
1663
|
+
- .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
|
1664
|
+
- .slice(0, 5)
|
|
1665
|
+
- })
|
|
1666
|
+
-
|
|
1667
|
+
- const serverDotClass = createMemo(() => {
|
|
1668
|
+
- const healthy = server.healthy()
|
|
1669
|
+
- if (healthy === true) return "bg-icon-success-base"
|
|
1670
|
+
- if (healthy === false) return "bg-icon-critical-base"
|
|
1671
|
+
- return "bg-border-weak-base"
|
|
1672
|
+
- })
|
|
1673
|
+
-
|
|
1674
|
+
- function openProject(directory: string) {
|
|
1675
|
+
- layout.projects.open(directory)
|
|
1676
|
+
- server.projects.touch(directory)
|
|
1677
|
+
- navigate(`/${base64Encode(directory)}`)
|
|
1678
|
+
- }
|
|
1679
|
+
+ const [error, setError] = createSignal("")
|
|
1680
|
+
|
|
1681
|
+
- async function chooseProject() {
|
|
1682
|
+
- function resolve(result: string | string[] | null) {
|
|
1683
|
+
- if (Array.isArray(result)) {
|
|
1684
|
+
- for (const directory of result) {
|
|
1685
|
+
- openProject(directory)
|
|
1686
|
+
- }
|
|
1687
|
+
- } else if (result) {
|
|
1688
|
+
- openProject(result)
|
|
1689
|
+
+ onMount(() => {
|
|
1690
|
+
+ void (async () => {
|
|
1691
|
+
+ try {
|
|
1692
|
+
+ const workspace = await readPinnedWorkspace()
|
|
1693
|
+
+ navigate(workspaceHref(workspace.directory), { replace: true })
|
|
1694
|
+
+ } catch (error) {
|
|
1695
|
+
+ setError(error instanceof Error ? error.message : "Failed to resolve pinned workspace")
|
|
1696
|
+
}
|
|
1697
|
+
- }
|
|
1698
|
+
-
|
|
1699
|
+
- if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
|
1700
|
+
- const result = await platform.openDirectoryPickerDialog?.({
|
|
1701
|
+
- title: language.t("command.project.open"),
|
|
1702
|
+
- multiple: true,
|
|
1703
|
+
- })
|
|
1704
|
+
- resolve(result)
|
|
1705
|
+
- } else {
|
|
1706
|
+
- dialog.show(
|
|
1707
|
+
- () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
|
|
1708
|
+
- () => resolve(null),
|
|
1709
|
+
- )
|
|
1710
|
+
- }
|
|
1711
|
+
- }
|
|
1712
|
+
+ })()
|
|
1713
|
+
+ })
|
|
1714
|
+
|
|
1715
|
+
return (
|
|
1716
|
+
- <div class="mx-auto mt-55 w-full md:w-auto px-4">
|
|
1717
|
+
- <Logo class="md:w-xl opacity-12" />
|
|
1718
|
+
- <Button
|
|
1719
|
+
- size="large"
|
|
1720
|
+
- variant="ghost"
|
|
1721
|
+
- class="mt-4 mx-auto text-14-regular text-text-weak"
|
|
1722
|
+
- onClick={() => dialog.show(() => <DialogSelectServer />)}
|
|
1723
|
+
- >
|
|
1724
|
+
- <div
|
|
1725
|
+
- classList={{
|
|
1726
|
+
- "size-2 rounded-full": true,
|
|
1727
|
+
- [serverDotClass()]: true,
|
|
1728
|
+
- }}
|
|
1729
|
+
- />
|
|
1730
|
+
- {server.name}
|
|
1731
|
+
- </Button>
|
|
1732
|
+
- <Switch>
|
|
1733
|
+
- <Match when={sync.data.project.length > 0}>
|
|
1734
|
+
- <div class="mt-20 w-full flex flex-col gap-4">
|
|
1735
|
+
- <div class="flex gap-2 items-center justify-between pl-3">
|
|
1736
|
+
- <div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
|
|
1737
|
+
- <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
|
|
1738
|
+
- {language.t("command.project.open")}
|
|
1739
|
+
- </Button>
|
|
1740
|
+
- </div>
|
|
1741
|
+
- <ul class="flex flex-col gap-2">
|
|
1742
|
+
- <For each={recent()}>
|
|
1743
|
+
- {(project) => (
|
|
1744
|
+
- <Button
|
|
1745
|
+
- size="large"
|
|
1746
|
+
- variant="ghost"
|
|
1747
|
+
- class="text-14-mono text-left justify-between px-3"
|
|
1748
|
+
- onClick={() => openProject(project.worktree)}
|
|
1749
|
+
- >
|
|
1750
|
+
- {project.worktree.replace(homedir(), "~")}
|
|
1751
|
+
- <div class="text-14-regular text-text-weak">
|
|
1752
|
+
- {DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
|
|
1753
|
+
- </div>
|
|
1754
|
+
- </Button>
|
|
1755
|
+
- )}
|
|
1756
|
+
- </For>
|
|
1757
|
+
- </ul>
|
|
1758
|
+
- </div>
|
|
1759
|
+
- </Match>
|
|
1760
|
+
- <Match when={true}>
|
|
1761
|
+
- <div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
|
1762
|
+
- <Icon name="folder-add-left" size="large" />
|
|
1763
|
+
- <div class="flex flex-col gap-1 items-center justify-center">
|
|
1764
|
+
- <div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
|
|
1765
|
+
- <div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
|
|
1766
|
+
- </div>
|
|
1767
|
+
- <Button class="px-3 mt-1" onClick={chooseProject}>
|
|
1768
|
+
- {language.t("command.project.open")}
|
|
1769
|
+
- </Button>
|
|
1770
|
+
+ <div class="mx-auto mt-55 w-full max-w-xl px-4">
|
|
1771
|
+
+ <div class="rounded-xl border border-border-weak-base bg-surface-raised-base px-5 py-5">
|
|
1772
|
+
+ <div class="text-16-medium text-text-strong">Agent Mockingbird</div>
|
|
1773
|
+
+ <div class="mt-2 text-14-regular text-text-weak">Opening the pinned workspace…</div>
|
|
1774
|
+
+ <Show when={error()}>
|
|
1775
|
+
+ <div class="mt-4 rounded-lg border border-icon-critical-base/20 bg-icon-critical-base/8 px-3 py-2 text-13-regular text-text-strong">
|
|
1776
|
+
+ {error()}
|
|
1777
|
+
</div>
|
|
1778
|
+
- </Match>
|
|
1779
|
+
- </Switch>
|
|
1780
|
+
+ </Show>
|
|
1781
|
+
+ </div>
|
|
1782
|
+
</div>
|
|
1783
|
+
)
|
|
1784
|
+
}
|
|
1785
|
+
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
|
|
1786
|
+
index ab2687dca..5626c040b 100644
|
|
1787
|
+
--- a/packages/app/src/pages/layout.tsx
|
|
1788
|
+
+++ b/packages/app/src/pages/layout.tsx
|
|
1789
|
+
@@ -2,6 +2,8 @@ import {
|
|
1790
|
+
batch,
|
|
1791
|
+
createEffect,
|
|
1792
|
+
createMemo,
|
|
1793
|
+
+ createResource,
|
|
1794
|
+
+ createSignal,
|
|
1795
|
+
For,
|
|
1796
|
+
on,
|
|
1797
|
+
onCleanup,
|
|
1798
|
+
@@ -57,7 +59,6 @@ import { setSessionHandoff } from "@/pages/session/handoff"
|
|
1799
|
+
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
1800
|
+
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
|
1801
|
+
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
|
1802
|
+
-import { DialogSelectServer } from "@/components/dialog-select-server"
|
|
1803
|
+
import { DialogSettings } from "@/components/dialog-settings"
|
|
1804
|
+
import { useCommand, type CommandOption } from "@/context/command"
|
|
1805
|
+
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
|
1806
|
+
@@ -90,6 +91,7 @@ import {
|
|
1807
|
+
} from "./layout/sidebar-workspace"
|
|
1808
|
+
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
|
1809
|
+
import { SidebarContent } from "./layout/sidebar-shell"
|
|
1810
|
+
+import { readPinnedWorkspace } from "@/utils/waffle"
|
|
1811
|
+
|
|
1812
|
+
export default function Layout(props: ParentProps) {
|
|
1813
|
+
const [store, setStore, , ready] = persisted(
|
|
1814
|
+
@@ -127,6 +129,7 @@ export default function Layout(props: ParentProps) {
|
|
1815
|
+
const command = useCommand()
|
|
1816
|
+
const theme = useTheme()
|
|
1817
|
+
const language = useLanguage()
|
|
1818
|
+
+ const [pinnedWorkspace] = createResource(readPinnedWorkspace)
|
|
1819
|
+
const initialDirectory = decode64(params.dir)
|
|
1820
|
+
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
|
|
1821
|
+
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
|
|
1822
|
+
@@ -137,6 +140,7 @@ export default function Layout(props: ParentProps) {
|
|
1823
|
+
}
|
|
1824
|
+
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
|
|
1825
|
+
const currentDir = createMemo(() => decode64(params.dir) ?? "")
|
|
1826
|
+
+ const pinnedDirectory = createMemo(() => pinnedWorkspace()?.directory?.trim() ?? "")
|
|
1827
|
+
|
|
1828
|
+
const [state, setState] = createStore({
|
|
1829
|
+
autoselect: !initialDirectory,
|
|
1830
|
+
@@ -572,6 +576,30 @@ export default function Layout(props: ParentProps) {
|
|
1831
|
+
return projects.find((p) => p.worktree === root)
|
|
1832
|
+
})
|
|
1833
|
+
|
|
1834
|
+
+ const sidebarProjects = createMemo(() => {
|
|
1835
|
+
+ const pinned = pinnedDirectory()
|
|
1836
|
+
+ const projects = layout.projects.list()
|
|
1837
|
+
+ if (!pinned) return projects
|
|
1838
|
+
+ const pinnedKey = workspaceKey(pinned)
|
|
1839
|
+
+ const matches = projects.filter((project) => {
|
|
1840
|
+
+ if (workspaceKey(project.worktree) === pinnedKey) return true
|
|
1841
|
+
+ return project.sandboxes?.some((directory) => workspaceKey(directory) === pinnedKey) ?? false
|
|
1842
|
+
+ })
|
|
1843
|
+
+ if (matches.length > 0) return matches
|
|
1844
|
+
+
|
|
1845
|
+
+ const current = currentProject()
|
|
1846
|
+
+ if (!current) return []
|
|
1847
|
+
+ if (workspaceKey(current.worktree) === pinnedKey) return [current]
|
|
1848
|
+
+ if (current.sandboxes?.some((directory) => workspaceKey(directory) === pinnedKey)) return [current]
|
|
1849
|
+
+ return []
|
|
1850
|
+
+ })
|
|
1851
|
+
+
|
|
1852
|
+
+ createEffect(() => {
|
|
1853
|
+
+ const directory = currentDir()
|
|
1854
|
+
+ if (!directory) return
|
|
1855
|
+
+ layout.projects.open(directory)
|
|
1856
|
+
+ })
|
|
1857
|
+
+
|
|
1858
|
+
createEffect(
|
|
1859
|
+
on(
|
|
1860
|
+
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
|
|
1861
|
+
@@ -624,10 +652,7 @@ export default function Layout(props: ParentProps) {
|
|
1862
|
+
workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
|
1863
|
+
|
|
1864
|
+
const workspaceSetting = createMemo(() => {
|
|
1865
|
+
- const project = currentProject()
|
|
1866
|
+
- if (!project) return false
|
|
1867
|
+
- if (project.vcs !== "git") return false
|
|
1868
|
+
- return layout.sidebar.workspaces(project.worktree)()
|
|
1869
|
+
+ return false
|
|
1870
|
+
})
|
|
1871
|
+
|
|
1872
|
+
const visibleSessionDirs = createMemo(() => {
|
|
1873
|
+
@@ -999,25 +1024,12 @@ export default function Layout(props: ParentProps) {
|
|
1874
|
+
keybind: "mod+b",
|
|
1875
|
+
onSelect: () => layout.sidebar.toggle(),
|
|
1876
|
+
},
|
|
1877
|
+
- {
|
|
1878
|
+
- id: "project.open",
|
|
1879
|
+
- title: language.t("command.project.open"),
|
|
1880
|
+
- category: language.t("command.category.project"),
|
|
1881
|
+
- keybind: "mod+o",
|
|
1882
|
+
- onSelect: () => chooseProject(),
|
|
1883
|
+
- },
|
|
1884
|
+
{
|
|
1885
|
+
id: "provider.connect",
|
|
1886
|
+
title: language.t("command.provider.connect"),
|
|
1887
|
+
category: language.t("command.category.provider"),
|
|
1888
|
+
onSelect: () => connectProvider(),
|
|
1889
|
+
},
|
|
1890
|
+
- {
|
|
1891
|
+
- id: "server.switch",
|
|
1892
|
+
- title: language.t("command.server.switch"),
|
|
1893
|
+
- category: language.t("command.category.server"),
|
|
1894
|
+
- onSelect: () => openServer(),
|
|
1895
|
+
- },
|
|
1896
|
+
{
|
|
1897
|
+
id: "settings.open",
|
|
1898
|
+
title: language.t("command.settings.open"),
|
|
1899
|
+
@@ -1064,41 +1076,6 @@ export default function Layout(props: ParentProps) {
|
|
1900
|
+
if (session) archiveSession(session)
|
|
1901
|
+
},
|
|
1902
|
+
},
|
|
1903
|
+
- {
|
|
1904
|
+
- id: "workspace.new",
|
|
1905
|
+
- title: language.t("workspace.new"),
|
|
1906
|
+
- category: language.t("command.category.workspace"),
|
|
1907
|
+
- keybind: "mod+shift+w",
|
|
1908
|
+
- disabled: !workspaceSetting(),
|
|
1909
|
+
- onSelect: () => {
|
|
1910
|
+
- const project = currentProject()
|
|
1911
|
+
- if (!project) return
|
|
1912
|
+
- return createWorkspace(project)
|
|
1913
|
+
- },
|
|
1914
|
+
- },
|
|
1915
|
+
- {
|
|
1916
|
+
- id: "workspace.toggle",
|
|
1917
|
+
- title: language.t("command.workspace.toggle"),
|
|
1918
|
+
- description: language.t("command.workspace.toggle.description"),
|
|
1919
|
+
- category: language.t("command.category.workspace"),
|
|
1920
|
+
- slash: "workspace",
|
|
1921
|
+
- disabled: !currentProject() || currentProject()?.vcs !== "git",
|
|
1922
|
+
- onSelect: () => {
|
|
1923
|
+
- const project = currentProject()
|
|
1924
|
+
- if (!project) return
|
|
1925
|
+
- if (project.vcs !== "git") return
|
|
1926
|
+
- const wasEnabled = layout.sidebar.workspaces(project.worktree)()
|
|
1927
|
+
- layout.sidebar.toggleWorkspaces(project.worktree)
|
|
1928
|
+
- showToast({
|
|
1929
|
+
- title: wasEnabled
|
|
1930
|
+
- ? language.t("toast.workspace.disabled.title")
|
|
1931
|
+
- : language.t("toast.workspace.enabled.title"),
|
|
1932
|
+
- description: wasEnabled
|
|
1933
|
+
- ? language.t("toast.workspace.disabled.description")
|
|
1934
|
+
- : language.t("toast.workspace.enabled.description"),
|
|
1935
|
+
- })
|
|
1936
|
+
- },
|
|
1937
|
+
- },
|
|
1938
|
+
{
|
|
1939
|
+
id: "theme.cycle",
|
|
1940
|
+
title: language.t("command.theme.cycle"),
|
|
1941
|
+
@@ -1165,10 +1142,6 @@ export default function Layout(props: ParentProps) {
|
|
1942
|
+
dialog.show(() => <DialogSelectProvider />)
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
- function openServer() {
|
|
1946
|
+
- dialog.show(() => <DialogSelectServer />)
|
|
1947
|
+
- }
|
|
1948
|
+
-
|
|
1949
|
+
function openSettings() {
|
|
1950
|
+
dialog.show(() => <DialogSettings />)
|
|
1951
|
+
}
|
|
1952
|
+
@@ -2299,6 +2272,7 @@ export default function Layout(props: ParentProps) {
|
|
1953
|
+
arm()
|
|
1954
|
+
}}
|
|
1955
|
+
>
|
|
1956
|
+
+ <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
|
1957
|
+
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
|
1958
|
+
<Show when={layout.sidebar.opened()}>
|
|
1959
|
+
<div onPointerDown={() => setState("sizing", true)}>
|
|
1960
|
+
@@ -2347,6 +2321,7 @@ export default function Layout(props: ParentProps) {
|
|
1961
|
+
onClick={(e) => e.stopPropagation()}
|
|
1962
|
+
>
|
|
1963
|
+
{sidebarContent(true)}
|
|
1964
|
+
+ {sidebarContent(true)}
|
|
1965
|
+
</nav>
|
|
1966
|
+
</div>
|
|
1967
|
+
|
|
1968
|
+
diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx
|
|
1969
|
+
index a26bc1831..ea7e4d9dc 100644
|
|
1970
|
+
--- a/packages/app/src/pages/layout/sidebar-project.tsx
|
|
1971
|
+
+++ b/packages/app/src/pages/layout/sidebar-project.tsx
|
|
1972
|
+
@@ -143,21 +143,6 @@ const ProjectTile = (props: {
|
|
1973
|
+
</ContextMenu.Trigger>
|
|
1974
|
+
<ContextMenu.Portal>
|
|
1975
|
+
<ContextMenu.Content>
|
|
1976
|
+
- <ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
|
1977
|
+
- <ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
|
1978
|
+
- </ContextMenu.Item>
|
|
1979
|
+
- <ContextMenu.Item
|
|
1980
|
+
- data-action="project-workspaces-toggle"
|
|
1981
|
+
- data-project={base64Encode(props.project.worktree)}
|
|
1982
|
+
- disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
|
|
1983
|
+
- onSelect={() => props.toggleProjectWorkspaces(props.project)}
|
|
1984
|
+
- >
|
|
1985
|
+
- <ContextMenu.ItemLabel>
|
|
1986
|
+
- {props.workspacesEnabled(props.project)
|
|
1987
|
+
- ? props.language.t("sidebar.workspaces.disable")
|
|
1988
|
+
- : props.language.t("sidebar.workspaces.enable")}
|
|
1989
|
+
- </ContextMenu.ItemLabel>
|
|
1990
|
+
- </ContextMenu.Item>
|
|
1991
|
+
<ContextMenu.Item
|
|
1992
|
+
data-action="project-clear-notifications"
|
|
1993
|
+
data-project={base64Encode(props.project.worktree)}
|
|
1994
|
+
@@ -166,14 +151,6 @@ const ProjectTile = (props: {
|
|
1995
|
+
>
|
|
1996
|
+
<ContextMenu.ItemLabel>{props.language.t("sidebar.project.clearNotifications")}</ContextMenu.ItemLabel>
|
|
1997
|
+
</ContextMenu.Item>
|
|
1998
|
+
- <ContextMenu.Separator />
|
|
1999
|
+
- <ContextMenu.Item
|
|
2000
|
+
- data-action="project-close-menu"
|
|
2001
|
+
- data-project={base64Encode(props.project.worktree)}
|
|
2002
|
+
- onSelect={() => props.closeProject(props.project.worktree)}
|
|
2003
|
+
- >
|
|
2004
|
+
- <ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
|
|
2005
|
+
- </ContextMenu.Item>
|
|
2006
|
+
</ContextMenu.Content>
|
|
2007
|
+
</ContextMenu.Portal>
|
|
2008
|
+
</ContextMenu>
|
|
2009
|
+
diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx
|
|
2010
|
+
index ca36af2a4..924828c2f 100644
|
|
2011
|
+
--- a/packages/app/src/pages/layout/sidebar-shell.tsx
|
|
2012
|
+
+++ b/packages/app/src/pages/layout/sidebar-shell.tsx
|
|
2013
|
+
@@ -21,9 +21,9 @@ export const SidebarContent = (props: {
|
|
2014
|
+
handleDragStart: (event: unknown) => void
|
|
2015
|
+
handleDragEnd: () => void
|
|
2016
|
+
handleDragOver: (event: DragEvent) => void
|
|
2017
|
+
- openProjectLabel: JSX.Element
|
|
2018
|
+
- openProjectKeybind: Accessor<string | undefined>
|
|
2019
|
+
- onOpenProject: () => void
|
|
2020
|
+
+ openProjectLabel?: JSX.Element
|
|
2021
|
+
+ openProjectKeybind?: Accessor<string | undefined>
|
|
2022
|
+
+ onOpenProject?: () => void
|
|
2023
|
+
renderProjectOverlay: () => JSX.Element
|
|
2024
|
+
settingsLabel: Accessor<string>
|
|
2025
|
+
settingsKeybind: Accessor<string | undefined>
|
|
2026
|
+
@@ -66,25 +66,6 @@ export const SidebarContent = (props: {
|
|
2027
|
+
<SortableProvider ids={props.projects().map((p) => p.worktree)}>
|
|
2028
|
+
<For each={props.projects()}>{(project) => props.renderProject(project)}</For>
|
|
2029
|
+
</SortableProvider>
|
|
2030
|
+
- <Tooltip
|
|
2031
|
+
- placement={placement()}
|
|
2032
|
+
- value={
|
|
2033
|
+
- <div class="flex items-center gap-2">
|
|
2034
|
+
- <span>{props.openProjectLabel}</span>
|
|
2035
|
+
- <Show when={!props.mobile && !!props.openProjectKeybind()}>
|
|
2036
|
+
- <span class="text-icon-base text-12-medium">{props.openProjectKeybind()}</span>
|
|
2037
|
+
- </Show>
|
|
2038
|
+
- </div>
|
|
2039
|
+
- }
|
|
2040
|
+
- >
|
|
2041
|
+
- <IconButton
|
|
2042
|
+
- icon="plus"
|
|
2043
|
+
- variant="ghost"
|
|
2044
|
+
- size="large"
|
|
2045
|
+
- onClick={props.onOpenProject}
|
|
2046
|
+
- aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined}
|
|
2047
|
+
- />
|
|
2048
|
+
- </Tooltip>
|
|
2049
|
+
</div>
|
|
2050
|
+
<DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
|
|
2051
|
+
</DragDropProvider>
|
|
2052
|
+
diff --git a/packages/app/src/utils/waffle.ts b/packages/app/src/utils/waffle.ts
|
|
2053
|
+
new file mode 100644
|
|
2054
|
+
index 000000000..79286f66b
|
|
2055
|
+
--- /dev/null
|
|
2056
|
+
+++ b/packages/app/src/utils/waffle.ts
|
|
2057
|
+
@@ -0,0 +1,61 @@
|
|
2058
|
+
+import { base64Encode } from "@opencode-ai/util/encode"
|
|
2059
|
+
+
|
|
2060
|
+
+export class WaffleApiError extends Error {
|
|
2061
|
+
+ status: number
|
|
2062
|
+
+
|
|
2063
|
+
+ constructor(message: string, status: number) {
|
|
2064
|
+
+ super(message)
|
|
2065
|
+
+ this.name = "WaffleApiError"
|
|
2066
|
+
+ this.status = status
|
|
2067
|
+
+ }
|
|
2068
|
+
+}
|
|
2069
|
+
+
|
|
2070
|
+
+async function parseJson<T>(response: Response): Promise<T> {
|
|
2071
|
+
+ const contentType = response.headers.get("content-type") ?? ""
|
|
2072
|
+
+ if (!contentType.includes("application/json")) {
|
|
2073
|
+
+ throw new WaffleApiError(`Expected JSON response from ${response.url}`, response.status)
|
|
2074
|
+
+ }
|
|
2075
|
+
+
|
|
2076
|
+
+ return (await response.json()) as T
|
|
2077
|
+
+}
|
|
2078
|
+
+
|
|
2079
|
+
+export async function waffleJson<T>(path: string, init?: RequestInit): Promise<T> {
|
|
2080
|
+
+ const response = await fetch(path, {
|
|
2081
|
+
+ ...init,
|
|
2082
|
+
+ headers: {
|
|
2083
|
+
+ ...(init?.body ? { "Content-Type": "application/json" } : {}),
|
|
2084
|
+
+ ...(init?.headers ?? {}),
|
|
2085
|
+
+ },
|
|
2086
|
+
+ })
|
|
2087
|
+
+
|
|
2088
|
+
+ if (!response.ok) {
|
|
2089
|
+
+ let message = `${response.status} ${response.statusText}`.trim()
|
|
2090
|
+
+ try {
|
|
2091
|
+
+ const payload = await parseJson<{ error?: string }>(response)
|
|
2092
|
+
+ if (payload.error?.trim()) message = payload.error.trim()
|
|
2093
|
+
+ } catch {
|
|
2094
|
+
+ // Preserve the default HTTP status message when the error body is not JSON.
|
|
2095
|
+
+ }
|
|
2096
|
+
+ throw new WaffleApiError(message, response.status)
|
|
2097
|
+
+ }
|
|
2098
|
+
+
|
|
2099
|
+
+ return parseJson<T>(response)
|
|
2100
|
+
+}
|
|
2101
|
+
+
|
|
2102
|
+
+export function prettyJson(value: unknown) {
|
|
2103
|
+
+ return JSON.stringify(value, null, 2)
|
|
2104
|
+
+}
|
|
2105
|
+
+
|
|
2106
|
+
+export function workspaceHref(directory: string, sessionID?: string) {
|
|
2107
|
+
+ const slug = base64Encode(directory)
|
|
2108
|
+
+ return sessionID ? `/${slug}/session/${sessionID}` : `/${slug}/session`
|
|
2109
|
+
+}
|
|
2110
|
+
+
|
|
2111
|
+
+export interface PinnedWorkspacePayload {
|
|
2112
|
+
+ directory: string
|
|
2113
|
+
+ hash: string
|
|
2114
|
+
+}
|
|
2115
|
+
+
|
|
2116
|
+
+export function readPinnedWorkspace() {
|
|
2117
|
+
+ return waffleJson<PinnedWorkspacePayload>("/api/waffle/runtime/pinned-workspace")
|
|
2118
|
+
+}
|
|
2119
|
+
diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts
|
|
2120
|
+
index 631ca7811..870c7a7cb 100644
|
|
2121
|
+
--- a/packages/opencode/src/cli/cmd/providers.ts
|
|
2122
|
+
+++ b/packages/opencode/src/cli/cmd/providers.ts
|
|
2123
|
+
@@ -166,7 +166,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
export function resolvePluginProviders(input: {
|
|
2127
|
+
- hooks: Hooks[]
|
|
2128
|
+
+ hooks: Array<Hooks | null | undefined>
|
|
2129
|
+
existingProviders: Record<string, unknown>
|
|
2130
|
+
disabled: Set<string>
|
|
2131
|
+
enabled?: Set<string>
|
|
2132
|
+
@@ -176,6 +176,7 @@ export function resolvePluginProviders(input: {
|
|
2133
|
+
const result: Array<{ id: string; name: string }> = []
|
|
2134
|
+
|
|
2135
|
+
for (const hook of input.hooks) {
|
|
2136
|
+
+ if (!hook) continue
|
|
2137
|
+
if (!hook.auth) continue
|
|
2138
|
+
const id = hook.auth.provider
|
|
2139
|
+
if (seen.has(id)) continue
|
|
2140
|
+
@@ -384,7 +385,7 @@ export const ProvidersLoginCommand = cmd({
|
|
2141
|
+
provider = selected as string
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
- const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
|
2145
|
+
+ const plugin = await Plugin.list().then((x) => x.findLast((x) => x?.auth?.provider === provider))
|
|
2146
|
+
if (plugin && plugin.auth) {
|
|
2147
|
+
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
|
2148
|
+
if (handled) return
|
|
2149
|
+
@@ -398,7 +399,7 @@ export const ProvidersLoginCommand = cmd({
|
|
2150
|
+
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
|
2151
|
+
provider = custom.replace(/^@ai-sdk\//, "")
|
|
2152
|
+
|
|
2153
|
+
- const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
|
2154
|
+
+ const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x?.auth?.provider === provider))
|
|
2155
|
+
if (customPlugin && customPlugin.auth) {
|
|
2156
|
+
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
|
2157
|
+
if (handled) return
|
|
2158
|
+
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
|
|
2159
|
+
index 8790efac4..1910ffd65 100644
|
|
2160
|
+
--- a/packages/opencode/src/plugin/index.ts
|
|
2161
|
+
+++ b/packages/opencode/src/plugin/index.ts
|
|
2162
|
+
@@ -21,6 +21,30 @@ export namespace Plugin {
|
|
2163
|
+
// Built-in plugins that are directly imported (not installed from npm)
|
|
2164
|
+
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
|
|
2165
|
+
|
|
2166
|
+
+ function isHooks(value: unknown): value is Hooks {
|
|
2167
|
+
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
2168
|
+
+ }
|
|
2169
|
+
+
|
|
2170
|
+
+ async function collectHooksFromModuleExports(mod: Record<string, unknown>, input: PluginInput) {
|
|
2171
|
+
+ const hooks: Hooks[] = []
|
|
2172
|
+
+ const seen = new Set<PluginInstance>()
|
|
2173
|
+
+
|
|
2174
|
+
+ for (const [name, exported] of Object.entries(mod)) {
|
|
2175
|
+
+ if (typeof exported !== "function") continue
|
|
2176
|
+
+ const fn = exported as PluginInstance
|
|
2177
|
+
+ if (seen.has(fn)) continue
|
|
2178
|
+
+ seen.add(fn)
|
|
2179
|
+
+ const init = await fn(input)
|
|
2180
|
+
+ if (!isHooks(init)) {
|
|
2181
|
+
+ log.info("ignoring non-hook plugin export", { name })
|
|
2182
|
+
+ continue
|
|
2183
|
+
+ }
|
|
2184
|
+
+ hooks.push(init)
|
|
2185
|
+
+ }
|
|
2186
|
+
+
|
|
2187
|
+
+ return hooks
|
|
2188
|
+
+ }
|
|
2189
|
+
+
|
|
2190
|
+
const state = Instance.state(async () => {
|
|
2191
|
+
const client = createOpencodeClient({
|
|
2192
|
+
baseUrl: "http://localhost:4096",
|
|
2193
|
+
@@ -85,12 +109,7 @@ export namespace Plugin {
|
|
2194
|
+
// Object.entries(mod) would return both entries pointing to the same function reference.
|
|
2195
|
+
await import(plugin)
|
|
2196
|
+
.then(async (mod) => {
|
|
2197
|
+
- const seen = new Set<PluginInstance>()
|
|
2198
|
+
- for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
|
|
2199
|
+
- if (seen.has(fn)) continue
|
|
2200
|
+
- seen.add(fn)
|
|
2201
|
+
- hooks.push(await fn(input))
|
|
2202
|
+
- }
|
|
2203
|
+
+ hooks.push(...(await collectHooksFromModuleExports(mod as Record<string, unknown>, input)))
|
|
2204
|
+
})
|
|
2205
|
+
.catch((err) => {
|
|
2206
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
2207
|
+
@@ -116,6 +135,7 @@ export namespace Plugin {
|
|
2208
|
+
>(name: Name, input: Input, output: Output): Promise<Output> {
|
|
2209
|
+
if (!name) return output
|
|
2210
|
+
for (const hook of await state().then((x) => x.hooks)) {
|
|
2211
|
+
+ if (!hook) continue
|
|
2212
|
+
const fn = hook[name]
|
|
2213
|
+
if (!fn) continue
|
|
2214
|
+
// @ts-expect-error if you feel adventurous, please fix the typing, make sure to bump the try-counter if you
|
|
2215
|
+
@@ -134,16 +154,23 @@ export namespace Plugin {
|
|
2216
|
+
const hooks = await state().then((x) => x.hooks)
|
|
2217
|
+
const config = await Config.get()
|
|
2218
|
+
for (const hook of hooks) {
|
|
2219
|
+
+ if (!hook) continue
|
|
2220
|
+
// @ts-expect-error this is because we haven't moved plugin to sdk v2
|
|
2221
|
+
await hook.config?.(config)
|
|
2222
|
+
}
|
|
2223
|
+
Bus.subscribeAll(async (input) => {
|
|
2224
|
+
const hooks = await state().then((x) => x.hooks)
|
|
2225
|
+
for (const hook of hooks) {
|
|
2226
|
+
+ if (!hook) continue
|
|
2227
|
+
hook["event"]?.({
|
|
2228
|
+
event: input,
|
|
2229
|
+
})
|
|
2230
|
+
}
|
|
2231
|
+
})
|
|
2232
|
+
}
|
|
2233
|
+
+
|
|
2234
|
+
+ export const testing = {
|
|
2235
|
+
+ collectHooksFromModuleExports,
|
|
2236
|
+
+ isHooks,
|
|
2237
|
+
+ }
|
|
2238
|
+
}
|
|
2239
|
+
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
|
|
2240
|
+
index 349073197..40b608c1d 100644
|
|
2241
|
+
--- a/packages/opencode/src/provider/provider.ts
|
|
2242
|
+
+++ b/packages/opencode/src/provider/provider.ts
|
|
2243
|
+
@@ -999,6 +999,7 @@ export namespace Provider {
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
for (const plugin of await Plugin.list()) {
|
|
2247
|
+
+ if (!plugin) continue
|
|
2248
|
+
if (!plugin.auth) continue
|
|
2249
|
+
const providerID = ProviderID.make(plugin.auth.provider)
|
|
2250
|
+
if (disabled.has(providerID)) continue
|
|
2251
|
+
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
|
|
2252
|
+
index 55bcf2dfc..7b67b5568 100644
|
|
2253
|
+
--- a/packages/opencode/src/server/server.ts
|
|
2254
|
+
+++ b/packages/opencode/src/server/server.ts
|
|
2255
|
+
@@ -54,7 +54,7 @@ export namespace Server {
|
|
2256
|
+
|
|
2257
|
+
export const Default = lazy(() => createApp({}))
|
|
2258
|
+
|
|
2259
|
+
- export const createApp = (opts: { cors?: string[] }): Hono => {
|
|
2260
|
+
+ export const createApp = (opts: { cors?: string[]; localApp?: boolean }): Hono => {
|
|
2261
|
+
const app = new Hono()
|
|
2262
|
+
return app
|
|
2263
|
+
.onError((err, c) => {
|
|
2264
|
+
@@ -555,6 +555,10 @@ export namespace Server {
|
|
2265
|
+
},
|
|
2266
|
+
)
|
|
2267
|
+
.all("/*", async (c) => {
|
|
2268
|
+
+ if (opts.localApp) {
|
|
2269
|
+
+ return c.notFound()
|
|
2270
|
+
+ }
|
|
2271
|
+
+
|
|
2272
|
+
const path = c.req.path
|
|
2273
|
+
|
|
2274
|
+
const response = await proxy(`https://app.opencode.ai${path}`, {
|
|
2275
|
+
diff --git a/packages/opencode/test/cli/plugin-auth-picker.test.ts b/packages/opencode/test/cli/plugin-auth-picker.test.ts
|
|
2276
|
+
index 5a1cf059d..33656399e 100644
|
|
2277
|
+
--- a/packages/opencode/test/cli/plugin-auth-picker.test.ts
|
|
2278
|
+
+++ b/packages/opencode/test/cli/plugin-auth-picker.test.ts
|
|
2279
|
+
@@ -117,4 +117,14 @@ describe("resolvePluginProviders", () => {
|
|
2280
|
+
})
|
|
2281
|
+
expect(result).toEqual([])
|
|
2282
|
+
})
|
|
2283
|
+
+
|
|
2284
|
+
+ test("tolerates undefined hooks from malformed plugin exports", () => {
|
|
2285
|
+
+ const result = resolvePluginProviders({
|
|
2286
|
+
+ hooks: [undefined, hookWithAuth("portkey"), undefined],
|
|
2287
|
+
+ existingProviders: {},
|
|
2288
|
+
+ disabled: new Set(),
|
|
2289
|
+
+ providerNames: {},
|
|
2290
|
+
+ })
|
|
2291
|
+
+ expect(result).toEqual([{ id: "portkey", name: "portkey" }])
|
|
2292
|
+
+ })
|
|
2293
|
+
})
|
|
2294
|
+
diff --git a/packages/opencode/test/plugin/module-exports.test.ts b/packages/opencode/test/plugin/module-exports.test.ts
|
|
2295
|
+
new file mode 100644
|
|
2296
|
+
index 000000000..07c24b3ca
|
|
2297
|
+
--- /dev/null
|
|
2298
|
+
+++ b/packages/opencode/test/plugin/module-exports.test.ts
|
|
2299
|
+
@@ -0,0 +1,39 @@
|
|
2300
|
+
+import { describe, expect, test } from "bun:test"
|
|
2301
|
+
+
|
|
2302
|
+
+import { Plugin } from "../../src/plugin"
|
|
2303
|
+
+
|
|
2304
|
+
+describe("Plugin.testing.collectHooksFromModuleExports", () => {
|
|
2305
|
+
+ test("ignores helper exports that do not return hooks", async () => {
|
|
2306
|
+
+ const pluginFactory = async () => ({
|
|
2307
|
+
+ auth: {
|
|
2308
|
+
+ provider: "portkey",
|
|
2309
|
+
+ methods: [],
|
|
2310
|
+
+ },
|
|
2311
|
+
+ })
|
|
2312
|
+
+ const helper = () => undefined
|
|
2313
|
+
+
|
|
2314
|
+
+ const hooks = await Plugin.testing.collectHooksFromModuleExports(
|
|
2315
|
+
+ {
|
|
2316
|
+
+ default: pluginFactory,
|
|
2317
|
+
+ pluginFactory,
|
|
2318
|
+
+ helper,
|
|
2319
|
+
+ },
|
|
2320
|
+
+ {} as never,
|
|
2321
|
+
+ )
|
|
2322
|
+
+
|
|
2323
|
+
+ expect(hooks).toHaveLength(1)
|
|
2324
|
+
+ expect(hooks[0]?.auth?.provider).toBe("portkey")
|
|
2325
|
+
+ })
|
|
2326
|
+
+
|
|
2327
|
+
+ test("ignores non-function exports", async () => {
|
|
2328
|
+
+ const hooks = await Plugin.testing.collectHooksFromModuleExports(
|
|
2329
|
+
+ {
|
|
2330
|
+
+ value: 123,
|
|
2331
|
+
+ text: "hello",
|
|
2332
|
+
+ },
|
|
2333
|
+
+ {} as never,
|
|
2334
|
+
+ )
|
|
2335
|
+
+
|
|
2336
|
+
+ expect(hooks).toEqual([])
|
|
2337
|
+
+ })
|
|
2338
|
+
+})
|
|
2339
|
+
--
|
|
2340
|
+
2.53.0
|
|
2341
|
+
|