@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 +13 -2
- package/src/daemon-api.ts +10 -0
- package/src/index.ts +8 -0
- package/src/test-context.ts +27 -0
- package/src/test-ui.ts +284 -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
|
@@ -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,
|
package/src/test-context.ts
CHANGED
|
@@ -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
|
-
|
|
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
|