@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux-plugin",
3
- "version": "0.1.3",
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
- 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