@brimveyn/aimux-plugin 0.1.2 → 0.1.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux-plugin",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Plugin authoring API for aimux — context, effects, events and RPC for in-process plugins.",
5
5
  "keywords": [
6
6
  "aimux",
@@ -28,7 +28,8 @@
28
28
  ],
29
29
  "type": "module",
30
30
  "exports": {
31
- ".": "./src/index.ts"
31
+ ".": "./src/index.ts",
32
+ "./testing": "./src/testing.ts"
32
33
  },
33
34
  "publishConfig": {
34
35
  "access": "public"
@@ -37,6 +38,16 @@
37
38
  "@types/react": "^19.2.14"
38
39
  },
39
40
  "peerDependencies": {
41
+ "@opentui/core": ">=0.1.90",
42
+ "@opentui/react": ">=0.1.90",
40
43
  "react": ">=19"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "@opentui/core": {
47
+ "optional": true
48
+ },
49
+ "@opentui/react": {
50
+ "optional": true
51
+ }
41
52
  }
42
53
  }
package/src/daemon-api.ts CHANGED
@@ -22,6 +22,16 @@ export interface PluginTabView {
22
22
  command: string
23
23
  workspaceId?: string
24
24
  workerName?: string
25
+ /**
26
+ * True while the tab still carries the title it was born with: created
27
+ * without one, on an assistant that supports being named, and renamed by
28
+ * nobody since — not the user, not aimux, not a plugin.
29
+ *
30
+ * A plugin that names tabs reads this before naming one, which is how it
31
+ * avoids naming the same tab twice or writing over a title the user chose.
32
+ * `rename` clears it, whoever calls it.
33
+ */
34
+ unnamed: boolean
25
35
  }
26
36
 
27
37
  export interface PluginSpawnTabInput {
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  type PluginKeymapContribution,
33
33
  type PluginManifest,
34
34
  } from './manifest'
35
+ export { createTestUiSurface, type TestUiRegistrations, type TestUiSurface } from './test-ui'
35
36
  export {
36
37
  createTestContext,
37
38
  type TestContextHandle,
@@ -42,13 +43,19 @@ export {
42
43
  export type {
43
44
  PluginActionsApi,
44
45
  PluginBarWidget,
46
+ PluginCommitMessage,
47
+ PluginCommitMessageRequest,
45
48
  PluginComponent,
49
+ PluginGitApi,
50
+ PluginGitFile,
51
+ PluginGitStatus,
46
52
  PluginKit,
47
53
  PluginModal,
48
54
  PluginModalsApi,
49
55
  PluginNode,
50
56
  PluginPane,
51
57
  PluginPanesApi,
58
+ PluginScreen,
52
59
  PluginSettingsApi,
53
60
  PluginSettingValue,
54
61
  PluginStateApi,
@@ -67,6 +74,7 @@ export type {
67
74
  PluginView,
68
75
  PluginViewsApi,
69
76
  PluginWidgetsApi,
77
+ PluginWidgetSize,
70
78
  } from './ui'
71
79
  export type {
72
80
  DaemonPluginContext,
@@ -9,6 +9,7 @@ import type {
9
9
  import { EffectStack } from './effects'
10
10
  import { PluginEventBus } from './event-bus'
11
11
  import { PLUGIN_API_VERSION, type PluginHost, type PluginManifest } from './manifest'
12
+ import { createTestUiSurface, type TestUiSurface } from './test-ui'
12
13
 
13
14
  export interface TestLogEntry {
14
15
  level: 'debug' | 'info' | 'warn' | 'error'
@@ -73,6 +74,13 @@ export interface TestContextHandle {
73
74
  effectCount: () => number
74
75
  /** Unwind, exactly as an unload would. Resolves with any disposer errors. */
75
76
  dispose: () => Promise<unknown[]>
77
+ /**
78
+ * The recording UI half, when `host` is `'ui'` and no `extend` replaced it:
79
+ * what the plugin registered, the toasts it raised, and the levers that drive
80
+ * `ctx.ui.state`, `ctx.ui.settings` and `ctx.ui.themes`. Absent for a daemon
81
+ * plugin, which has no `ctx.ui` to record.
82
+ */
83
+ ui?: TestUiSurface
76
84
  }
77
85
 
78
86
  /**
@@ -191,9 +199,28 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
191
199
  waterfall: async <T>(event: string, value: T) => bus.waterfall(event, value),
192
200
  }
193
201
 
202
+ /**
203
+ * A UI plugin's first line is `ctx.ui.something.register(...)`, so a `ui`
204
+ * context without `ctx.ui` cannot run one at all. `extend` still wins: the
205
+ * real hosts and `plugin doctor` pass their own services, and this stub is
206
+ * what an author outside aimux gets instead.
207
+ */
208
+ const surface = host === 'ui' ? createTestUiSurface(effects) : undefined
209
+ if (surface) {
210
+ const extended = ctx as PluginContext & {
211
+ ui: unknown
212
+ actions: unknown
213
+ store: unknown
214
+ }
215
+ extended.ui = surface.ui
216
+ extended.actions = surface.actions
217
+ extended.store = surface.store
218
+ }
219
+
194
220
  options.extend?.(ctx)
195
221
 
196
222
  return {
223
+ ...(surface === undefined ? {} : { ui: surface }),
197
224
  apply: async (definition) => {
198
225
  await (definition as PluginDefinition).apply(ctx)
199
226
  },
package/src/test-ui.ts ADDED
@@ -0,0 +1,284 @@
1
+ import type { EffectStack } from './effects'
2
+ import type { Disposer } from './types'
3
+ import type {
4
+ PluginActionsApi,
5
+ PluginCommitMessage,
6
+ PluginCommitMessageRequest,
7
+ PluginGitStatus,
8
+ PluginSettingValue,
9
+ PluginStoreApi,
10
+ PluginThemeSnapshot,
11
+ PluginUiApi,
12
+ PluginUiState,
13
+ } from './ui'
14
+
15
+ /**
16
+ * The UI half of a plugin, with no aimux drawing anything.
17
+ *
18
+ * `createTestContext` gives a daemon plugin everything it needs — the real bus,
19
+ * the real effect stack, recorded RPC — but a UI plugin's first line is
20
+ * `ctx.ui.widgets.register(...)`, and `ctx.ui` simply was not there. So the
21
+ * test the scaffold generates, against the half the scaffold generates, threw
22
+ * on the first statement: the author's first run of `bun test` was a red they
23
+ * had not written. Inside this repo it was invisible, because aimux's own tests
24
+ * pass `extend` with the real services.
25
+ *
26
+ * Everything here records rather than draws, and every registration goes on the
27
+ * same effect stack the rest of the context uses — so `effectCount()` counts
28
+ * them and `dispose()` unwinds them, which is the property a plugin test is
29
+ * really there to check.
30
+ */
31
+
32
+ /** What the plugin registered, by surface. Mirrors what `plugin doctor` reports. */
33
+ export interface TestUiRegistrations {
34
+ widgets: string[]
35
+ views: string[]
36
+ modals: string[]
37
+ panes: string[]
38
+ statusBar: string[]
39
+ statsPages: string[]
40
+ themes: string[]
41
+ settingsSections: number
42
+ /** Whether the plugin claimed the commit-message slot. */
43
+ commitMessageProvider: boolean
44
+ actions: string[]
45
+ effects: string[]
46
+ }
47
+
48
+ export interface TestUiSurface {
49
+ ui: PluginUiApi
50
+ actions: PluginActionsApi
51
+ store: PluginStoreApi
52
+ registrations: TestUiRegistrations
53
+ /** Everything `ctx.ui.toast` was asked to show, newest last. */
54
+ toasts: { level: 'info' | 'success' | 'error'; message: string }[]
55
+ /** Panes and views the plugin asked to open or close, in order. */
56
+ opened: string[]
57
+ /** Drives `ctx.ui.state`: set it, and subscribers hear about it. */
58
+ setState: (next: Partial<PluginUiState>) => void
59
+ /** Drives `ctx.ui.settings.watch` and what `get` answers. */
60
+ setSetting: (id: string, value: PluginSettingValue) => void
61
+ /** Drives `ctx.ui.themes.onChange` and what `current()` answers. */
62
+ setTheme: (snapshot: PluginThemeSnapshot) => void
63
+ /** Drives `ctx.ui.git.status`. */
64
+ setGitStatus: (status: PluginGitStatus) => void
65
+ /**
66
+ * Asks the provider the plugin registered, as the commit flow would. Throws
67
+ * when it registered none, because a test that silently asserts nothing is
68
+ * the failure this whole harness exists to avoid.
69
+ */
70
+ askForCommitMessage: (
71
+ request?: Partial<PluginCommitMessageRequest>
72
+ ) => Promise<PluginCommitMessage | null>
73
+ }
74
+
75
+ const EMPTY_STATE: PluginUiState = {
76
+ activeTab: null,
77
+ activeTabId: null,
78
+ projectId: null,
79
+ tabs: [],
80
+ }
81
+
82
+ const DEFAULT_THEME: PluginThemeSnapshot = { colors: {}, mode: 'dark' }
83
+
84
+ const EMPTY_GIT: PluginGitStatus = { ahead: 0, behind: 0, branch: null, files: [] }
85
+
86
+ const EMPTY_REQUEST: PluginCommitMessageRequest = {
87
+ assistant: 'claude',
88
+ branch: 'main',
89
+ diff: '',
90
+ files: [],
91
+ projectId: 'p1',
92
+ recentCommits: '',
93
+ repoRoot: '/tmp/repo',
94
+ }
95
+
96
+ /** A component that renders nothing: a test asserts on registrations, not pixels. */
97
+ const nothing = (): null => null
98
+
99
+ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
100
+ const registrations: TestUiRegistrations = {
101
+ actions: [],
102
+ commitMessageProvider: false,
103
+ effects: [],
104
+ modals: [],
105
+ panes: [],
106
+ settingsSections: 0,
107
+ statsPages: [],
108
+ statusBar: [],
109
+ themes: [],
110
+ views: [],
111
+ widgets: [],
112
+ }
113
+ const toasts: TestUiSurface['toasts'] = []
114
+ const opened: string[] = []
115
+
116
+ let state: PluginUiState = EMPTY_STATE
117
+ const stateListeners = new Set<(next: PluginUiState) => void>()
118
+ const settings = new Map<string, PluginSettingValue>()
119
+ const settingListeners = new Map<string, Set<(value: PluginSettingValue) => void>>()
120
+ let theme: PluginThemeSnapshot = DEFAULT_THEME
121
+ const themeListeners = new Set<(snapshot: PluginThemeSnapshot) => void>()
122
+ let slice: unknown
123
+ let git: PluginGitStatus = EMPTY_GIT
124
+ let commitProvider:
125
+ | ((
126
+ request: PluginCommitMessageRequest,
127
+ signal: AbortSignal
128
+ ) => Promise<PluginCommitMessage | null> | PluginCommitMessage | null)
129
+ | null = null
130
+
131
+ /** Records a registration and hands back a disposer that unrecords it. */
132
+ function record(into: string[], id: string): Disposer {
133
+ into.push(id)
134
+ const dispose = (): void => {
135
+ const at = into.indexOf(id)
136
+ if (at !== -1) into.splice(at, 1)
137
+ }
138
+ effects.add(dispose)
139
+ return dispose
140
+ }
141
+
142
+ function own<T>(set: Set<T>, listener: T): Disposer {
143
+ set.add(listener)
144
+ const dispose = (): void => {
145
+ set.delete(listener)
146
+ }
147
+ effects.add(dispose)
148
+ return dispose
149
+ }
150
+
151
+ const ui: PluginUiApi = {
152
+ git: {
153
+ provideCommitMessage: (provider) => {
154
+ commitProvider = provider
155
+ registrations.commitMessageProvider = true
156
+ const dispose = (): void => {
157
+ if (commitProvider === provider) commitProvider = null
158
+ registrations.commitMessageProvider = false
159
+ }
160
+ effects.add(dispose)
161
+ return dispose
162
+ },
163
+ status: () => git,
164
+ },
165
+ kit: {
166
+ KeyHint: nothing,
167
+ List: nothing,
168
+ Panel: nothing,
169
+ Row: nothing,
170
+ useTheme: () => theme.colors,
171
+ },
172
+ modals: {
173
+ close: () => opened.push('modal:close'),
174
+ open: (id) => opened.push(`modal:${id}`),
175
+ register: (modal) => record(registrations.modals, modal.id),
176
+ },
177
+ navigate: (screen) => opened.push(`screen:${screen}`),
178
+ panes: {
179
+ close: (id) => opened.push(`pane:close:${id}`),
180
+ open: (id) => opened.push(`pane:${id}`),
181
+ register: (pane) => record(registrations.panes, pane.id),
182
+ },
183
+ settings: {
184
+ get: (id) => settings.get(id),
185
+ registerSection: () => {
186
+ registrations.settingsSections += 1
187
+ const dispose = (): void => {
188
+ registrations.settingsSections -= 1
189
+ }
190
+ effects.add(dispose)
191
+ return dispose
192
+ },
193
+ watch: (id, listener) => {
194
+ const set = settingListeners.get(id) ?? new Set()
195
+ settingListeners.set(id, set)
196
+ return own(set, listener)
197
+ },
198
+ },
199
+ state: {
200
+ get: () => state,
201
+ subscribe: (listener) => {
202
+ // Fires once immediately, exactly as the real one does.
203
+ listener(state)
204
+ return own(stateListeners, listener)
205
+ },
206
+ use: (select) => select(state),
207
+ },
208
+ stats: {
209
+ registerPage: (page) => record(registrations.statsPages, page.id),
210
+ },
211
+ statusBar: {
212
+ register: (segment) => record(registrations.statusBar, segment.id),
213
+ },
214
+ themes: {
215
+ current: () => theme,
216
+ onChange: (listener) => own(themeListeners, listener),
217
+ register: (id) => record(registrations.themes, id),
218
+ },
219
+ toast: {
220
+ error: (message) => toasts.push({ level: 'error', message }),
221
+ info: (message) => toasts.push({ level: 'info', message }),
222
+ success: (message) => toasts.push({ level: 'success', message }),
223
+ },
224
+ views: {
225
+ close: () => opened.push('view:close'),
226
+ open: (id) => opened.push(`view:${id}`),
227
+ register: (view) => record(registrations.views, view.id),
228
+ },
229
+ widgets: {
230
+ register: (widget) => record(registrations.widgets, widget.id),
231
+ },
232
+ }
233
+
234
+ const actions: PluginActionsApi = {
235
+ effect: (effectId) => record(registrations.effects, effectId),
236
+ register: (verb) => record(registrations.actions, verb),
237
+ }
238
+
239
+ const store: PluginStoreApi = {
240
+ dispatch: () => {
241
+ /* a slice reducer is the plugin's; a bare context has none to run */
242
+ },
243
+ get: () => slice,
244
+ reducer: () => {
245
+ const dispose = (): void => {
246
+ /* nothing was installed, so nothing comes off */
247
+ }
248
+ effects.add(dispose)
249
+ return dispose
250
+ },
251
+ set: (next) => {
252
+ slice = next
253
+ },
254
+ use: () => slice,
255
+ }
256
+
257
+ return {
258
+ actions,
259
+ askForCommitMessage: async (request) => {
260
+ if (commitProvider === null) throw new Error('the plugin registered no commit provider')
261
+ return commitProvider({ ...EMPTY_REQUEST, ...request }, new AbortController().signal)
262
+ },
263
+ opened,
264
+ registrations,
265
+ setGitStatus: (status) => {
266
+ git = status
267
+ },
268
+ setSetting: (id, value) => {
269
+ settings.set(id, value)
270
+ for (const listener of settingListeners.get(id) ?? []) listener(value)
271
+ },
272
+ setState: (next) => {
273
+ state = { ...state, ...next }
274
+ for (const listener of stateListeners) listener(state)
275
+ },
276
+ setTheme: (snapshot) => {
277
+ theme = snapshot
278
+ for (const listener of themeListeners) listener(snapshot)
279
+ },
280
+ store,
281
+ toasts,
282
+ ui,
283
+ }
284
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { createTestRenderer } from '@opentui/core/testing'
2
+ import { createRoot } from '@opentui/react'
3
+
4
+ import type { PluginNode } from './ui'
5
+
6
+ /**
7
+ * Does it draw?
8
+ *
9
+ * `createTestContext` answers what a plugin *registers* — the widget exists,
10
+ * the action is bound, an unload leaves nothing behind. It says nothing about
11
+ * what any of it looks like, and a widget whose renderer throws on an empty
12
+ * data set registers exactly as cleanly as one that works.
13
+ *
14
+ * aimux has a test renderer; a plugin author outside this repo did not. This is
15
+ * that renderer, with the two lines of setup already done, in a separate entry
16
+ * point so the extra dependencies stay out of a plugin's runtime:
17
+ *
18
+ * ```ts
19
+ * import { renderPluginNode } from '@brimveyn/aimux-plugin/testing'
20
+ *
21
+ * const { frame } = await renderPluginNode(<MyWidget cols={30} rows={8} />)
22
+ * expect(frame).toContain('CPU')
23
+ * ```
24
+ *
25
+ * `@opentui/core` and `@opentui/react` are peers rather than dependencies: the
26
+ * host already ships them, and a second copy in a plugin's tree is the module
27
+ * duplication the plugin loader spends real effort avoiding at runtime.
28
+ */
29
+
30
+ export interface RenderPluginOptions {
31
+ /** Terminal size to render into. Defaults to a bar-sized 40×12. */
32
+ cols?: number
33
+ rows?: number
34
+ /**
35
+ * How long to keep rendering while `until` is false. A widget that fetches on
36
+ * mount needs a few frames before it has anything to draw.
37
+ */
38
+ timeoutMs?: number
39
+ /** Stop as soon as this is true of the current frame. Default: first frame. */
40
+ until?: (frame: string) => boolean
41
+ }
42
+
43
+ export interface RenderedPlugin {
44
+ /** The drawn frame, as text. */
45
+ frame: string
46
+ /** Renders again and returns the new frame — for asserting on a change. */
47
+ next: () => Promise<string>
48
+ /** Tears the renderer down. Call it, or the process keeps a root mounted. */
49
+ dispose: () => void
50
+ }
51
+
52
+ const DEFAULT_COLS = 40
53
+ const DEFAULT_ROWS = 12
54
+ const DEFAULT_TIMEOUT_MS = 2_000
55
+ /** One macrotask, which is what React needs to commit the tree. */
56
+ const COMMIT_TICK_MS = 10
57
+
58
+ export async function renderPluginNode(
59
+ node: PluginNode,
60
+ options: RenderPluginOptions = {}
61
+ ): Promise<RenderedPlugin> {
62
+ const cols = options.cols ?? DEFAULT_COLS
63
+ const rows = options.rows ?? DEFAULT_ROWS
64
+ const { captureCharFrame, renderer, renderOnce } = await createTestRenderer({
65
+ height: rows,
66
+ width: cols,
67
+ })
68
+ const root = createRoot(renderer)
69
+ root.render(node)
70
+
71
+ const draw = async (): Promise<string> => {
72
+ await renderOnce()
73
+ return captureCharFrame()
74
+ }
75
+
76
+ // React commits on a macrotask, not on the render tick: capturing straight
77
+ // after `root.render` gives a blank frame every time, which would make the
78
+ // simplest possible assertion fail for a reason that has nothing to do with
79
+ // the widget under test.
80
+ await draw()
81
+ await new Promise<void>((resolve) => setTimeout(resolve, COMMIT_TICK_MS))
82
+ let frame = await draw()
83
+ const until = options.until
84
+ if (until !== undefined) {
85
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
86
+ while (!until(frame) && Date.now() < deadline) {
87
+ await new Promise<void>((resolve) => setTimeout(resolve, 10))
88
+ frame = await draw()
89
+ }
90
+ }
91
+
92
+ return {
93
+ dispose: () => {
94
+ root.unmount()
95
+ },
96
+ frame,
97
+ next: draw,
98
+ }
99
+ }
package/src/ui.ts CHANGED
@@ -36,11 +36,23 @@ export interface PluginToastApi {
36
36
  error: (message: string) => void
37
37
  }
38
38
 
39
+ /** The room a bar widget has been given, in cells. */
40
+ export interface PluginWidgetSize {
41
+ cols: number
42
+ rows: number
43
+ }
44
+
39
45
  export interface PluginBarWidget {
40
46
  /** Unqualified; the host prefixes the plugin id. */
41
47
  id: string
42
48
  label: string
43
- render: (contentWidth: number) => PluginNode
49
+ /**
50
+ * `size` is the second argument rather than a replacement for the first: the
51
+ * width already shipped as a number under `apiVersion: 1`, and swapping it
52
+ * would break every published plugin to save one parameter. `size.cols` is
53
+ * the same value.
54
+ */
55
+ render: (contentWidth: number, size: PluginWidgetSize) => PluginNode
44
56
  }
45
57
 
46
58
  export interface PluginView {
@@ -269,7 +281,84 @@ export interface PluginKit {
269
281
  KeyHint: PluginComponent<{ hints: readonly { keys: string; label: string }[] }>
270
282
  }
271
283
 
284
+ /** One changed file, as the git panel sees it. */
285
+ export interface PluginGitFile {
286
+ path: string
287
+ /** Porcelain-ish status: `modified`, `new`, `deleted`, `renamed`, … */
288
+ status: string
289
+ /** Which half of the panel it sits in — staged or not. */
290
+ section: string
291
+ added: number | null
292
+ removed: number | null
293
+ }
294
+
295
+ /**
296
+ * The working tree as the panel last saw it. A snapshot of aimux's poll, not a
297
+ * fresh `git status`: it is what the user is looking at, which is the point,
298
+ * and it is empty until a project with a path is open.
299
+ */
300
+ export interface PluginGitStatus {
301
+ branch: string | null
302
+ ahead: number
303
+ behind: number
304
+ files: PluginGitFile[]
305
+ }
306
+
307
+ /** Everything aimux gathered before asking for a commit message. */
308
+ export interface PluginCommitMessageRequest {
309
+ projectId: string
310
+ repoRoot: string
311
+ branch: string
312
+ /**
313
+ * The assistant in the tab the commit is being written for — `claude`,
314
+ * `codex`, … A provider that calls a model headlessly needs to know which
315
+ * one the user is already working with.
316
+ */
317
+ assistant: string
318
+ /** Staged diff when anything is staged, the working-tree diff otherwise. */
319
+ diff: string
320
+ /** `git log --oneline`, for house style rather than for content. */
321
+ recentCommits: string
322
+ files: PluginGitFile[]
323
+ /** The tail of what the agent in the tab was doing, when there is one. */
324
+ sessionTail?: string
325
+ }
326
+
327
+ export interface PluginCommitMessage {
328
+ title: string
329
+ body?: string
330
+ }
331
+
332
+ export interface PluginGitApi {
333
+ /** The panel's last refresh. */
334
+ status: () => PluginGitStatus
335
+ /**
336
+ * Answers "what should this commit say", replacing the headless model call
337
+ * aimux would otherwise make. Return `null` to decline this one — aimux falls
338
+ * back to its own suggestion rather than leaving the user with nothing.
339
+ *
340
+ * One plugin at a time: the second to ask is refused, and told so in its log,
341
+ * because a message that depends on load order is worse than no message.
342
+ */
343
+ provideCommitMessage: (
344
+ provider: (
345
+ request: PluginCommitMessageRequest,
346
+ signal: AbortSignal
347
+ ) => Promise<PluginCommitMessage | null> | PluginCommitMessage | null
348
+ ) => Disposer
349
+ }
350
+
351
+ /** The screens a plugin may send the user to. */
352
+ export type PluginScreen = 'git' | 'stats' | 'settings' | 'terminal'
353
+
272
354
  export interface PluginUiApi {
355
+ /**
356
+ * Opens one of aimux's own screens, or `terminal` to leave the one you are
357
+ * on. Deliberately four names and not an id space: exposing modal or view ids
358
+ * would make them API, and they are not.
359
+ */
360
+ navigate: (screen: PluginScreen) => void
361
+ git: PluginGitApi
273
362
  widgets: PluginWidgetsApi
274
363
  views: PluginViewsApi
275
364
  modals: PluginModalsApi