@brimveyn/aimux-plugin 0.1.4 → 0.1.5

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.4",
3
+ "version": "0.1.5",
4
4
  "description": "Plugin authoring API for aimux — context, effects, events and RPC for in-process plugins.",
5
5
  "keywords": [
6
6
  "aimux",
package/src/daemon-api.ts CHANGED
@@ -109,8 +109,30 @@ export interface PluginProjectsApi {
109
109
  get: (projectId: string) => PluginProjectView | undefined
110
110
  }
111
111
 
112
+ export interface PluginCreateWorkspaceInput {
113
+ projectId: string
114
+ name: string
115
+ /** Defaults to `aimux/<name>`. */
116
+ branch?: string
117
+ /** Base ref for the branch. Defaults to `HEAD`. */
118
+ base?: string
119
+ }
120
+
112
121
  export interface PluginWorkspacesApi {
113
122
  list: (projectId: string) => PluginWorkspaceView[]
123
+ /**
124
+ * Creates a git worktree and its catalog record — what `aimux workspace
125
+ * create` does, from inside the daemon. Resolves with the record; rejects
126
+ * with the same messages the CLI prints, a base ref that does not exist
127
+ * included.
128
+ */
129
+ create: (input: PluginCreateWorkspaceInput) => Promise<PluginWorkspaceView>
130
+ /**
131
+ * Removes a worktree and its record. Refuses the primary workspace, and
132
+ * one with live tabs unless `force` says otherwise — a tab running in a
133
+ * directory that vanishes is a worse outcome than a rejected call.
134
+ */
135
+ remove: (projectId: string, workspaceId: string, options?: { force?: boolean }) => Promise<void>
114
136
  }
115
137
 
116
138
  /**
@@ -128,6 +150,38 @@ export interface PluginMetricsApi {
128
150
  counters: (days?: number) => PluginCounterDay[]
129
151
  }
130
152
 
153
+ /** What aimux knows about the conversation behind a tab. */
154
+ export interface PluginSessionInfo {
155
+ tabId: string
156
+ assistant: string
157
+ /** The vendor's conversation id, when the tab was spawned with one. */
158
+ sessionId: string | null
159
+ /** The transcript on disk, when the vendor writes one and it exists yet. */
160
+ transcriptPath: string | null
161
+ /** The `--model` the tab was spawned with, when one was given. */
162
+ model: string | null
163
+ }
164
+
165
+ /**
166
+ * Token usage of one conversation, read from its transcript. Cumulative
167
+ * across the whole session, which is what a token dashboard, a handoff
168
+ * threshold, or a "you are near the limit" nudge wants.
169
+ */
170
+ export interface PluginSessionUsage {
171
+ tabId: string
172
+ input: number
173
+ output: number
174
+ cacheRead: number
175
+ cacheWrite: number
176
+ total: number
177
+ /** Billed assistant messages seen. */
178
+ turns: number
179
+ /** Total tokens per model id. */
180
+ models: Record<string, number>
181
+ /** ISO time of the last billed message, or null with no usage yet. */
182
+ lastAt: string | null
183
+ }
184
+
131
185
  export interface PluginAssistantsApi {
132
186
  /**
133
187
  * Registers a complete assistant: spawn command, status classifier, question
@@ -135,6 +189,19 @@ export interface PluginAssistantsApi {
135
189
  * `src/pty/assistant-registry.ts` for the shape.
136
190
  */
137
191
  register: (definition: unknown) => Disposer
192
+ /** The conversation behind a tab, or undefined for an unknown tab. */
193
+ session: (tabId: string) => PluginSessionInfo | undefined
194
+ /**
195
+ * Reads the transcript. Zero everywhere for a tab whose assistant writes
196
+ * none, or has not written one yet; undefined for an unknown tab.
197
+ */
198
+ usage: (tabId: string) => Promise<PluginSessionUsage | undefined>
199
+ /**
200
+ * Closes the tab and spawns a fresh one resuming the same conversation —
201
+ * the move after a rate limit, a crashed CLI, or a handoff. Resolves with
202
+ * the new tab id. Rejects when the tab has no session to resume.
203
+ */
204
+ resume: (tabId: string) => Promise<string>
138
205
  }
139
206
 
140
207
  export interface PluginHooksApi {
package/src/index.ts CHANGED
@@ -8,10 +8,13 @@ export type {
8
8
  PluginAssistantsApi,
9
9
  PluginCliApi,
10
10
  PluginCounterDay,
11
+ PluginCreateWorkspaceInput,
11
12
  PluginHooksApi,
12
13
  PluginMetricsApi,
13
14
  PluginProjectsApi,
14
15
  PluginProjectView,
16
+ PluginSessionInfo,
17
+ PluginSessionUsage,
15
18
  PluginSpawnTabInput,
16
19
  PluginTabsApi,
17
20
  PluginTabView,
@@ -24,6 +27,7 @@ export { type EventBusOptions, PluginEventBus } from './event-bus'
24
27
  export {
25
28
  PLUGIN_API_VERSION,
26
29
  type PluginBarContribution,
30
+ type PluginCommandPaneSpec,
27
31
  type PluginCommandSpec,
28
32
  type PluginConfigField,
29
33
  type PluginConfigFieldType,
@@ -31,6 +35,7 @@ export {
31
35
  type PluginHost,
32
36
  type PluginKeymapContribution,
33
37
  type PluginManifest,
38
+ type PluginServiceSpec,
34
39
  } from './manifest'
35
40
  export { createTestUiSurface, type TestUiRegistrations, type TestUiSurface } from './test-ui'
36
41
  export {
@@ -41,19 +46,30 @@ export {
41
46
  type TestRpcCall,
42
47
  } from './test-context'
43
48
  export type {
49
+ PluginActionMeta,
44
50
  PluginActionsApi,
45
51
  PluginBarWidget,
52
+ PluginCommandEntry,
53
+ PluginCommandPane,
54
+ PluginCommandsApi,
46
55
  PluginCommitMessage,
47
56
  PluginCommitMessageRequest,
48
57
  PluginComponent,
49
58
  PluginGitApi,
59
+ PluginGitCommitInput,
50
60
  PluginGitFile,
51
61
  PluginGitStatus,
52
62
  PluginKit,
63
+ PluginLayoutApi,
64
+ PluginLayoutNode,
53
65
  PluginModal,
54
66
  PluginModalsApi,
55
67
  PluginNode,
68
+ PluginNotification,
69
+ PluginNotificationEvent,
70
+ PluginNotificationsApi,
56
71
  PluginPane,
72
+ PluginPaneDirection,
57
73
  PluginPanesApi,
58
74
  PluginScreen,
59
75
  PluginSettingsApi,
package/src/manifest.ts CHANGED
@@ -71,6 +71,10 @@ export interface PluginBarContribution {
71
71
  * every plugin, here as everywhere else.
72
72
  */
73
73
  export interface PluginKeymapContribution {
74
+ /** Stable id used by user overrides. Defaults to `action`. */
75
+ id?: string
76
+ /** Human-readable label shown in settings and key help. */
77
+ description?: string
74
78
  /** Mode id, e.g. `navigation`, or the plugin's own pane mode. */
75
79
  mode: string
76
80
  /** Key notation, `<leader>` included. */
@@ -84,6 +88,34 @@ export interface PluginContributions {
84
88
  keymaps?: PluginKeymapContribution[]
85
89
  }
86
90
 
91
+ /**
92
+ * A pane that runs a program, declared rather than registered — the manifest
93
+ * twin of `ctx.ui.panes.registerCommand`. `cwd` reads as it does there.
94
+ */
95
+ export interface PluginCommandPaneSpec {
96
+ id: string
97
+ title?: string
98
+ /** argv, not a shell string. */
99
+ command: string[]
100
+ cwd?: string
101
+ }
102
+
103
+ /**
104
+ * A long-running process the daemon supervises: started when the plugin
105
+ * loads, stopped when it unloads, restarted according to `restart`. A relay,
106
+ * a watcher, a bridge — anything that is not a command that finishes.
107
+ *
108
+ * Runs with the same `AIMUX_*` environment as `commands[]`, so it can call
109
+ * back through the CLI in any language.
110
+ */
111
+ export interface PluginServiceSpec {
112
+ id: string
113
+ /** argv, not a shell string. */
114
+ command: string[]
115
+ /** Default `on-failure`: a clean exit stays down, a crash comes back. */
116
+ restart?: 'never' | 'on-failure' | 'always'
117
+ }
118
+
87
119
  export interface PluginManifest {
88
120
  /** Reverse-DNS-ish, `<vendor>.<name>`; the namespace for every registration. */
89
121
  id: string
@@ -100,6 +132,10 @@ export interface PluginManifest {
100
132
  build?: string[][]
101
133
  config?: Record<string, PluginConfigField>
102
134
  commands?: PluginCommandSpec[]
135
+ /** Panes that host a program. Applied when the UI half loads — or, with no UI entry, at once. */
136
+ panes?: PluginCommandPaneSpec[]
137
+ /** Processes the daemon keeps alive for the plugin. */
138
+ services?: PluginServiceSpec[]
103
139
  /**
104
140
  * What the plugin asks the interface for: a place for its widget, a key for
105
141
  * its action. Applied by the host when the UI half loads, withdrawn when it
@@ -211,10 +211,12 @@ export function createTestContext(options: TestContextOptions = {}): TestContext
211
211
  ui: unknown
212
212
  actions: unknown
213
213
  store: unknown
214
+ commands: unknown
214
215
  }
215
216
  extended.ui = surface.ui
216
217
  extended.actions = surface.actions
217
218
  extended.store = surface.store
219
+ extended.commands = surface.commands
218
220
  }
219
221
 
220
222
  options.extend?.(ctx)
package/src/test-ui.ts CHANGED
@@ -2,9 +2,11 @@ import type { EffectStack } from './effects'
2
2
  import type { Disposer } from './types'
3
3
  import type {
4
4
  PluginActionsApi,
5
+ PluginCommandsApi,
5
6
  PluginCommitMessage,
6
7
  PluginCommitMessageRequest,
7
8
  PluginGitStatus,
9
+ PluginNotificationEvent,
8
10
  PluginSettingValue,
9
11
  PluginStoreApi,
10
12
  PluginThemeSnapshot,
@@ -35,12 +37,16 @@ export interface TestUiRegistrations {
35
37
  views: string[]
36
38
  modals: string[]
37
39
  panes: string[]
40
+ /** Command panes — programs the plugin asked to host. */
41
+ commandPanes: string[]
38
42
  statusBar: string[]
39
43
  statsPages: string[]
40
44
  themes: string[]
41
45
  settingsSections: number
42
46
  /** Whether the plugin claimed the commit-message slot. */
43
47
  commitMessageProvider: boolean
48
+ /** Whether the plugin claimed the notification slot. */
49
+ notificationSink: boolean
44
50
  actions: string[]
45
51
  effects: string[]
46
52
  }
@@ -49,11 +55,18 @@ export interface TestUiSurface {
49
55
  ui: PluginUiApi
50
56
  actions: PluginActionsApi
51
57
  store: PluginStoreApi
58
+ commands: PluginCommandsApi
52
59
  registrations: TestUiRegistrations
53
60
  /** Everything `ctx.ui.toast` was asked to show, newest last. */
54
61
  toasts: { level: 'info' | 'success' | 'error'; message: string }[]
62
+ /** Everything `ctx.ui.notifications.notify` raised, newest last. */
63
+ notifications: { title: string; message?: string; level?: string }[]
55
64
  /** Panes and views the plugin asked to open or close, in order. */
56
65
  opened: string[]
66
+ /** Layout verbs the plugin called, in order — `split:vertical`, `focus:left`, … */
67
+ layoutCalls: string[]
68
+ /** Git writes the plugin asked for, in order — `stage:a.ts`, `commit:title`, … */
69
+ gitWrites: string[]
57
70
  /** Drives `ctx.ui.state`: set it, and subscribers hear about it. */
58
71
  setState: (next: Partial<PluginUiState>) => void
59
72
  /** Drives `ctx.ui.settings.watch` and what `get` answers. */
@@ -70,6 +83,13 @@ export interface TestUiSurface {
70
83
  askForCommitMessage: (
71
84
  request?: Partial<PluginCommitMessageRequest>
72
85
  ) => Promise<PluginCommitMessage | null>
86
+ /**
87
+ * Delivers an event to the sink the plugin provided, as aimux would on an
88
+ * agent finishing a turn. Throws when it provided none.
89
+ */
90
+ deliverNotification: (event: PluginNotificationEvent) => Promise<void>
91
+ /** Drives `ctx.ui.git.diff`. */
92
+ setGitDiff: (path: string, diff: string) => void
73
93
  }
74
94
 
75
95
  const EMPTY_STATE: PluginUiState = {
@@ -99,9 +119,11 @@ const nothing = (): null => null
99
119
  export function createTestUiSurface(effects: EffectStack): TestUiSurface {
100
120
  const registrations: TestUiRegistrations = {
101
121
  actions: [],
122
+ commandPanes: [],
102
123
  commitMessageProvider: false,
103
124
  effects: [],
104
125
  modals: [],
126
+ notificationSink: false,
105
127
  panes: [],
106
128
  settingsSections: 0,
107
129
  statsPages: [],
@@ -111,7 +133,12 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
111
133
  widgets: [],
112
134
  }
113
135
  const toasts: TestUiSurface['toasts'] = []
136
+ const notifications: TestUiSurface['notifications'] = []
114
137
  const opened: string[] = []
138
+ const layoutCalls: string[] = []
139
+ const gitWrites: string[] = []
140
+ const diffs = new Map<string, string>()
141
+ let notificationSink: ((event: PluginNotificationEvent) => void | Promise<void>) | null = null
115
142
 
116
143
  let state: PluginUiState = EMPTY_STATE
117
144
  const stateListeners = new Set<(next: PluginUiState) => void>()
@@ -150,6 +177,13 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
150
177
 
151
178
  const ui: PluginUiApi = {
152
179
  git: {
180
+ commit: async (input) => {
181
+ gitWrites.push(`commit:${input.title}`)
182
+ },
183
+ diff: async (path) => diffs.get(path) ?? '',
184
+ discard: async (paths) => {
185
+ gitWrites.push(`discard:${paths.join(',')}`)
186
+ },
153
187
  provideCommitMessage: (provider) => {
154
188
  commitProvider = provider
155
189
  registrations.commitMessageProvider = true
@@ -160,7 +194,13 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
160
194
  effects.add(dispose)
161
195
  return dispose
162
196
  },
197
+ stage: async (paths) => {
198
+ gitWrites.push(`stage:${paths.join(',')}`)
199
+ },
163
200
  status: () => git,
201
+ unstage: async (paths) => {
202
+ gitWrites.push(`unstage:${paths.join(',')}`)
203
+ },
164
204
  },
165
205
  kit: {
166
206
  KeyHint: nothing,
@@ -169,16 +209,42 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
169
209
  Row: nothing,
170
210
  useTheme: () => theme.colors,
171
211
  },
212
+ layout: {
213
+ close: (tabId) => layoutCalls.push(`close:${tabId ?? 'active'}`),
214
+ focus: (direction) => layoutCalls.push(`focus:${direction}`),
215
+ panes: () => (state.activeTabId === null ? [] : [state.activeTabId]),
216
+ resize: (delta, axis) => layoutCalls.push(`resize:${axis}:${delta}`),
217
+ split: (direction) => layoutCalls.push(`split:${direction}`),
218
+ swap: (direction) => layoutCalls.push(`swap:${direction}`),
219
+ tree: () => null,
220
+ },
172
221
  modals: {
173
222
  close: () => opened.push('modal:close'),
174
223
  open: (id) => opened.push(`modal:${id}`),
175
224
  register: (modal) => record(registrations.modals, modal.id),
176
225
  },
177
226
  navigate: (screen) => opened.push(`screen:${screen}`),
227
+ notifications: {
228
+ notify: (notification) => {
229
+ notifications.push(notification)
230
+ },
231
+ provide: (sink) => {
232
+ notificationSink = sink
233
+ registrations.notificationSink = true
234
+ const dispose = (): void => {
235
+ if (notificationSink === sink) notificationSink = null
236
+ registrations.notificationSink = false
237
+ }
238
+ effects.add(dispose)
239
+ return dispose
240
+ },
241
+ },
178
242
  panes: {
179
243
  close: (id) => opened.push(`pane:close:${id}`),
180
244
  open: (id) => opened.push(`pane:${id}`),
245
+ openCommandPanes: () => [],
181
246
  register: (pane) => record(registrations.panes, pane.id),
247
+ registerCommand: (pane) => record(registrations.commandPanes, pane.id),
182
248
  },
183
249
  settings: {
184
250
  get: (id) => settings.get(id),
@@ -236,6 +302,17 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
236
302
  register: (verb) => record(registrations.actions, verb),
237
303
  }
238
304
 
305
+ const commands: PluginCommandsApi = {
306
+ list: () =>
307
+ registrations.actions.map((verb) => ({
308
+ id: verb,
309
+ kind: 'action' as const,
310
+ pluginId: 'test',
311
+ title: verb,
312
+ })),
313
+ run: () => false,
314
+ }
315
+
239
316
  const store: PluginStoreApi = {
240
317
  dispatch: () => {
241
318
  /* a slice reducer is the plugin's; a bare context has none to run */
@@ -260,8 +337,19 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
260
337
  if (commitProvider === null) throw new Error('the plugin registered no commit provider')
261
338
  return commitProvider({ ...EMPTY_REQUEST, ...request }, new AbortController().signal)
262
339
  },
340
+ commands,
341
+ deliverNotification: async (event) => {
342
+ if (notificationSink === null) throw new Error('the plugin provided no notification sink')
343
+ await notificationSink(event)
344
+ },
345
+ gitWrites,
346
+ layoutCalls,
347
+ notifications,
263
348
  opened,
264
349
  registrations,
350
+ setGitDiff: (path, diff) => {
351
+ diffs.set(path, diff)
352
+ },
265
353
  setGitStatus: (status) => {
266
354
  git = status
267
355
  },
package/src/types.ts CHANGED
@@ -8,7 +8,7 @@ import type {
8
8
  PluginWorkspacesApi,
9
9
  } from './daemon-api'
10
10
  import type { PluginHost, PluginManifest } from './manifest'
11
- import type { PluginActionsApi, PluginStoreApi, PluginUiApi } from './ui'
11
+ import type { PluginActionsApi, PluginCommandsApi, PluginStoreApi, PluginUiApi } from './ui'
12
12
 
13
13
  /**
14
14
  * Everything a plugin registers hands back one of these. The kernel calls
@@ -122,6 +122,7 @@ export interface UiPluginContext<Slice = unknown> extends PluginContext {
122
122
  readonly ui: PluginUiApi
123
123
  readonly actions: PluginActionsApi
124
124
  readonly store: PluginStoreApi<Slice>
125
+ readonly commands: PluginCommandsApi
125
126
  }
126
127
 
127
128
  /**
package/src/ui.ts CHANGED
@@ -159,6 +159,32 @@ export interface PluginPane {
159
159
  render: () => PluginNode
160
160
  }
161
161
 
162
+ /**
163
+ * A pane that hosts a *program* rather than React: lazygit, yazi, a Rust TUI
164
+ * the plugin ships. Same story the daemon's `commands[]` tells — a plugin in
165
+ * any language — applied to the interface.
166
+ *
167
+ * Opening one spawns the argv in a terminal pane aimux owns on the plugin's
168
+ * behalf: it is a real PTY tab, so it takes the keyboard like any terminal
169
+ * does. The pane outlives a reload of the plugin (a re-registration under the
170
+ * same id adopts it) and dies with the plugin (unlink, uninstall, disable). A
171
+ * program that exits leaves a pane that says so, and `Ctrl+r` restarts it.
172
+ */
173
+ export interface PluginCommandPane {
174
+ /** Unqualified; the host prefixes the plugin id. */
175
+ id: string
176
+ /** Drawn in the pane's border and in the tab strip. */
177
+ title: string
178
+ /** argv, not a shell string — no quoting rules to get wrong. */
179
+ command: string[]
180
+ /**
181
+ * Where to run it. `workspace` (the default) is the active workspace's
182
+ * directory, `project` the project's, `plugin` the plugin's own root; any
183
+ * other value is taken as an absolute path.
184
+ */
185
+ cwd?: 'workspace' | 'project' | 'plugin' | (string & {})
186
+ }
187
+
162
188
  export interface PluginPanesApi {
163
189
  /**
164
190
  * Declares a pane: a leaf in the layout tree that draws something other than
@@ -169,6 +195,12 @@ export interface PluginPanesApi {
169
195
  * Registering does not put it on screen; `open` does.
170
196
  */
171
197
  register: (pane: PluginPane) => Disposer
198
+ /**
199
+ * Declares a pane that runs a program. The manifest's `panes[]` block is the
200
+ * same declaration without a line of TypeScript. `open` and `close` take the
201
+ * same unqualified id either way.
202
+ */
203
+ registerCommand: (pane: PluginCommandPane) => Disposer
172
204
  /**
173
205
  * Splits the pane the user is in and puts this one beside it. Takes the
174
206
  * unqualified id. Opening one that is already open does nothing: the id is
@@ -181,8 +213,112 @@ export interface PluginPanesApi {
181
213
  * in its own mode — `plugin.pane.<pluginId>.<id>`.
182
214
  */
183
215
  open: (id: string, direction?: 'horizontal' | 'vertical') => void
184
- /** Takes it off screen. The layout collapses as it would for a closed tab. */
216
+ /**
217
+ * Takes it off screen. The layout collapses as it would for a closed tab.
218
+ * For a command pane this also kills the program.
219
+ */
185
220
  close: (id: string) => void
221
+ /**
222
+ * Which command panes are on screen right now, by unqualified id. A React
223
+ * pane is either registered or not; a command pane also has a process, and
224
+ * a plugin that wants to toggle one needs to know.
225
+ */
226
+ openCommandPanes: () => string[]
227
+ }
228
+
229
+ /** The layout of one group, as a plugin reads it. Same shape aimux persists. */
230
+ export type PluginLayoutNode =
231
+ | { type: 'leaf'; id: string; kind: 'tab' | 'plugin' }
232
+ | {
233
+ type: 'split'
234
+ direction: 'horizontal' | 'vertical'
235
+ ratio: number
236
+ first: PluginLayoutNode
237
+ second: PluginLayoutNode
238
+ }
239
+
240
+ export type PluginPaneDirection = 'left' | 'right' | 'up' | 'down'
241
+
242
+ /**
243
+ * The layout as an API rather than as keys. Every verb here is one the
244
+ * keyboard already has, dispatched through the same reducer, so a plugin
245
+ * cannot reach a layout the user could not have made by hand.
246
+ *
247
+ * Everything acts on the pane holding the keyboard unless a `tabId` says
248
+ * otherwise; nothing here throws for a pane that cannot move, because a
249
+ * key press does not either.
250
+ */
251
+ export interface PluginLayoutApi {
252
+ /** Splits the active pane and puts a new terminal beside it, same assistant. */
253
+ split: (direction: 'horizontal' | 'vertical') => void
254
+ /** Moves the keyboard to the neighbouring pane. */
255
+ focus: (direction: PluginPaneDirection) => void
256
+ /** Exchanges the active pane with its neighbour in that direction. */
257
+ swap: (direction: PluginPaneDirection) => void
258
+ /** Nudges the split the active pane sits in. `delta` is in steps, ±1 typically. */
259
+ resize: (delta: number, axis: 'horizontal' | 'vertical') => void
260
+ /** Closes a pane — the active one when no id is given. Kills its process. */
261
+ close: (tabId?: string) => void
262
+ /** The active group's tree, or null when the active tab is not split. */
263
+ tree: () => PluginLayoutNode | null
264
+ /** Every pane id in the active group, in draw order. One id when unsplit. */
265
+ panes: () => string[]
266
+ }
267
+
268
+ /** What a notification says. */
269
+ export interface PluginNotification {
270
+ title: string
271
+ message?: string
272
+ level?: 'info' | 'success' | 'warning' | 'error'
273
+ }
274
+
275
+ /**
276
+ * A notification aimux is about to make — its own (`waiting-input`,
277
+ * `turn-complete`) or one a plugin raised (`custom`). What a sink receives.
278
+ */
279
+ export interface PluginNotificationEvent extends PluginNotification {
280
+ kind: 'waiting-input' | 'turn-complete' | 'custom'
281
+ tabId?: string
282
+ workspaceId?: string
283
+ /** The plugin that raised it; absent on aimux's own. */
284
+ pluginId?: string
285
+ }
286
+
287
+ export interface PluginNotificationsApi {
288
+ /** Shows a toast — or hands it to the sink, when a plugin provides one. */
289
+ notify: (notification: PluginNotification) => void
290
+ /**
291
+ * Replaces aimux's own notifications: the sound on an agent asking a
292
+ * question or finishing a turn stops playing, and every event — aimux's
293
+ * and other plugins' — lands here instead. A ntfy or Telegram plugin
294
+ * *replaces* the native toast rather than doubling it.
295
+ *
296
+ * One plugin at a time, on the `provideCommitMessage` model: the second to
297
+ * ask is refused and told so in its log.
298
+ */
299
+ provide: (sink: (event: PluginNotificationEvent) => void | Promise<void>) => Disposer
300
+ }
301
+
302
+ /** One entry of what `ctx.commands.list()` enumerates. */
303
+ export interface PluginCommandEntry {
304
+ /** `action` runs in the UI, `exec` is a manifest `commands[]` subprocess, `cli` an `aimux <group> <verb>`. */
305
+ kind: 'action' | 'exec' | 'cli'
306
+ /** The name to run it by: the qualified action, `<pluginId> <commandId>`, or `<group> <verb>`. */
307
+ id: string
308
+ pluginId: string
309
+ title: string
310
+ description?: string
311
+ }
312
+
313
+ /**
314
+ * Everything runnable that plugins have contributed, in one list. Without it
315
+ * a command palette written by a third party has nothing to show; with it,
316
+ * `run` fires an action the way its key would.
317
+ */
318
+ export interface PluginCommandsApi {
319
+ list: () => PluginCommandEntry[]
320
+ /** Runs an `action` entry by its qualified id. Returns false when nothing answered. */
321
+ run: (id: string) => boolean
186
322
  }
187
323
 
188
324
  export interface PluginStatusBarSegment {
@@ -329,9 +465,33 @@ export interface PluginCommitMessage {
329
465
  body?: string
330
466
  }
331
467
 
468
+ export interface PluginGitCommitInput {
469
+ title: string
470
+ body?: string
471
+ }
472
+
332
473
  export interface PluginGitApi {
333
474
  /** The panel's last refresh. */
334
475
  status: () => PluginGitStatus
476
+ /**
477
+ * The diff of one file, unified. `staged` reads the index against HEAD; the
478
+ * default reads the working tree against the index, which is what the
479
+ * panel shows for an unstaged file. Empty for an untracked file — git has
480
+ * nothing to compare it with — so read the file itself in that case.
481
+ */
482
+ diff: (path: string, options?: { staged?: boolean }) => Promise<string>
483
+ /** `git add -- <paths>`. Rejects with git's own message on failure. */
484
+ stage: (paths: readonly string[]) => Promise<void>
485
+ /** `git restore --staged -- <paths>`. */
486
+ unstage: (paths: readonly string[]) => Promise<void>
487
+ /**
488
+ * Throws away working-tree changes: `git checkout --` for a tracked file,
489
+ * deletion for an untracked one. There is no undo, which is why it takes
490
+ * paths rather than "everything".
491
+ */
492
+ discard: (paths: readonly string[]) => Promise<void>
493
+ /** Commits what is staged. Rejects when nothing is, with git's own words. */
494
+ commit: (input: PluginGitCommitInput) => Promise<void>
335
495
  /**
336
496
  * Answers "what should this commit say", replacing the headless model call
337
497
  * aimux would otherwise make. Return `null` to decline this one — aimux falls
@@ -366,6 +526,8 @@ export interface PluginUiApi {
366
526
  themes: PluginThemesApi
367
527
  toast: PluginToastApi
368
528
  panes: PluginPanesApi
529
+ layout: PluginLayoutApi
530
+ notifications: PluginNotificationsApi
369
531
  state: PluginStateApi
370
532
  stats: PluginStatsApi
371
533
  statusBar: PluginStatusBarApi
@@ -376,13 +538,22 @@ export interface PluginUiApi {
376
538
  * Keyboard actions and their effects. Registered by unqualified verb; a user's
377
539
  * keymap binds the qualified name with `k.plugin('acme.thing.open')`.
378
540
  */
541
+ /** What a palette shows for an action. Optional: an action without one lists under its verb. */
542
+ export interface PluginActionMeta {
543
+ title?: string
544
+ description?: string
545
+ }
546
+
379
547
  export interface PluginActionsApi {
380
548
  /**
381
549
  * The action a key produces. Receives the mode context and returns a
382
550
  * `KeyResult` — the same value a built-in binding produces — or null for
383
551
  * "not handled here".
552
+ *
553
+ * `meta` gives it a title. An action with one is something a palette can
554
+ * list and a user can find; without it the verb is all anyone sees.
384
555
  */
385
- register: (verb: string, handler: (ctx: unknown) => unknown) => Disposer
556
+ register: (verb: string, handler: (ctx: unknown) => unknown, meta?: PluginActionMeta) => Disposer
386
557
  /**
387
558
  * The side of a binding that is allowed to do things: spawn a tab, write a
388
559
  * file, call out. Reached from an action's `KeyResult` as a `plugin-effect`.