@brimveyn/aimux-plugin 0.1.3 → 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 +13 -2
- package/src/daemon-api.ts +10 -0
- package/src/index.ts +7 -0
- package/src/test-ui.ts +56 -0
- package/src/testing.ts +99 -0
- package/src/ui.ts +90 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux-plugin",
|
|
3
|
-
"version": "0.1.
|
|
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
|
@@ -43,13 +43,19 @@ export {
|
|
|
43
43
|
export type {
|
|
44
44
|
PluginActionsApi,
|
|
45
45
|
PluginBarWidget,
|
|
46
|
+
PluginCommitMessage,
|
|
47
|
+
PluginCommitMessageRequest,
|
|
46
48
|
PluginComponent,
|
|
49
|
+
PluginGitApi,
|
|
50
|
+
PluginGitFile,
|
|
51
|
+
PluginGitStatus,
|
|
47
52
|
PluginKit,
|
|
48
53
|
PluginModal,
|
|
49
54
|
PluginModalsApi,
|
|
50
55
|
PluginNode,
|
|
51
56
|
PluginPane,
|
|
52
57
|
PluginPanesApi,
|
|
58
|
+
PluginScreen,
|
|
53
59
|
PluginSettingsApi,
|
|
54
60
|
PluginSettingValue,
|
|
55
61
|
PluginStateApi,
|
|
@@ -68,6 +74,7 @@ export type {
|
|
|
68
74
|
PluginView,
|
|
69
75
|
PluginViewsApi,
|
|
70
76
|
PluginWidgetsApi,
|
|
77
|
+
PluginWidgetSize,
|
|
71
78
|
} from './ui'
|
|
72
79
|
export type {
|
|
73
80
|
DaemonPluginContext,
|
package/src/test-ui.ts
CHANGED
|
@@ -2,6 +2,9 @@ import type { EffectStack } from './effects'
|
|
|
2
2
|
import type { Disposer } from './types'
|
|
3
3
|
import type {
|
|
4
4
|
PluginActionsApi,
|
|
5
|
+
PluginCommitMessage,
|
|
6
|
+
PluginCommitMessageRequest,
|
|
7
|
+
PluginGitStatus,
|
|
5
8
|
PluginSettingValue,
|
|
6
9
|
PluginStoreApi,
|
|
7
10
|
PluginThemeSnapshot,
|
|
@@ -36,6 +39,8 @@ export interface TestUiRegistrations {
|
|
|
36
39
|
statsPages: string[]
|
|
37
40
|
themes: string[]
|
|
38
41
|
settingsSections: number
|
|
42
|
+
/** Whether the plugin claimed the commit-message slot. */
|
|
43
|
+
commitMessageProvider: boolean
|
|
39
44
|
actions: string[]
|
|
40
45
|
effects: string[]
|
|
41
46
|
}
|
|
@@ -55,6 +60,16 @@ export interface TestUiSurface {
|
|
|
55
60
|
setSetting: (id: string, value: PluginSettingValue) => void
|
|
56
61
|
/** Drives `ctx.ui.themes.onChange` and what `current()` answers. */
|
|
57
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>
|
|
58
73
|
}
|
|
59
74
|
|
|
60
75
|
const EMPTY_STATE: PluginUiState = {
|
|
@@ -66,12 +81,25 @@ const EMPTY_STATE: PluginUiState = {
|
|
|
66
81
|
|
|
67
82
|
const DEFAULT_THEME: PluginThemeSnapshot = { colors: {}, mode: 'dark' }
|
|
68
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
|
+
|
|
69
96
|
/** A component that renders nothing: a test asserts on registrations, not pixels. */
|
|
70
97
|
const nothing = (): null => null
|
|
71
98
|
|
|
72
99
|
export function createTestUiSurface(effects: EffectStack): TestUiSurface {
|
|
73
100
|
const registrations: TestUiRegistrations = {
|
|
74
101
|
actions: [],
|
|
102
|
+
commitMessageProvider: false,
|
|
75
103
|
effects: [],
|
|
76
104
|
modals: [],
|
|
77
105
|
panes: [],
|
|
@@ -92,6 +120,13 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
|
|
|
92
120
|
let theme: PluginThemeSnapshot = DEFAULT_THEME
|
|
93
121
|
const themeListeners = new Set<(snapshot: PluginThemeSnapshot) => void>()
|
|
94
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
|
|
95
130
|
|
|
96
131
|
/** Records a registration and hands back a disposer that unrecords it. */
|
|
97
132
|
function record(into: string[], id: string): Disposer {
|
|
@@ -114,6 +149,19 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
|
|
|
114
149
|
}
|
|
115
150
|
|
|
116
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
|
+
},
|
|
117
165
|
kit: {
|
|
118
166
|
KeyHint: nothing,
|
|
119
167
|
List: nothing,
|
|
@@ -126,6 +174,7 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
|
|
|
126
174
|
open: (id) => opened.push(`modal:${id}`),
|
|
127
175
|
register: (modal) => record(registrations.modals, modal.id),
|
|
128
176
|
},
|
|
177
|
+
navigate: (screen) => opened.push(`screen:${screen}`),
|
|
129
178
|
panes: {
|
|
130
179
|
close: (id) => opened.push(`pane:close:${id}`),
|
|
131
180
|
open: (id) => opened.push(`pane:${id}`),
|
|
@@ -207,8 +256,15 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
|
|
|
207
256
|
|
|
208
257
|
return {
|
|
209
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
|
+
},
|
|
210
263
|
opened,
|
|
211
264
|
registrations,
|
|
265
|
+
setGitStatus: (status) => {
|
|
266
|
+
git = status
|
|
267
|
+
},
|
|
212
268
|
setSetting: (id, value) => {
|
|
213
269
|
settings.set(id, value)
|
|
214
270
|
for (const listener of settingListeners.get(id) ?? []) listener(value)
|
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
|
-
|
|
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
|