@agentprojectcontext/apx 1.15.5 → 1.16.0
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/package.json +40 -5
- package/src/cli/commands/log.js +113 -0
- package/src/cli/commands/overlay.js +253 -0
- package/src/cli/commands/sys.js +88 -16
- package/src/cli/index.js +23 -1
- package/src/cli/terminal-chat/renderer.js +71 -56
- package/src/cli-ts/commands/agent.ts +173 -0
- package/src/cli-ts/commands/chat.ts +119 -0
- package/src/cli-ts/commands/daemon.ts +112 -0
- package/src/cli-ts/commands/exec.ts +109 -0
- package/src/cli-ts/commands/mcp.ts +235 -0
- package/src/cli-ts/commands/session.ts +224 -0
- package/src/cli-ts/commands/status.ts +61 -0
- package/src/cli-ts/http.ts +36 -0
- package/src/cli-ts/index.ts +73 -0
- package/src/cli-ts/ui.ts +107 -0
- package/src/core/logging.js +81 -0
- package/src/daemon/api.js +58 -0
- package/src/daemon/engines/anthropic.js +60 -1
- package/src/daemon/engines/index.js +2 -1
- package/src/daemon/engines/ollama.js +70 -3
- package/src/daemon/index.js +58 -0
- package/src/daemon/overlay-ws.js +40 -0
- package/src/daemon/plugins/index.js +2 -1
- package/src/daemon/plugins/overlay.js +177 -0
- package/src/daemon/plugins/telegram.js +15 -3
- package/src/daemon/super-agent.js +102 -19
- package/src/daemon/transcription.js +262 -59
- package/src/daemon/wakeup.js +14 -19
- package/src/daemon/whisper-server.py +57 -6
- package/src/overlay/index.html +44 -0
- package/src/overlay/main.js +480 -0
- package/src/overlay/package.json +3 -0
- package/src/overlay/preload.js +34 -0
- package/src/overlay/renderer.js +371 -0
- package/src/overlay/style.css +250 -0
- package/src/tui/_shims/cli-error.ts +6 -0
- package/src/tui/_shims/cli-logo.ts +18 -0
- package/src/tui/_shims/cli-ui.ts +1 -0
- package/src/tui/_shims/config-console-state.ts +7 -0
- package/src/tui/_shims/core-any.ts +30 -0
- package/src/tui/_shims/core-binary.ts +13 -0
- package/src/tui/_shims/core-flag.ts +3 -0
- package/src/tui/_shims/core-log.ts +14 -0
- package/src/tui/_shims/lsp-language.ts +1 -0
- package/src/tui/_shims/opencode-any.ts +135 -0
- package/src/tui/_shims/opencode-sdk-v2.ts +48 -0
- package/src/tui/_shims/plugin-tui.ts +13 -0
- package/src/tui/_shims/provider-provider.ts +10 -0
- package/src/tui/_shims/session-retry.ts +1 -0
- package/src/tui/_shims/session-schema.ts +15 -0
- package/src/tui/_shims/session-session.ts +3 -0
- package/src/tui/_shims/snapshot.ts +4 -0
- package/src/tui/_shims/tool-any.ts +18 -0
- package/src/tui/_shims/util-error.ts +7 -0
- package/src/tui/_shims/util-filesystem.ts +79 -0
- package/src/tui/_shims/util-format.ts +7 -0
- package/src/tui/_shims/util-iife.ts +3 -0
- package/src/tui/_shims/util-locale.ts +10 -0
- package/src/tui/_shims/util-process.ts +38 -0
- package/src/tui/app.tsx +783 -0
- package/src/tui/asset/charge.wav +0 -0
- package/src/tui/asset/pulse-a.wav +0 -0
- package/src/tui/asset/pulse-b.wav +0 -0
- package/src/tui/asset/pulse-c.wav +0 -0
- package/src/tui/attach.ts +100 -0
- package/src/tui/component/bg-pulse-render.ts +436 -0
- package/src/tui/component/bg-pulse.tsx +99 -0
- package/src/tui/component/border.tsx +21 -0
- package/src/tui/component/dialog-agent.tsx +31 -0
- package/src/tui/component/dialog-console-org.tsx +103 -0
- package/src/tui/component/dialog-mcp.tsx +85 -0
- package/src/tui/component/dialog-model.tsx +175 -0
- package/src/tui/component/dialog-provider.tsx +456 -0
- package/src/tui/component/dialog-retry-action.tsx +160 -0
- package/src/tui/component/dialog-session-delete-failed.tsx +99 -0
- package/src/tui/component/dialog-session-list.tsx +323 -0
- package/src/tui/component/dialog-session-rename.tsx +31 -0
- package/src/tui/component/dialog-skill.tsx +36 -0
- package/src/tui/component/dialog-stash.tsx +87 -0
- package/src/tui/component/dialog-status.tsx +168 -0
- package/src/tui/component/dialog-tag.tsx +44 -0
- package/src/tui/component/dialog-theme-list.tsx +50 -0
- package/src/tui/component/dialog-variant.tsx +39 -0
- package/src/tui/component/dialog-workspace-create.tsx +302 -0
- package/src/tui/component/dialog-workspace-file-changes.tsx +138 -0
- package/src/tui/component/dialog-workspace-unavailable.tsx +69 -0
- package/src/tui/component/error-component.tsx +92 -0
- package/src/tui/component/logo.tsx +896 -0
- package/src/tui/component/plugin-route-missing.tsx +14 -0
- package/src/tui/component/prompt/autocomplete.tsx +869 -0
- package/src/tui/component/prompt/cwd.ts +0 -0
- package/src/tui/component/prompt/frecency.tsx +90 -0
- package/src/tui/component/prompt/history.tsx +108 -0
- package/src/tui/component/prompt/index.tsx +1809 -0
- package/src/tui/component/prompt/part.ts +16 -0
- package/src/tui/component/prompt/stash.tsx +101 -0
- package/src/tui/component/prompt/traits.ts +35 -0
- package/src/tui/component/spinner.tsx +24 -0
- package/src/tui/component/startup-loading.tsx +63 -0
- package/src/tui/component/todo-item.tsx +32 -0
- package/src/tui/component/use-connected.tsx +9 -0
- package/src/tui/component/workspace-label.tsx +19 -0
- package/src/tui/config/cwd.ts +5 -0
- package/src/tui/config/keybind.ts +432 -0
- package/src/tui/config/tui-migrate.ts +154 -0
- package/src/tui/config/tui-schema.ts +34 -0
- package/src/tui/config/tui.ts +46 -0
- package/src/tui/context/aggregate-failures.ts +34 -0
- package/src/tui/context/args.tsx +15 -0
- package/src/tui/context/command-palette.tsx +163 -0
- package/src/tui/context/directory.ts +15 -0
- package/src/tui/context/editor-zed.ts +283 -0
- package/src/tui/context/editor.ts +468 -0
- package/src/tui/context/event-apx.ts +22 -0
- package/src/tui/context/event.ts +6 -0
- package/src/tui/context/exit.tsx +60 -0
- package/src/tui/context/helper.tsx +25 -0
- package/src/tui/context/kv.tsx +81 -0
- package/src/tui/context/local.tsx +608 -0
- package/src/tui/context/path-format.tsx +39 -0
- package/src/tui/context/project-apx.tsx +48 -0
- package/src/tui/context/project.tsx +7 -0
- package/src/tui/context/prompt.tsx +18 -0
- package/src/tui/context/route.tsx +52 -0
- package/src/tui/context/sdk-apx.tsx +185 -0
- package/src/tui/context/sdk.tsx +6 -0
- package/src/tui/context/sync-apx.tsx +178 -0
- package/src/tui/context/sync-v2.tsx +16 -0
- package/src/tui/context/sync.tsx +118 -0
- package/src/tui/context/theme/aura.json +69 -0
- package/src/tui/context/theme/ayu.json +80 -0
- package/src/tui/context/theme/carbonfox.json +248 -0
- package/src/tui/context/theme/catppuccin-frappe.json +230 -0
- package/src/tui/context/theme/catppuccin-macchiato.json +230 -0
- package/src/tui/context/theme/catppuccin.json +112 -0
- package/src/tui/context/theme/cobalt2.json +225 -0
- package/src/tui/context/theme/cursor.json +249 -0
- package/src/tui/context/theme/dracula.json +219 -0
- package/src/tui/context/theme/everforest.json +241 -0
- package/src/tui/context/theme/flexoki.json +237 -0
- package/src/tui/context/theme/github.json +233 -0
- package/src/tui/context/theme/gruvbox.json +242 -0
- package/src/tui/context/theme/kanagawa.json +77 -0
- package/src/tui/context/theme/lucent-orng.json +234 -0
- package/src/tui/context/theme/material.json +235 -0
- package/src/tui/context/theme/matrix.json +77 -0
- package/src/tui/context/theme/mercury.json +252 -0
- package/src/tui/context/theme/monokai.json +221 -0
- package/src/tui/context/theme/nightowl.json +221 -0
- package/src/tui/context/theme/nord.json +223 -0
- package/src/tui/context/theme/one-dark.json +84 -0
- package/src/tui/context/theme/opencode.json +245 -0
- package/src/tui/context/theme/orng.json +249 -0
- package/src/tui/context/theme/osaka-jade.json +93 -0
- package/src/tui/context/theme/palenight.json +222 -0
- package/src/tui/context/theme/rosepine.json +234 -0
- package/src/tui/context/theme/solarized.json +223 -0
- package/src/tui/context/theme/synthwave84.json +226 -0
- package/src/tui/context/theme/tokyonight.json +243 -0
- package/src/tui/context/theme/vercel.json +245 -0
- package/src/tui/context/theme/vesper.json +218 -0
- package/src/tui/context/theme/zenburn.json +223 -0
- package/src/tui/context/theme.tsx +1247 -0
- package/src/tui/context/tui-config.tsx +9 -0
- package/src/tui/event.ts +16 -0
- package/src/tui/feature-plugins/home/footer.tsx +94 -0
- package/src/tui/feature-plugins/home/tips-view.tsx +166 -0
- package/src/tui/feature-plugins/home/tips.tsx +59 -0
- package/src/tui/feature-plugins/sidebar/context.tsx +65 -0
- package/src/tui/feature-plugins/sidebar/files.tsx +63 -0
- package/src/tui/feature-plugins/sidebar/footer.tsx +94 -0
- package/src/tui/feature-plugins/sidebar/lsp.tsx +65 -0
- package/src/tui/feature-plugins/sidebar/mcp.tsx +97 -0
- package/src/tui/feature-plugins/sidebar/todo.tsx +49 -0
- package/src/tui/feature-plugins/system/plugins.tsx +269 -0
- package/src/tui/feature-plugins/system/session-v2.tsx +1143 -0
- package/src/tui/feature-plugins/system/which-key.tsx +608 -0
- package/src/tui/keymap.tsx +166 -0
- package/src/tui/layer.ts +6 -0
- package/src/tui/plugin/api.tsx +381 -0
- package/src/tui/plugin/command-shim.ts +109 -0
- package/src/tui/plugin/internal.ts +33 -0
- package/src/tui/plugin/runtime.ts +1069 -0
- package/src/tui/plugin/slots.tsx +60 -0
- package/src/tui/routes/home.tsx +96 -0
- package/src/tui/routes/session/dialog-fork-from-timeline.tsx +76 -0
- package/src/tui/routes/session/dialog-message.tsx +108 -0
- package/src/tui/routes/session/dialog-subagent.tsx +26 -0
- package/src/tui/routes/session/dialog-timeline.tsx +47 -0
- package/src/tui/routes/session/footer.tsx +91 -0
- package/src/tui/routes/session/index.tsx +188 -0
- package/src/tui/routes/session/permission.tsx +722 -0
- package/src/tui/routes/session/question.tsx +490 -0
- package/src/tui/routes/session/sidebar.tsx +102 -0
- package/src/tui/routes/session/subagent-footer.tsx +133 -0
- package/src/tui/run.ts +84 -0
- package/src/tui/thread.ts +261 -0
- package/src/tui/tsconfig.json +40 -0
- package/src/tui/ui/dialog-alert.tsx +66 -0
- package/src/tui/ui/dialog-confirm.tsx +108 -0
- package/src/tui/ui/dialog-export-options.tsx +217 -0
- package/src/tui/ui/dialog-help.tsx +40 -0
- package/src/tui/ui/dialog-prompt.tsx +101 -0
- package/src/tui/ui/dialog-select.tsx +553 -0
- package/src/tui/ui/dialog.tsx +211 -0
- package/src/tui/ui/link.tsx +34 -0
- package/src/tui/ui/spinner.ts +368 -0
- package/src/tui/ui/toast.tsx +111 -0
- package/src/tui/util/clipboard.ts +217 -0
- package/src/tui/util/editor.ts +37 -0
- package/src/tui/util/model.ts +23 -0
- package/src/tui/util/provider-origin.ts +7 -0
- package/src/tui/util/revert-diff.ts +18 -0
- package/src/tui/util/scroll.ts +25 -0
- package/src/tui/util/selection.ts +65 -0
- package/src/tui/util/signal.ts +41 -0
- package/src/tui/util/sound.ts +156 -0
- package/src/tui/util/transcript.ts +112 -0
- package/src/tui/validate-session.ts +29 -0
- package/src/tui/win32.ts +130 -0
- package/src/tui/worker.ts +104 -0
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
import { runtimeModules as keymapRuntimeModules } from "@opentui/keymap/runtime-modules"
|
|
2
|
+
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
|
|
3
|
+
import {
|
|
4
|
+
type TuiDispose,
|
|
5
|
+
type TuiPlugin,
|
|
6
|
+
type TuiPluginApi,
|
|
7
|
+
type TuiPluginInstallResult,
|
|
8
|
+
type TuiPluginModule,
|
|
9
|
+
type TuiPluginMeta,
|
|
10
|
+
type TuiPluginStatus,
|
|
11
|
+
type TuiSlotPlugin,
|
|
12
|
+
type TuiTheme,
|
|
13
|
+
} from "@opencode-ai/plugin/tui"
|
|
14
|
+
import path from "path"
|
|
15
|
+
import { fileURLToPath } from "url"
|
|
16
|
+
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
|
17
|
+
import * as Log from "@opencode-ai/core/util/log"
|
|
18
|
+
import { errorData, errorMessage } from "@/util/error"
|
|
19
|
+
import { isRecord } from "@/util/record"
|
|
20
|
+
import {
|
|
21
|
+
readPackageThemes,
|
|
22
|
+
readPluginId,
|
|
23
|
+
readV1Plugin,
|
|
24
|
+
resolvePluginId,
|
|
25
|
+
type PluginPackage,
|
|
26
|
+
type PluginSource,
|
|
27
|
+
} from "@/plugin/shared"
|
|
28
|
+
import { PluginLoader } from "@/plugin/loader"
|
|
29
|
+
import { PluginMeta } from "@/plugin/meta"
|
|
30
|
+
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
|
|
31
|
+
import { hasTheme, upsertTheme } from "../context/theme"
|
|
32
|
+
import { Global } from "@opencode-ai/core/global"
|
|
33
|
+
import { Filesystem } from "@/util/filesystem"
|
|
34
|
+
import { Process } from "@/util/process"
|
|
35
|
+
import { Flock } from "@opencode-ai/core/util/flock"
|
|
36
|
+
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
37
|
+
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
|
38
|
+
import { setupSlots, Slot as View } from "./slots"
|
|
39
|
+
import type { HostPluginApi, HostSlots } from "./slots"
|
|
40
|
+
import { ConfigPlugin } from "@/config/plugin"
|
|
41
|
+
import { createCommandShim } from "./command-shim"
|
|
42
|
+
|
|
43
|
+
ensureRuntimePluginSupport({ additional: keymapRuntimeModules })
|
|
44
|
+
|
|
45
|
+
type PluginLoad = {
|
|
46
|
+
options: ConfigPlugin.Options | undefined
|
|
47
|
+
spec: string
|
|
48
|
+
target: string
|
|
49
|
+
retry: boolean
|
|
50
|
+
source: PluginSource | "internal"
|
|
51
|
+
id: string
|
|
52
|
+
module: TuiPluginModule
|
|
53
|
+
origin: ConfigPlugin.Origin
|
|
54
|
+
theme_root: string
|
|
55
|
+
theme_files: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Api = HostPluginApi
|
|
59
|
+
|
|
60
|
+
type PluginScope = {
|
|
61
|
+
lifecycle: TuiPluginApi["lifecycle"]
|
|
62
|
+
track: (fn: (() => void) | undefined) => () => void
|
|
63
|
+
dispose: () => Promise<void>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type PluginEntry = {
|
|
67
|
+
id: string
|
|
68
|
+
load: PluginLoad
|
|
69
|
+
meta: TuiPluginMeta
|
|
70
|
+
themes: Record<string, PluginMeta.Theme>
|
|
71
|
+
plugin: TuiPlugin
|
|
72
|
+
enabled: boolean
|
|
73
|
+
scope?: PluginScope
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ScopedKeymapMethods = new Set<PropertyKey>([
|
|
77
|
+
"acquireResource",
|
|
78
|
+
"registerLayer",
|
|
79
|
+
"registerLayerFields",
|
|
80
|
+
"prependLayerBindingsTransformer",
|
|
81
|
+
"appendLayerBindingsTransformer",
|
|
82
|
+
"prependBindingTransformer",
|
|
83
|
+
"appendBindingTransformer",
|
|
84
|
+
"prependBindingParser",
|
|
85
|
+
"appendBindingParser",
|
|
86
|
+
"registerToken",
|
|
87
|
+
"registerSequencePattern",
|
|
88
|
+
"prependBindingExpander",
|
|
89
|
+
"appendBindingExpander",
|
|
90
|
+
"registerBindingFields",
|
|
91
|
+
"registerCommandFields",
|
|
92
|
+
"prependCommandTransformer",
|
|
93
|
+
"appendCommandTransformer",
|
|
94
|
+
"prependCommandResolver",
|
|
95
|
+
"appendCommandResolver",
|
|
96
|
+
"prependLayerAnalyzer",
|
|
97
|
+
"appendLayerAnalyzer",
|
|
98
|
+
"intercept",
|
|
99
|
+
"on",
|
|
100
|
+
"prependEventMatchResolver",
|
|
101
|
+
"appendEventMatchResolver",
|
|
102
|
+
"prependDisambiguationResolver",
|
|
103
|
+
"appendDisambiguationResolver",
|
|
104
|
+
])
|
|
105
|
+
|
|
106
|
+
type RuntimeState = {
|
|
107
|
+
directory: string
|
|
108
|
+
api: Api
|
|
109
|
+
slots: HostSlots
|
|
110
|
+
plugins: PluginEntry[]
|
|
111
|
+
plugins_by_id: Map<string, PluginEntry>
|
|
112
|
+
pending: Map<string, ConfigPlugin.Origin>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const log = Log.create({ service: "tui.plugin" })
|
|
116
|
+
const DISPOSE_TIMEOUT_MS = 5000
|
|
117
|
+
const KV_KEY = "plugin_enabled"
|
|
118
|
+
const EMPTY_TUI: TuiPluginModule = {
|
|
119
|
+
tui: async () => {},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fail(message: string, data: Record<string, unknown>) {
|
|
123
|
+
if (!("error" in data)) {
|
|
124
|
+
log.error(message, data)
|
|
125
|
+
console.error(`[tui.plugin] ${message}`, data)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const text = `${message}: ${errorMessage(data.error)}`
|
|
130
|
+
const next = { ...data, error: errorData(data.error) }
|
|
131
|
+
log.error(text, next)
|
|
132
|
+
console.error(`[tui.plugin] ${text}`, next)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function warn(message: string, data: Record<string, unknown>) {
|
|
136
|
+
log.warn(message, data)
|
|
137
|
+
console.warn(`[tui.plugin] ${message}`, data)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope): TuiPluginApi["keymap"] {
|
|
141
|
+
const cache = new Map<PropertyKey, unknown>()
|
|
142
|
+
return new Proxy(keymap, {
|
|
143
|
+
get(target, prop) {
|
|
144
|
+
const value = Reflect.get(target, prop, target)
|
|
145
|
+
if (typeof value !== "function") return value
|
|
146
|
+
if (cache.has(prop)) return cache.get(prop)
|
|
147
|
+
const fn = ScopedKeymapMethods.has(prop)
|
|
148
|
+
? (...args: unknown[]) => {
|
|
149
|
+
const dispose = (value as (...args: unknown[]) => unknown).apply(target, args)
|
|
150
|
+
return scope.track(typeof dispose === "function" ? (dispose as () => void) : undefined)
|
|
151
|
+
}
|
|
152
|
+
: (...args: unknown[]) => (value as (...args: unknown[]) => unknown).apply(target, args)
|
|
153
|
+
cache.set(prop, fn)
|
|
154
|
+
return fn
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
|
160
|
+
|
|
161
|
+
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
resolve({ type: "timeout" })
|
|
165
|
+
}, ms)
|
|
166
|
+
|
|
167
|
+
Promise.resolve()
|
|
168
|
+
.then(fn)
|
|
169
|
+
.then(
|
|
170
|
+
() => {
|
|
171
|
+
resolve({ type: "ok" })
|
|
172
|
+
},
|
|
173
|
+
(error) => {
|
|
174
|
+
resolve({ type: "error", error })
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
.finally(() => {
|
|
178
|
+
clearTimeout(timer)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isTheme(value: unknown) {
|
|
184
|
+
if (!isRecord(value)) return false
|
|
185
|
+
if (!("theme" in value)) return false
|
|
186
|
+
if (!isRecord(value.theme)) return false
|
|
187
|
+
return true
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resolveRoot(root: string) {
|
|
191
|
+
if (root.startsWith("file://")) {
|
|
192
|
+
const file = fileURLToPath(root)
|
|
193
|
+
if (root.endsWith("/")) return file
|
|
194
|
+
return path.dirname(file)
|
|
195
|
+
}
|
|
196
|
+
if (path.isAbsolute(root)) return root
|
|
197
|
+
return path.resolve(process.cwd(), root)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function createThemeInstaller(
|
|
201
|
+
meta: ConfigPlugin.Origin,
|
|
202
|
+
root: string,
|
|
203
|
+
spec: string,
|
|
204
|
+
plugin: PluginEntry,
|
|
205
|
+
): TuiTheme["install"] {
|
|
206
|
+
return async (file) => {
|
|
207
|
+
const raw = file.startsWith("file://") ? fileURLToPath(file) : file
|
|
208
|
+
const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
|
|
209
|
+
const name = path.basename(src, path.extname(src))
|
|
210
|
+
const source_dir = path.dirname(meta.source)
|
|
211
|
+
const local_dir =
|
|
212
|
+
path.basename(source_dir) === ".opencode"
|
|
213
|
+
? path.join(source_dir, "themes")
|
|
214
|
+
: path.join(source_dir, ".opencode", "themes")
|
|
215
|
+
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
|
|
216
|
+
const dest = path.join(dest_dir, `${name}.json`)
|
|
217
|
+
const stat = await Filesystem.statAsync(src)
|
|
218
|
+
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
|
|
219
|
+
const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
|
|
220
|
+
const info = {
|
|
221
|
+
src,
|
|
222
|
+
dest,
|
|
223
|
+
mtime,
|
|
224
|
+
size,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await Flock.withLock(`tui-theme:${dest}`, async () => {
|
|
228
|
+
const save = async () => {
|
|
229
|
+
plugin.themes[name] = info
|
|
230
|
+
await PluginMeta.setTheme(plugin.id, name, info).catch((error) => {
|
|
231
|
+
log.warn("failed to track tui plugin theme", {
|
|
232
|
+
path: spec,
|
|
233
|
+
id: plugin.id,
|
|
234
|
+
theme: src,
|
|
235
|
+
dest,
|
|
236
|
+
error,
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const exists = hasTheme(name)
|
|
242
|
+
const prev = plugin.themes[name]
|
|
243
|
+
if (exists) {
|
|
244
|
+
if (plugin.meta.state !== "updated") {
|
|
245
|
+
if (!prev && (await Filesystem.exists(dest))) {
|
|
246
|
+
await save()
|
|
247
|
+
}
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
if (prev?.dest === dest && prev.mtime === mtime && prev.size === size) return
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const text = await Filesystem.readText(src).catch((error) => {
|
|
254
|
+
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
|
|
255
|
+
return
|
|
256
|
+
})
|
|
257
|
+
if (text === undefined) return
|
|
258
|
+
|
|
259
|
+
const fail = Symbol()
|
|
260
|
+
const data = await Promise.resolve(text)
|
|
261
|
+
.then((x) => JSON.parse(x))
|
|
262
|
+
.catch((error) => {
|
|
263
|
+
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
|
|
264
|
+
return fail
|
|
265
|
+
})
|
|
266
|
+
if (data === fail) return
|
|
267
|
+
|
|
268
|
+
if (!isTheme(data)) {
|
|
269
|
+
log.warn("invalid tui plugin theme", { path: spec, theme: src })
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (exists || !(await Filesystem.exists(dest))) {
|
|
274
|
+
await Filesystem.write(dest, text).catch((error) => {
|
|
275
|
+
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
upsertTheme(name, data)
|
|
280
|
+
await save()
|
|
281
|
+
}).catch((error) => {
|
|
282
|
+
log.warn("failed to lock tui plugin theme install", { path: spec, theme: src, dest, error })
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function createMeta(
|
|
288
|
+
source: PluginLoad["source"],
|
|
289
|
+
spec: string,
|
|
290
|
+
target: string,
|
|
291
|
+
meta: { state: PluginMeta.State; entry: PluginMeta.Entry } | undefined,
|
|
292
|
+
id?: string,
|
|
293
|
+
): TuiPluginMeta {
|
|
294
|
+
if (meta) {
|
|
295
|
+
return {
|
|
296
|
+
state: meta.state,
|
|
297
|
+
...meta.entry,
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const now = Date.now()
|
|
302
|
+
return {
|
|
303
|
+
state: source === "internal" ? "same" : "first",
|
|
304
|
+
id: id ?? spec,
|
|
305
|
+
source,
|
|
306
|
+
spec,
|
|
307
|
+
target,
|
|
308
|
+
first_time: now,
|
|
309
|
+
last_time: now,
|
|
310
|
+
time_changed: now,
|
|
311
|
+
load_count: 1,
|
|
312
|
+
fingerprint: target,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
|
|
317
|
+
const spec = item.id
|
|
318
|
+
const target = spec
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
options: undefined,
|
|
322
|
+
spec,
|
|
323
|
+
target,
|
|
324
|
+
retry: false,
|
|
325
|
+
source: "internal",
|
|
326
|
+
id: item.id,
|
|
327
|
+
module: item,
|
|
328
|
+
origin: {
|
|
329
|
+
spec,
|
|
330
|
+
scope: "global",
|
|
331
|
+
source: target,
|
|
332
|
+
},
|
|
333
|
+
theme_root: process.cwd(),
|
|
334
|
+
theme_files: [],
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function readThemeFiles(spec: string, pkg?: PluginPackage) {
|
|
339
|
+
if (!pkg) return [] as string[]
|
|
340
|
+
return Promise.resolve()
|
|
341
|
+
.then(() => readPackageThemes(spec, pkg))
|
|
342
|
+
.catch((error) => {
|
|
343
|
+
warn("invalid tui plugin oc-themes", {
|
|
344
|
+
path: spec,
|
|
345
|
+
pkg: pkg.pkg,
|
|
346
|
+
error,
|
|
347
|
+
})
|
|
348
|
+
return [] as string[]
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function syncPluginThemes(plugin: PluginEntry) {
|
|
353
|
+
if (!plugin.load.theme_files.length) return
|
|
354
|
+
if (plugin.meta.state === "same") return
|
|
355
|
+
const install = createThemeInstaller(plugin.load.origin, plugin.load.theme_root, plugin.load.spec, plugin)
|
|
356
|
+
for (const file of plugin.load.theme_files) {
|
|
357
|
+
await install(file).catch((error) => {
|
|
358
|
+
warn("failed to sync tui plugin oc-themes", { path: plugin.load.spec, id: plugin.id, theme: file, error })
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function createPluginScope(load: PluginLoad, id: string) {
|
|
364
|
+
const ctrl = new AbortController()
|
|
365
|
+
let list: { key: symbol; fn: TuiDispose }[] = []
|
|
366
|
+
let done = false
|
|
367
|
+
|
|
368
|
+
const onDispose = (fn: TuiDispose) => {
|
|
369
|
+
if (done) return () => {}
|
|
370
|
+
const key = Symbol()
|
|
371
|
+
list.push({ key, fn })
|
|
372
|
+
let drop = false
|
|
373
|
+
return () => {
|
|
374
|
+
if (drop) return
|
|
375
|
+
drop = true
|
|
376
|
+
list = list.filter((x) => x.key !== key)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const track = (fn: (() => void) | undefined) => {
|
|
381
|
+
if (!fn) return () => {}
|
|
382
|
+
let drop = false
|
|
383
|
+
let off = () => {}
|
|
384
|
+
const wrapped = () => {
|
|
385
|
+
if (drop) return
|
|
386
|
+
drop = true
|
|
387
|
+
off()
|
|
388
|
+
fn()
|
|
389
|
+
}
|
|
390
|
+
off = onDispose(wrapped)
|
|
391
|
+
return wrapped
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const lifecycle: TuiPluginApi["lifecycle"] = {
|
|
395
|
+
signal: ctrl.signal,
|
|
396
|
+
onDispose,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const dispose = async () => {
|
|
400
|
+
if (done) return
|
|
401
|
+
done = true
|
|
402
|
+
ctrl.abort()
|
|
403
|
+
const queue = [...list].reverse()
|
|
404
|
+
list = []
|
|
405
|
+
const until = Date.now() + DISPOSE_TIMEOUT_MS
|
|
406
|
+
for (const item of queue) {
|
|
407
|
+
const left = until - Date.now()
|
|
408
|
+
if (left <= 0) {
|
|
409
|
+
fail("timed out cleaning up tui plugin", {
|
|
410
|
+
path: load.spec,
|
|
411
|
+
id,
|
|
412
|
+
timeout: DISPOSE_TIMEOUT_MS,
|
|
413
|
+
})
|
|
414
|
+
break
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const out = await runCleanup(item.fn, left)
|
|
418
|
+
if (out.type === "ok") continue
|
|
419
|
+
if (out.type === "timeout") {
|
|
420
|
+
fail("timed out cleaning up tui plugin", {
|
|
421
|
+
path: load.spec,
|
|
422
|
+
id,
|
|
423
|
+
timeout: DISPOSE_TIMEOUT_MS,
|
|
424
|
+
})
|
|
425
|
+
break
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (out.type === "error") {
|
|
429
|
+
fail("failed to clean up tui plugin", {
|
|
430
|
+
path: load.spec,
|
|
431
|
+
id,
|
|
432
|
+
error: out.error,
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
lifecycle,
|
|
440
|
+
track,
|
|
441
|
+
dispose,
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function readPluginEnabledMap(value: unknown) {
|
|
446
|
+
if (!isRecord(value)) return {}
|
|
447
|
+
return Object.fromEntries(
|
|
448
|
+
Object.entries(value).filter((item): item is [string, boolean] => typeof item[1] === "boolean"),
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function pluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) {
|
|
453
|
+
return {
|
|
454
|
+
...readPluginEnabledMap(config.plugin_enabled),
|
|
455
|
+
...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})),
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function writePluginEnabledState(api: Api, id: string, enabled: boolean) {
|
|
460
|
+
api.kv.set(KV_KEY, {
|
|
461
|
+
...readPluginEnabledMap(api.kv.get(KV_KEY, {})),
|
|
462
|
+
[id]: enabled,
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function listPluginStatus(state: RuntimeState): TuiPluginStatus[] {
|
|
467
|
+
return state.plugins.map((plugin) => ({
|
|
468
|
+
id: plugin.id,
|
|
469
|
+
source: plugin.meta.source,
|
|
470
|
+
spec: plugin.meta.spec,
|
|
471
|
+
target: plugin.meta.target,
|
|
472
|
+
enabled: plugin.enabled,
|
|
473
|
+
active: plugin.scope !== undefined,
|
|
474
|
+
}))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function deactivatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
|
|
478
|
+
plugin.enabled = false
|
|
479
|
+
if (persist) writePluginEnabledState(state.api, plugin.id, false)
|
|
480
|
+
if (!plugin.scope) return true
|
|
481
|
+
const scope = plugin.scope
|
|
482
|
+
plugin.scope = undefined
|
|
483
|
+
await scope.dispose()
|
|
484
|
+
return true
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
|
|
488
|
+
plugin.enabled = true
|
|
489
|
+
if (persist) writePluginEnabledState(state.api, plugin.id, true)
|
|
490
|
+
if (plugin.scope) return true
|
|
491
|
+
|
|
492
|
+
const scope = createPluginScope(plugin.load, plugin.id)
|
|
493
|
+
const api = pluginApi(state, plugin, scope, plugin.id)
|
|
494
|
+
const ok = await Promise.resolve()
|
|
495
|
+
.then(async () => {
|
|
496
|
+
await syncPluginThemes(plugin)
|
|
497
|
+
await plugin.plugin(api, plugin.load.options, plugin.meta)
|
|
498
|
+
return true
|
|
499
|
+
})
|
|
500
|
+
.catch((error) => {
|
|
501
|
+
fail("failed to initialize tui plugin", {
|
|
502
|
+
path: plugin.load.spec,
|
|
503
|
+
id: plugin.id,
|
|
504
|
+
error,
|
|
505
|
+
})
|
|
506
|
+
return false
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
if (!ok) {
|
|
510
|
+
await scope.dispose()
|
|
511
|
+
return false
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!plugin.enabled) {
|
|
515
|
+
await scope.dispose()
|
|
516
|
+
return true
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
plugin.scope = scope
|
|
520
|
+
return true
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function activatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
|
|
524
|
+
if (!state) return false
|
|
525
|
+
const plugin = state.plugins_by_id.get(id)
|
|
526
|
+
if (!plugin) return false
|
|
527
|
+
return activatePluginEntry(state, plugin, persist)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function deactivatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
|
|
531
|
+
if (!state) return false
|
|
532
|
+
const plugin = state.plugins_by_id.get(id)
|
|
533
|
+
if (!plugin) return false
|
|
534
|
+
return deactivatePluginEntry(state, plugin, persist)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScope, base: string): TuiPluginApi {
|
|
538
|
+
const api = runtime.api
|
|
539
|
+
const host = runtime.slots
|
|
540
|
+
const load = plugin.load
|
|
541
|
+
|
|
542
|
+
const route: TuiPluginApi["route"] = {
|
|
543
|
+
register(list) {
|
|
544
|
+
return scope.track(api.route.register(list))
|
|
545
|
+
},
|
|
546
|
+
navigate(name, params) {
|
|
547
|
+
api.route.navigate(name, params)
|
|
548
|
+
},
|
|
549
|
+
get current() {
|
|
550
|
+
return api.route.current
|
|
551
|
+
},
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
|
|
555
|
+
install: createThemeInstaller(load.origin, load.theme_root, load.spec, plugin),
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
const event: TuiPluginApi["event"] = {
|
|
559
|
+
on(type, handler) {
|
|
560
|
+
return scope.track(api.event.on(type, handler))
|
|
561
|
+
},
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const keymap = createScopedKeymap(api.keymap, scope)
|
|
565
|
+
|
|
566
|
+
let count = 0
|
|
567
|
+
|
|
568
|
+
const slots: TuiPluginApi["slots"] = {
|
|
569
|
+
register(plugin: TuiSlotPlugin) {
|
|
570
|
+
const id = count ? `${base}:${count}` : base
|
|
571
|
+
count += 1
|
|
572
|
+
scope.track(host.register({ ...plugin, id }))
|
|
573
|
+
return id
|
|
574
|
+
},
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
app: api.app,
|
|
579
|
+
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
|
|
580
|
+
command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds),
|
|
581
|
+
keys: api.keys,
|
|
582
|
+
keymap,
|
|
583
|
+
route,
|
|
584
|
+
ui: api.ui,
|
|
585
|
+
tuiConfig: api.tuiConfig,
|
|
586
|
+
kv: api.kv,
|
|
587
|
+
state: api.state,
|
|
588
|
+
theme,
|
|
589
|
+
get client() {
|
|
590
|
+
return api.client
|
|
591
|
+
},
|
|
592
|
+
event,
|
|
593
|
+
renderer: api.renderer,
|
|
594
|
+
slots,
|
|
595
|
+
plugins: {
|
|
596
|
+
list() {
|
|
597
|
+
return listPluginStatus(runtime)
|
|
598
|
+
},
|
|
599
|
+
activate(id) {
|
|
600
|
+
return activatePluginById(runtime, id, true)
|
|
601
|
+
},
|
|
602
|
+
deactivate(id) {
|
|
603
|
+
return deactivatePluginById(runtime, id, true)
|
|
604
|
+
},
|
|
605
|
+
add(spec) {
|
|
606
|
+
return addPluginBySpec(runtime, spec)
|
|
607
|
+
},
|
|
608
|
+
install(spec, options) {
|
|
609
|
+
return installPluginBySpec(runtime, spec, options?.global)
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
lifecycle: scope.lifecycle,
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
|
|
617
|
+
if (state.plugins_by_id.has(plugin.id)) {
|
|
618
|
+
fail("duplicate tui plugin id", {
|
|
619
|
+
id: plugin.id,
|
|
620
|
+
path: plugin.load.spec,
|
|
621
|
+
})
|
|
622
|
+
return false
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
state.plugins_by_id.set(plugin.id, plugin)
|
|
626
|
+
state.plugins.push(plugin)
|
|
627
|
+
return true
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Resolved) {
|
|
631
|
+
const map = pluginEnabledState(state, config)
|
|
632
|
+
for (const plugin of state.plugins) {
|
|
633
|
+
const enabled = map[plugin.id]
|
|
634
|
+
if (enabled === undefined) continue
|
|
635
|
+
plugin.enabled = enabled
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => Promise<void>) {
|
|
640
|
+
return PluginLoader.loadExternal({
|
|
641
|
+
items: list,
|
|
642
|
+
kind: "tui",
|
|
643
|
+
wait: async () => {
|
|
644
|
+
await wait().catch((error) => {
|
|
645
|
+
log.warn("failed waiting for tui plugin dependencies", { error })
|
|
646
|
+
})
|
|
647
|
+
},
|
|
648
|
+
finish: async (loaded, origin, retry) => {
|
|
649
|
+
const mod = await Promise.resolve()
|
|
650
|
+
.then(() => readV1Plugin(loaded.mod as Record<string, unknown>, loaded.spec, "tui") as TuiPluginModule)
|
|
651
|
+
.catch((error) => {
|
|
652
|
+
fail("failed to load tui plugin", {
|
|
653
|
+
path: loaded.spec,
|
|
654
|
+
target: loaded.entry,
|
|
655
|
+
retry,
|
|
656
|
+
error,
|
|
657
|
+
})
|
|
658
|
+
return
|
|
659
|
+
})
|
|
660
|
+
if (!mod) return
|
|
661
|
+
|
|
662
|
+
const id = await resolvePluginId(
|
|
663
|
+
loaded.source,
|
|
664
|
+
loaded.spec,
|
|
665
|
+
loaded.target,
|
|
666
|
+
readPluginId(mod.id, loaded.spec),
|
|
667
|
+
loaded.pkg,
|
|
668
|
+
).catch((error) => {
|
|
669
|
+
fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error })
|
|
670
|
+
return
|
|
671
|
+
})
|
|
672
|
+
if (!id) return
|
|
673
|
+
|
|
674
|
+
const theme_files = await readThemeFiles(loaded.spec, loaded.pkg)
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
options: loaded.options,
|
|
678
|
+
spec: loaded.spec,
|
|
679
|
+
target: loaded.target,
|
|
680
|
+
retry,
|
|
681
|
+
source: loaded.source,
|
|
682
|
+
id,
|
|
683
|
+
module: mod,
|
|
684
|
+
origin,
|
|
685
|
+
theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
|
|
686
|
+
theme_files,
|
|
687
|
+
}
|
|
688
|
+
},
|
|
689
|
+
missing: async (loaded, origin, retry) => {
|
|
690
|
+
const theme_files = await readThemeFiles(loaded.spec, loaded.pkg)
|
|
691
|
+
if (!theme_files.length) return
|
|
692
|
+
|
|
693
|
+
const name =
|
|
694
|
+
typeof loaded.pkg?.json.name === "string" && loaded.pkg.json.name.trim().length > 0
|
|
695
|
+
? loaded.pkg.json.name.trim()
|
|
696
|
+
: undefined
|
|
697
|
+
const id = await resolvePluginId(loaded.source, loaded.spec, loaded.target, name, loaded.pkg).catch((error) => {
|
|
698
|
+
fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error })
|
|
699
|
+
return
|
|
700
|
+
})
|
|
701
|
+
if (!id) return
|
|
702
|
+
|
|
703
|
+
return {
|
|
704
|
+
options: loaded.options,
|
|
705
|
+
spec: loaded.spec,
|
|
706
|
+
target: loaded.target,
|
|
707
|
+
retry,
|
|
708
|
+
source: loaded.source,
|
|
709
|
+
id,
|
|
710
|
+
module: EMPTY_TUI,
|
|
711
|
+
origin,
|
|
712
|
+
theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
|
|
713
|
+
theme_files,
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
report: {
|
|
717
|
+
start(candidate, retry) {
|
|
718
|
+
log.info("loading tui plugin", { path: candidate.plan.spec, retry })
|
|
719
|
+
},
|
|
720
|
+
missing(candidate, retry, message) {
|
|
721
|
+
warn("tui plugin has no entrypoint", { path: candidate.plan.spec, retry, message })
|
|
722
|
+
},
|
|
723
|
+
error(candidate, retry, stage, error, resolved) {
|
|
724
|
+
const spec = candidate.plan.spec
|
|
725
|
+
if (stage === "install") {
|
|
726
|
+
fail("failed to resolve tui plugin", { path: spec, retry, error })
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
if (stage === "compatibility") {
|
|
730
|
+
fail("tui plugin incompatible", { path: spec, retry, error })
|
|
731
|
+
return
|
|
732
|
+
}
|
|
733
|
+
if (stage === "entry") {
|
|
734
|
+
fail("failed to resolve tui plugin entry", { path: spec, retry, error })
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
fail("failed to load tui plugin", { path: spec, target: resolved?.entry, retry, error })
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
|
|
744
|
+
if (!ready.length) return { plugins: [] as PluginEntry[], ok: true }
|
|
745
|
+
|
|
746
|
+
const meta = await PluginMeta.touchMany(
|
|
747
|
+
ready.map((item) => ({
|
|
748
|
+
spec: item.spec,
|
|
749
|
+
target: item.target,
|
|
750
|
+
id: item.id,
|
|
751
|
+
})),
|
|
752
|
+
).catch((error) => {
|
|
753
|
+
log.warn("failed to track tui plugins", { error })
|
|
754
|
+
return undefined
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
const plugins: PluginEntry[] = []
|
|
758
|
+
let ok = true
|
|
759
|
+
for (let i = 0; i < ready.length; i++) {
|
|
760
|
+
const entry = ready[i]
|
|
761
|
+
if (!entry) continue
|
|
762
|
+
const hit = meta?.[i]
|
|
763
|
+
if (hit && hit.state !== "same") {
|
|
764
|
+
log.info("tui plugin metadata updated", {
|
|
765
|
+
path: entry.spec,
|
|
766
|
+
retry: entry.retry,
|
|
767
|
+
state: hit.state,
|
|
768
|
+
source: hit.entry.source,
|
|
769
|
+
version: hit.entry.version,
|
|
770
|
+
modified: hit.entry.modified,
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const info = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
|
|
775
|
+
const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
|
|
776
|
+
const plugin: PluginEntry = {
|
|
777
|
+
id: entry.id,
|
|
778
|
+
load: entry,
|
|
779
|
+
meta: info,
|
|
780
|
+
themes,
|
|
781
|
+
plugin: entry.module.tui,
|
|
782
|
+
enabled: true,
|
|
783
|
+
}
|
|
784
|
+
if (!addPluginEntry(state, plugin)) {
|
|
785
|
+
ok = false
|
|
786
|
+
continue
|
|
787
|
+
}
|
|
788
|
+
plugins.push(plugin)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return { plugins, ok }
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function defaultPluginOrigin(state: RuntimeState, spec: string): ConfigPlugin.Origin {
|
|
795
|
+
return {
|
|
796
|
+
spec,
|
|
797
|
+
scope: "local",
|
|
798
|
+
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function installCause(err: unknown) {
|
|
803
|
+
if (!err || typeof err !== "object") return
|
|
804
|
+
if (!("cause" in err)) return
|
|
805
|
+
return (err as { cause?: unknown }).cause
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function installDetail(err: unknown) {
|
|
809
|
+
const hit = installCause(err) ?? err
|
|
810
|
+
if (!(hit instanceof Process.RunFailedError)) {
|
|
811
|
+
return {
|
|
812
|
+
message: errorMessage(hit),
|
|
813
|
+
missing: false,
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const lines = hit.stderr
|
|
818
|
+
.toString()
|
|
819
|
+
.split(/\r?\n/)
|
|
820
|
+
.map((line) => line.trim())
|
|
821
|
+
.filter(Boolean)
|
|
822
|
+
const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
|
|
823
|
+
return {
|
|
824
|
+
message: errs[0] ?? lines.at(-1) ?? errorMessage(hit),
|
|
825
|
+
missing: lines.some((line) => line.includes("No version matching")),
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
|
830
|
+
if (!state) return false
|
|
831
|
+
const spec = raw.trim()
|
|
832
|
+
if (!spec) return false
|
|
833
|
+
|
|
834
|
+
const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
|
|
835
|
+
const next = ConfigPlugin.pluginSpecifier(cfg.spec)
|
|
836
|
+
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
|
|
837
|
+
state.pending.delete(spec)
|
|
838
|
+
return true
|
|
839
|
+
}
|
|
840
|
+
const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()).catch((error) => {
|
|
841
|
+
fail("failed to add tui plugin", { path: next, error })
|
|
842
|
+
return [] as PluginLoad[]
|
|
843
|
+
})
|
|
844
|
+
if (!ready.length) {
|
|
845
|
+
return false
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const first = ready[0]
|
|
849
|
+
if (!first) {
|
|
850
|
+
fail("failed to add tui plugin", { path: next })
|
|
851
|
+
return false
|
|
852
|
+
}
|
|
853
|
+
if (state.plugins_by_id.has(first.id)) {
|
|
854
|
+
state.pending.delete(spec)
|
|
855
|
+
return true
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const out = await addExternalPluginEntries(state, [first])
|
|
859
|
+
let ok = out.ok && out.plugins.length > 0
|
|
860
|
+
for (const plugin of out.plugins) {
|
|
861
|
+
const active = await activatePluginEntry(state, plugin, false)
|
|
862
|
+
if (!active) ok = false
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (ok) state.pending.delete(spec)
|
|
866
|
+
if (!ok) {
|
|
867
|
+
fail("failed to add tui plugin", { path: next })
|
|
868
|
+
}
|
|
869
|
+
return ok
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
async function installPluginBySpec(
|
|
873
|
+
state: RuntimeState | undefined,
|
|
874
|
+
raw: string,
|
|
875
|
+
global = false,
|
|
876
|
+
): Promise<TuiPluginInstallResult> {
|
|
877
|
+
if (!state) {
|
|
878
|
+
return {
|
|
879
|
+
ok: false,
|
|
880
|
+
message: "Plugin runtime is not ready.",
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const spec = raw.trim()
|
|
885
|
+
if (!spec) {
|
|
886
|
+
return {
|
|
887
|
+
ok: false,
|
|
888
|
+
message: "Plugin package name is required",
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const dir = state.api.state.path
|
|
893
|
+
if (!dir.directory) {
|
|
894
|
+
return {
|
|
895
|
+
ok: false,
|
|
896
|
+
message: "Paths are still syncing. Try again in a moment.",
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const install = await installModulePlugin(spec)
|
|
901
|
+
if (!install.ok) {
|
|
902
|
+
const out = installDetail(install.error)
|
|
903
|
+
return {
|
|
904
|
+
ok: false,
|
|
905
|
+
message: out.message,
|
|
906
|
+
missing: out.missing,
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const manifest = await readPluginManifest(install.target)
|
|
911
|
+
if (!manifest.ok) {
|
|
912
|
+
if (manifest.code === "manifest_no_targets") {
|
|
913
|
+
return {
|
|
914
|
+
ok: false,
|
|
915
|
+
message: `"${spec}" does not expose plugin entrypoints or oc-themes in package.json`,
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return {
|
|
920
|
+
ok: false,
|
|
921
|
+
message: `Installed "${spec}" but failed to read ${manifest.file}`,
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const patch = await patchPluginConfig({
|
|
926
|
+
spec,
|
|
927
|
+
targets: manifest.targets,
|
|
928
|
+
global,
|
|
929
|
+
vcs: dir.worktree && dir.worktree !== "/" ? "git" : undefined,
|
|
930
|
+
worktree: dir.worktree,
|
|
931
|
+
directory: dir.directory,
|
|
932
|
+
})
|
|
933
|
+
if (!patch.ok) {
|
|
934
|
+
if (patch.code === "invalid_json") {
|
|
935
|
+
return {
|
|
936
|
+
ok: false,
|
|
937
|
+
message: `Invalid JSON in ${patch.file} (${patch.parse} at line ${patch.line}, column ${patch.col})`,
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
return {
|
|
942
|
+
ok: false,
|
|
943
|
+
message: errorMessage(patch.error),
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const tui = manifest.targets.find((item) => item.kind === "tui")
|
|
948
|
+
if (tui) {
|
|
949
|
+
const file = patch.items.find((item) => item.kind === "tui")?.file
|
|
950
|
+
const next = tui.opts ? ([spec, tui.opts] as ConfigPlugin.Spec) : spec
|
|
951
|
+
state.pending.set(spec, {
|
|
952
|
+
spec: next,
|
|
953
|
+
scope: global ? "global" : "local",
|
|
954
|
+
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
|
|
955
|
+
})
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return {
|
|
959
|
+
ok: true,
|
|
960
|
+
dir: patch.dir,
|
|
961
|
+
tui: Boolean(tui),
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
let dir = ""
|
|
966
|
+
let loaded: Promise<void> | undefined
|
|
967
|
+
let runtime: RuntimeState | undefined
|
|
968
|
+
export const Slot = View
|
|
969
|
+
|
|
970
|
+
export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) {
|
|
971
|
+
const cwd = process.cwd()
|
|
972
|
+
if (loaded) {
|
|
973
|
+
if (dir !== cwd) {
|
|
974
|
+
throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
|
|
975
|
+
}
|
|
976
|
+
return loaded
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
dir = cwd
|
|
980
|
+
loaded = load(input)
|
|
981
|
+
return loaded
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
export function list() {
|
|
985
|
+
if (!runtime) return []
|
|
986
|
+
return listPluginStatus(runtime)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
export async function activatePlugin(id: string) {
|
|
990
|
+
return activatePluginById(runtime, id, true)
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
export async function deactivatePlugin(id: string) {
|
|
994
|
+
return deactivatePluginById(runtime, id, true)
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
export async function addPlugin(spec: string) {
|
|
998
|
+
return addPluginBySpec(runtime, spec)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
export async function installPlugin(spec: string, options?: { global?: boolean }) {
|
|
1002
|
+
return installPluginBySpec(runtime, spec, options?.global)
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
export async function dispose() {
|
|
1006
|
+
const task = loaded
|
|
1007
|
+
loaded = undefined
|
|
1008
|
+
dir = ""
|
|
1009
|
+
if (task) await task
|
|
1010
|
+
const state = runtime
|
|
1011
|
+
runtime = undefined
|
|
1012
|
+
if (!state) return
|
|
1013
|
+
const queue = [...state.plugins].reverse()
|
|
1014
|
+
for (const plugin of queue) {
|
|
1015
|
+
await deactivatePluginEntry(state, plugin, false)
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async function load(input: { api: Api; config: TuiConfig.Resolved }) {
|
|
1020
|
+
const { api, config } = input
|
|
1021
|
+
const cwd = process.cwd()
|
|
1022
|
+
const slots = setupSlots(api)
|
|
1023
|
+
const next: RuntimeState = {
|
|
1024
|
+
directory: cwd,
|
|
1025
|
+
api,
|
|
1026
|
+
slots,
|
|
1027
|
+
plugins: [],
|
|
1028
|
+
plugins_by_id: new Map(),
|
|
1029
|
+
pending: new Map(),
|
|
1030
|
+
}
|
|
1031
|
+
runtime = next
|
|
1032
|
+
try {
|
|
1033
|
+
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
|
|
1034
|
+
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
|
|
1035
|
+
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
for (const item of INTERNAL_TUI_PLUGINS) {
|
|
1039
|
+
log.info("loading internal tui plugin", { id: item.id })
|
|
1040
|
+
const entry = loadInternalPlugin(item)
|
|
1041
|
+
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
|
1042
|
+
addPluginEntry(next, {
|
|
1043
|
+
id: entry.id,
|
|
1044
|
+
load: entry,
|
|
1045
|
+
meta,
|
|
1046
|
+
themes: {},
|
|
1047
|
+
plugin: entry.module.tui,
|
|
1048
|
+
enabled: item.enabled ?? true,
|
|
1049
|
+
})
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
|
|
1053
|
+
await addExternalPluginEntries(next, ready)
|
|
1054
|
+
|
|
1055
|
+
applyInitialPluginEnabledState(next, config)
|
|
1056
|
+
for (const plugin of next.plugins) {
|
|
1057
|
+
if (!plugin.enabled) continue
|
|
1058
|
+
// Keep plugin execution sequential for deterministic side effects:
|
|
1059
|
+
// command registration order affects keybind/command precedence,
|
|
1060
|
+
// route registration is last-wins when ids collide,
|
|
1061
|
+
// and hook chains rely on stable plugin ordering.
|
|
1062
|
+
await activatePluginEntry(next, plugin, false)
|
|
1063
|
+
}
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
fail("failed to load tui plugins", { directory: cwd, error })
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
export * as TuiPluginRuntime from "./runtime"
|