@brimveyn/aimux-plugin 0.1.2
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/LICENSE +21 -0
- package/README.md +101 -0
- package/package.json +42 -0
- package/src/daemon-api.ts +156 -0
- package/src/define-plugin.ts +21 -0
- package/src/effects.ts +67 -0
- package/src/event-bus.ts +137 -0
- package/src/index.ts +82 -0
- package/src/manifest.ts +109 -0
- package/src/test-context.ts +215 -0
- package/src/types.ts +151 -0
- package/src/ui.ts +326 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { Disposer } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The UI half's services — what a plugin reaches through `ctx.ui`, `ctx.actions`
|
|
7
|
+
* and `ctx.store`.
|
|
8
|
+
*
|
|
9
|
+
* Declared here, implemented by `src/ui/plugin-host.tsx`. The split matters:
|
|
10
|
+
* this package must stay free of React and of aimux's internals, so a plugin
|
|
11
|
+
* can be typechecked with nothing but `bun install`.
|
|
12
|
+
*
|
|
13
|
+
* Ids are namespaced by the host. A plugin registers `board` and the widget
|
|
14
|
+
* becomes `acme.thing.board`, so two plugins can each have a "board" and the
|
|
15
|
+
* owner of any id is readable from the id alone.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What a plugin renders. `react` is an optional peer of this package, typed
|
|
20
|
+
* only — no runtime dependency, and any plugin drawing UI already needs
|
|
21
|
+
* `@types/react` to write JSX at all.
|
|
22
|
+
*
|
|
23
|
+
* The node must come from *aimux's* React at runtime. The module loader forces
|
|
24
|
+
* that resolution, so a plugin that simply imports `react` gets the right one
|
|
25
|
+
* and its hooks work.
|
|
26
|
+
*/
|
|
27
|
+
export type PluginNode = ReactNode
|
|
28
|
+
|
|
29
|
+
/** A component the kit hands back, usable directly in a plugin's JSX. */
|
|
30
|
+
export type PluginComponent<Props> = (props: Props) => PluginNode
|
|
31
|
+
|
|
32
|
+
/** Aimux's own types, re-declared structurally where the shape is small. */
|
|
33
|
+
export interface PluginToastApi {
|
|
34
|
+
info: (message: string) => void
|
|
35
|
+
success: (message: string) => void
|
|
36
|
+
error: (message: string) => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PluginBarWidget {
|
|
40
|
+
/** Unqualified; the host prefixes the plugin id. */
|
|
41
|
+
id: string
|
|
42
|
+
label: string
|
|
43
|
+
render: (contentWidth: number) => PluginNode
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PluginView {
|
|
47
|
+
/** Unqualified; the host prefixes the plugin id. */
|
|
48
|
+
id: string
|
|
49
|
+
title: string
|
|
50
|
+
render: () => PluginNode
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PluginModal {
|
|
54
|
+
/** Unqualified; the host prefixes the plugin id. */
|
|
55
|
+
id: string
|
|
56
|
+
title: string
|
|
57
|
+
render: (props: unknown) => PluginNode
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface PluginWidgetsApi {
|
|
61
|
+
register: (widget: PluginBarWidget) => Disposer
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PluginViewsApi {
|
|
65
|
+
register: (view: PluginView) => Disposer
|
|
66
|
+
/** Replaces the panes with this view. Takes the unqualified id. */
|
|
67
|
+
open: (id: string) => void
|
|
68
|
+
/** Returns to the panes. */
|
|
69
|
+
close: () => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PluginModalsApi {
|
|
73
|
+
register: (modal: PluginModal) => Disposer
|
|
74
|
+
open: (id: string, props?: unknown) => void
|
|
75
|
+
close: () => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What a settings row holds. Absent when nothing has ever set it. */
|
|
79
|
+
export type PluginSettingValue = boolean | number | string | undefined
|
|
80
|
+
|
|
81
|
+
export interface PluginSettingsApi {
|
|
82
|
+
/**
|
|
83
|
+
* Registers a section beyond the one generated from the manifest's `config`
|
|
84
|
+
* schema. Most plugins need neither — declaring `config` is enough.
|
|
85
|
+
*/
|
|
86
|
+
registerSection: (section: unknown) => Disposer
|
|
87
|
+
/**
|
|
88
|
+
* Reads one of *aimux's own* settings by row id — the same dotted id that
|
|
89
|
+
* appears in `aimux.config.ts` and on the settings screen.
|
|
90
|
+
*
|
|
91
|
+
* A plugin's own configuration is `ctx.config`; this is for the cases where
|
|
92
|
+
* a plugin has to agree with aimux about something the user already set,
|
|
93
|
+
* rather than ask them a second time.
|
|
94
|
+
*/
|
|
95
|
+
get: (id: string) => PluginSettingValue
|
|
96
|
+
/**
|
|
97
|
+
* Calls back on every change to that row, and once immediately with the
|
|
98
|
+
* current value — so a plugin gating itself on a toggle is one call, with no
|
|
99
|
+
* separate "read it first" step to forget.
|
|
100
|
+
*/
|
|
101
|
+
watch: (id: string, listener: (value: PluginSettingValue) => void) => Disposer
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One tab, as a UI plugin sees it. A narrow projection on purpose: the app's
|
|
106
|
+
* own `TabSession` carries a viewport, terminal modes and a scrollback buffer,
|
|
107
|
+
* none of which a plugin has any business holding a reference to.
|
|
108
|
+
*/
|
|
109
|
+
export interface PluginTabInfo {
|
|
110
|
+
id: string
|
|
111
|
+
title: string
|
|
112
|
+
assistant: string
|
|
113
|
+
status: string
|
|
114
|
+
/** `idle` / `working` / `waiting-input`, or null when nothing has said yet. */
|
|
115
|
+
activity: string | null
|
|
116
|
+
workspaceId?: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What aimux is currently showing, as much of it as a plugin can act on. */
|
|
120
|
+
export interface PluginUiState {
|
|
121
|
+
tabs: readonly PluginTabInfo[]
|
|
122
|
+
activeTabId: string | null
|
|
123
|
+
/** The same tab `activeTabId` names, or null. Saved lookups add up. */
|
|
124
|
+
activeTab: PluginTabInfo | null
|
|
125
|
+
projectId: string | null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface PluginStateApi {
|
|
129
|
+
/** A snapshot, outside React. */
|
|
130
|
+
get: () => PluginUiState
|
|
131
|
+
/** Fires on every change, and once immediately with the current value. */
|
|
132
|
+
subscribe: (listener: (state: PluginUiState) => void) => Disposer
|
|
133
|
+
/**
|
|
134
|
+
* The hook a renderer wants: re-renders only when the selected value
|
|
135
|
+
* changes. The snapshot object is stable between changes, so selecting a
|
|
136
|
+
* field is cheap — selecting a *new object* re-renders every time, which is
|
|
137
|
+
* the same rule every store hook has.
|
|
138
|
+
*/
|
|
139
|
+
use: <T>(select: (state: PluginUiState) => T) => T
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface PluginPane {
|
|
143
|
+
/** Unqualified; the host prefixes the plugin id. */
|
|
144
|
+
id: string
|
|
145
|
+
/** Drawn in the pane's border. */
|
|
146
|
+
title: string
|
|
147
|
+
render: () => PluginNode
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface PluginPanesApi {
|
|
151
|
+
/**
|
|
152
|
+
* Declares a pane: a leaf in the layout tree that draws something other than
|
|
153
|
+
* a terminal. A widget is a narrow strip and a view takes the whole screen;
|
|
154
|
+
* a pane is the one that sits *beside* an agent — a board, a diff, a log
|
|
155
|
+
* browser.
|
|
156
|
+
*
|
|
157
|
+
* Registering does not put it on screen; `open` does.
|
|
158
|
+
*/
|
|
159
|
+
register: (pane: PluginPane) => Disposer
|
|
160
|
+
/**
|
|
161
|
+
* Splits the pane the user is in and puts this one beside it. Takes the
|
|
162
|
+
* unqualified id. Opening one that is already open does nothing: the id is
|
|
163
|
+
* the plugin's name for it, and two panes claiming it would make `close`
|
|
164
|
+
* ambiguous.
|
|
165
|
+
*
|
|
166
|
+
* Opening does not move the keyboard: `direction` decides where the pane
|
|
167
|
+
* goes, and focus stays on the terminal it was split from. The user walks
|
|
168
|
+
* into it with the ordinary pane-navigation keys, and its own keys are bound
|
|
169
|
+
* in its own mode — `plugin.pane.<pluginId>.<id>`.
|
|
170
|
+
*/
|
|
171
|
+
open: (id: string, direction?: 'horizontal' | 'vertical') => void
|
|
172
|
+
/** Takes it off screen. The layout collapses as it would for a closed tab. */
|
|
173
|
+
close: (id: string) => void
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface PluginStatusBarSegment {
|
|
177
|
+
/** Unqualified; the host prefixes the plugin id. */
|
|
178
|
+
id: string
|
|
179
|
+
render: () => PluginNode
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface PluginStatusBarApi {
|
|
183
|
+
/**
|
|
184
|
+
* Adds a tile to the right of the status bar, before the version.
|
|
185
|
+
*
|
|
186
|
+
* The bar draws its own separators and tile colours around it, so a segment
|
|
187
|
+
* renders content and nothing else — there are no powerline glyphs to get
|
|
188
|
+
* wrong. Order is registration order.
|
|
189
|
+
*/
|
|
190
|
+
register: (segment: PluginStatusBarSegment) => Disposer
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface PluginStatsPage {
|
|
194
|
+
/** Unqualified; the host prefixes the plugin id. */
|
|
195
|
+
id: string
|
|
196
|
+
label: string
|
|
197
|
+
/** One cell, text presentation — the rule every nav glyph follows. */
|
|
198
|
+
glyph: string
|
|
199
|
+
render: () => PluginNode
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface PluginStatsApi {
|
|
203
|
+
/** Adds a page to the stats screen's nav, after the built-in three. */
|
|
204
|
+
registerPage: (page: PluginStatsPage) => Disposer
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Light or dark. The user picks it, or the terminal does. */
|
|
208
|
+
export type PluginThemeMode = 'dark' | 'light'
|
|
209
|
+
|
|
210
|
+
export interface PluginThemeSnapshot {
|
|
211
|
+
/** Resolved colour tokens — the same values `kit.useTheme()` returns. */
|
|
212
|
+
colors: Record<string, string>
|
|
213
|
+
mode: PluginThemeMode
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface PluginThemesApi {
|
|
217
|
+
/** The shipped theme JSON shape. A shipped id may not be replaced. */
|
|
218
|
+
register: (id: string, theme: unknown) => Disposer
|
|
219
|
+
/**
|
|
220
|
+
* The active theme outside React. `kit.useTheme()` is the hook for anything
|
|
221
|
+
* being rendered; this is for the rest — a plugin writing the palette to a
|
|
222
|
+
* file, or telling another program about it.
|
|
223
|
+
*/
|
|
224
|
+
current: () => PluginThemeSnapshot
|
|
225
|
+
/** Fires on every theme or mode change. Does not fire for the initial value. */
|
|
226
|
+
onChange: (listener: (snapshot: PluginThemeSnapshot) => void) => Disposer
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The primitives a plugin renders with, already styled like the rest of aimux.
|
|
231
|
+
*
|
|
232
|
+
* Handed over on the context rather than imported: they are implemented in the
|
|
233
|
+
* app, against the same theme store and the same `Surface`/`ListItem` the
|
|
234
|
+
* built-in screens use, and re-implementing them in this package would be the
|
|
235
|
+
* duplication the type de-duplication just removed.
|
|
236
|
+
*
|
|
237
|
+
* Not a component library. A plugin that needs something else drops to `<box>`
|
|
238
|
+
* and `<text>` and styles it from `kit.useTheme()`, which is what every
|
|
239
|
+
* built-in view does.
|
|
240
|
+
*/
|
|
241
|
+
export interface PluginKit {
|
|
242
|
+
/**
|
|
243
|
+
* The resolved theme. The one thing a plugin must not hard-code: aimux ships
|
|
244
|
+
* 34 themes and loads more from disk, and a plugin with its own colours is
|
|
245
|
+
* the part of the screen that stops matching when the user switches.
|
|
246
|
+
*/
|
|
247
|
+
useTheme: () => Record<string, string>
|
|
248
|
+
/** A titled container — what a bar widget and a full-screen view both are. */
|
|
249
|
+
Panel: PluginComponent<{
|
|
250
|
+
children?: PluginNode
|
|
251
|
+
title?: string
|
|
252
|
+
tone?: 'muted' | 'elevated'
|
|
253
|
+
padding?: number
|
|
254
|
+
flexGrow?: number
|
|
255
|
+
}>
|
|
256
|
+
/** A label/value line — what every settings and stats row already is. */
|
|
257
|
+
Row: PluginComponent<{ label: PluginNode; value?: PluginNode; dim?: boolean }>
|
|
258
|
+
/** A selectable list, with the built-in cursor glyph and mouse wiring. */
|
|
259
|
+
List: PluginComponent<{
|
|
260
|
+
items: readonly unknown[]
|
|
261
|
+
selectedIndex?: number
|
|
262
|
+
keyOf?: (item: unknown, index: number) => string
|
|
263
|
+
renderItem: (item: unknown, index: number) => PluginNode
|
|
264
|
+
empty?: PluginNode
|
|
265
|
+
onSelect?: (index: number) => void
|
|
266
|
+
onHover?: (index: number) => void
|
|
267
|
+
}>
|
|
268
|
+
/** The footer line every modal and screen ends with. */
|
|
269
|
+
KeyHint: PluginComponent<{ hints: readonly { keys: string; label: string }[] }>
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface PluginUiApi {
|
|
273
|
+
widgets: PluginWidgetsApi
|
|
274
|
+
views: PluginViewsApi
|
|
275
|
+
modals: PluginModalsApi
|
|
276
|
+
settings: PluginSettingsApi
|
|
277
|
+
themes: PluginThemesApi
|
|
278
|
+
toast: PluginToastApi
|
|
279
|
+
panes: PluginPanesApi
|
|
280
|
+
state: PluginStateApi
|
|
281
|
+
stats: PluginStatsApi
|
|
282
|
+
statusBar: PluginStatusBarApi
|
|
283
|
+
kit: PluginKit
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Keyboard actions and their effects. Registered by unqualified verb; a user's
|
|
288
|
+
* keymap binds the qualified name with `k.plugin('acme.thing.open')`.
|
|
289
|
+
*/
|
|
290
|
+
export interface PluginActionsApi {
|
|
291
|
+
/**
|
|
292
|
+
* The action a key produces. Receives the mode context and returns a
|
|
293
|
+
* `KeyResult` — the same value a built-in binding produces — or null for
|
|
294
|
+
* "not handled here".
|
|
295
|
+
*/
|
|
296
|
+
register: (verb: string, handler: (ctx: unknown) => unknown) => Disposer
|
|
297
|
+
/**
|
|
298
|
+
* The side of a binding that is allowed to do things: spawn a tab, write a
|
|
299
|
+
* file, call out. Reached from an action's `KeyResult` as a `plugin-effect`.
|
|
300
|
+
*/
|
|
301
|
+
effect: (effectId: string, handler: (payload: unknown) => void | Promise<void>) => Disposer
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* This plugin's slice of `AppState`, at `state.plugins[<pluginId>]`. Opaque to
|
|
306
|
+
* the core reducer — the shape is the plugin's, and knowing it would make the
|
|
307
|
+
* app depend on plugins rather than the other way round.
|
|
308
|
+
*/
|
|
309
|
+
export interface PluginStoreApi<Slice = unknown> {
|
|
310
|
+
/** Installs the slice reducer. One per plugin; registering again replaces it. */
|
|
311
|
+
reducer: (
|
|
312
|
+
reduce: (slice: Slice | undefined, action: { actionId: string; payload?: unknown }) => Slice
|
|
313
|
+
) => Disposer
|
|
314
|
+
/** Reads the current slice. A snapshot: it does not subscribe. */
|
|
315
|
+
get: () => Slice | undefined
|
|
316
|
+
/**
|
|
317
|
+
* The slice, as a hook. What a renderer wants: `get()` inside a component
|
|
318
|
+
* reads the right value once and then never hears about the next one, which
|
|
319
|
+
* is a widget that quietly stops updating.
|
|
320
|
+
*/
|
|
321
|
+
use: () => Slice | undefined
|
|
322
|
+
/** Replaces the slice outright. */
|
|
323
|
+
set: (slice: Slice) => void
|
|
324
|
+
/** Dispatches into this plugin's reducer. */
|
|
325
|
+
dispatch: (actionId: string, payload?: unknown) => void
|
|
326
|
+
}
|