@brimveyn/aimux-plugin 0.1.3 → 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 +13 -2
- package/src/daemon-api.ts +77 -0
- package/src/index.ts +23 -0
- package/src/manifest.ts +36 -0
- package/src/test-context.ts +2 -0
- package/src/test-ui.ts +144 -0
- package/src/testing.ts +99 -0
- package/src/types.ts +2 -1
- package/src/ui.ts +263 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux-plugin",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -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 {
|
|
@@ -99,8 +109,30 @@ export interface PluginProjectsApi {
|
|
|
99
109
|
get: (projectId: string) => PluginProjectView | undefined
|
|
100
110
|
}
|
|
101
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
|
+
|
|
102
121
|
export interface PluginWorkspacesApi {
|
|
103
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>
|
|
104
136
|
}
|
|
105
137
|
|
|
106
138
|
/**
|
|
@@ -118,6 +150,38 @@ export interface PluginMetricsApi {
|
|
|
118
150
|
counters: (days?: number) => PluginCounterDay[]
|
|
119
151
|
}
|
|
120
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
|
+
|
|
121
185
|
export interface PluginAssistantsApi {
|
|
122
186
|
/**
|
|
123
187
|
* Registers a complete assistant: spawn command, status classifier, question
|
|
@@ -125,6 +189,19 @@ export interface PluginAssistantsApi {
|
|
|
125
189
|
* `src/pty/assistant-registry.ts` for the shape.
|
|
126
190
|
*/
|
|
127
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>
|
|
128
205
|
}
|
|
129
206
|
|
|
130
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,15 +46,32 @@ 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,
|
|
55
|
+
PluginCommitMessage,
|
|
56
|
+
PluginCommitMessageRequest,
|
|
46
57
|
PluginComponent,
|
|
58
|
+
PluginGitApi,
|
|
59
|
+
PluginGitCommitInput,
|
|
60
|
+
PluginGitFile,
|
|
61
|
+
PluginGitStatus,
|
|
47
62
|
PluginKit,
|
|
63
|
+
PluginLayoutApi,
|
|
64
|
+
PluginLayoutNode,
|
|
48
65
|
PluginModal,
|
|
49
66
|
PluginModalsApi,
|
|
50
67
|
PluginNode,
|
|
68
|
+
PluginNotification,
|
|
69
|
+
PluginNotificationEvent,
|
|
70
|
+
PluginNotificationsApi,
|
|
51
71
|
PluginPane,
|
|
72
|
+
PluginPaneDirection,
|
|
52
73
|
PluginPanesApi,
|
|
74
|
+
PluginScreen,
|
|
53
75
|
PluginSettingsApi,
|
|
54
76
|
PluginSettingValue,
|
|
55
77
|
PluginStateApi,
|
|
@@ -68,6 +90,7 @@ export type {
|
|
|
68
90
|
PluginView,
|
|
69
91
|
PluginViewsApi,
|
|
70
92
|
PluginWidgetsApi,
|
|
93
|
+
PluginWidgetSize,
|
|
71
94
|
} from './ui'
|
|
72
95
|
export type {
|
|
73
96
|
DaemonPluginContext,
|
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
|
package/src/test-context.ts
CHANGED
|
@@ -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,6 +2,11 @@ import type { EffectStack } from './effects'
|
|
|
2
2
|
import type { Disposer } from './types'
|
|
3
3
|
import type {
|
|
4
4
|
PluginActionsApi,
|
|
5
|
+
PluginCommandsApi,
|
|
6
|
+
PluginCommitMessage,
|
|
7
|
+
PluginCommitMessageRequest,
|
|
8
|
+
PluginGitStatus,
|
|
9
|
+
PluginNotificationEvent,
|
|
5
10
|
PluginSettingValue,
|
|
6
11
|
PluginStoreApi,
|
|
7
12
|
PluginThemeSnapshot,
|
|
@@ -32,10 +37,16 @@ export interface TestUiRegistrations {
|
|
|
32
37
|
views: string[]
|
|
33
38
|
modals: string[]
|
|
34
39
|
panes: string[]
|
|
40
|
+
/** Command panes — programs the plugin asked to host. */
|
|
41
|
+
commandPanes: string[]
|
|
35
42
|
statusBar: string[]
|
|
36
43
|
statsPages: string[]
|
|
37
44
|
themes: string[]
|
|
38
45
|
settingsSections: number
|
|
46
|
+
/** Whether the plugin claimed the commit-message slot. */
|
|
47
|
+
commitMessageProvider: boolean
|
|
48
|
+
/** Whether the plugin claimed the notification slot. */
|
|
49
|
+
notificationSink: boolean
|
|
39
50
|
actions: string[]
|
|
40
51
|
effects: string[]
|
|
41
52
|
}
|
|
@@ -44,17 +55,41 @@ export interface TestUiSurface {
|
|
|
44
55
|
ui: PluginUiApi
|
|
45
56
|
actions: PluginActionsApi
|
|
46
57
|
store: PluginStoreApi
|
|
58
|
+
commands: PluginCommandsApi
|
|
47
59
|
registrations: TestUiRegistrations
|
|
48
60
|
/** Everything `ctx.ui.toast` was asked to show, newest last. */
|
|
49
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 }[]
|
|
50
64
|
/** Panes and views the plugin asked to open or close, in order. */
|
|
51
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[]
|
|
52
70
|
/** Drives `ctx.ui.state`: set it, and subscribers hear about it. */
|
|
53
71
|
setState: (next: Partial<PluginUiState>) => void
|
|
54
72
|
/** Drives `ctx.ui.settings.watch` and what `get` answers. */
|
|
55
73
|
setSetting: (id: string, value: PluginSettingValue) => void
|
|
56
74
|
/** Drives `ctx.ui.themes.onChange` and what `current()` answers. */
|
|
57
75
|
setTheme: (snapshot: PluginThemeSnapshot) => void
|
|
76
|
+
/** Drives `ctx.ui.git.status`. */
|
|
77
|
+
setGitStatus: (status: PluginGitStatus) => void
|
|
78
|
+
/**
|
|
79
|
+
* Asks the provider the plugin registered, as the commit flow would. Throws
|
|
80
|
+
* when it registered none, because a test that silently asserts nothing is
|
|
81
|
+
* the failure this whole harness exists to avoid.
|
|
82
|
+
*/
|
|
83
|
+
askForCommitMessage: (
|
|
84
|
+
request?: Partial<PluginCommitMessageRequest>
|
|
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
|
|
58
93
|
}
|
|
59
94
|
|
|
60
95
|
const EMPTY_STATE: PluginUiState = {
|
|
@@ -66,14 +101,29 @@ const EMPTY_STATE: PluginUiState = {
|
|
|
66
101
|
|
|
67
102
|
const DEFAULT_THEME: PluginThemeSnapshot = { colors: {}, mode: 'dark' }
|
|
68
103
|
|
|
104
|
+
const EMPTY_GIT: PluginGitStatus = { ahead: 0, behind: 0, branch: null, files: [] }
|
|
105
|
+
|
|
106
|
+
const EMPTY_REQUEST: PluginCommitMessageRequest = {
|
|
107
|
+
assistant: 'claude',
|
|
108
|
+
branch: 'main',
|
|
109
|
+
diff: '',
|
|
110
|
+
files: [],
|
|
111
|
+
projectId: 'p1',
|
|
112
|
+
recentCommits: '',
|
|
113
|
+
repoRoot: '/tmp/repo',
|
|
114
|
+
}
|
|
115
|
+
|
|
69
116
|
/** A component that renders nothing: a test asserts on registrations, not pixels. */
|
|
70
117
|
const nothing = (): null => null
|
|
71
118
|
|
|
72
119
|
export function createTestUiSurface(effects: EffectStack): TestUiSurface {
|
|
73
120
|
const registrations: TestUiRegistrations = {
|
|
74
121
|
actions: [],
|
|
122
|
+
commandPanes: [],
|
|
123
|
+
commitMessageProvider: false,
|
|
75
124
|
effects: [],
|
|
76
125
|
modals: [],
|
|
126
|
+
notificationSink: false,
|
|
77
127
|
panes: [],
|
|
78
128
|
settingsSections: 0,
|
|
79
129
|
statsPages: [],
|
|
@@ -83,7 +133,12 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
|
|
|
83
133
|
widgets: [],
|
|
84
134
|
}
|
|
85
135
|
const toasts: TestUiSurface['toasts'] = []
|
|
136
|
+
const notifications: TestUiSurface['notifications'] = []
|
|
86
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
|
|
87
142
|
|
|
88
143
|
let state: PluginUiState = EMPTY_STATE
|
|
89
144
|
const stateListeners = new Set<(next: PluginUiState) => void>()
|
|
@@ -92,6 +147,13 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
|
|
|
92
147
|
let theme: PluginThemeSnapshot = DEFAULT_THEME
|
|
93
148
|
const themeListeners = new Set<(snapshot: PluginThemeSnapshot) => void>()
|
|
94
149
|
let slice: unknown
|
|
150
|
+
let git: PluginGitStatus = EMPTY_GIT
|
|
151
|
+
let commitProvider:
|
|
152
|
+
| ((
|
|
153
|
+
request: PluginCommitMessageRequest,
|
|
154
|
+
signal: AbortSignal
|
|
155
|
+
) => Promise<PluginCommitMessage | null> | PluginCommitMessage | null)
|
|
156
|
+
| null = null
|
|
95
157
|
|
|
96
158
|
/** Records a registration and hands back a disposer that unrecords it. */
|
|
97
159
|
function record(into: string[], id: string): Disposer {
|
|
@@ -114,6 +176,32 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
|
|
|
114
176
|
}
|
|
115
177
|
|
|
116
178
|
const ui: PluginUiApi = {
|
|
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
|
+
},
|
|
187
|
+
provideCommitMessage: (provider) => {
|
|
188
|
+
commitProvider = provider
|
|
189
|
+
registrations.commitMessageProvider = true
|
|
190
|
+
const dispose = (): void => {
|
|
191
|
+
if (commitProvider === provider) commitProvider = null
|
|
192
|
+
registrations.commitMessageProvider = false
|
|
193
|
+
}
|
|
194
|
+
effects.add(dispose)
|
|
195
|
+
return dispose
|
|
196
|
+
},
|
|
197
|
+
stage: async (paths) => {
|
|
198
|
+
gitWrites.push(`stage:${paths.join(',')}`)
|
|
199
|
+
},
|
|
200
|
+
status: () => git,
|
|
201
|
+
unstage: async (paths) => {
|
|
202
|
+
gitWrites.push(`unstage:${paths.join(',')}`)
|
|
203
|
+
},
|
|
204
|
+
},
|
|
117
205
|
kit: {
|
|
118
206
|
KeyHint: nothing,
|
|
119
207
|
List: nothing,
|
|
@@ -121,15 +209,42 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
|
|
|
121
209
|
Row: nothing,
|
|
122
210
|
useTheme: () => theme.colors,
|
|
123
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
|
+
},
|
|
124
221
|
modals: {
|
|
125
222
|
close: () => opened.push('modal:close'),
|
|
126
223
|
open: (id) => opened.push(`modal:${id}`),
|
|
127
224
|
register: (modal) => record(registrations.modals, modal.id),
|
|
128
225
|
},
|
|
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
|
+
},
|
|
129
242
|
panes: {
|
|
130
243
|
close: (id) => opened.push(`pane:close:${id}`),
|
|
131
244
|
open: (id) => opened.push(`pane:${id}`),
|
|
245
|
+
openCommandPanes: () => [],
|
|
132
246
|
register: (pane) => record(registrations.panes, pane.id),
|
|
247
|
+
registerCommand: (pane) => record(registrations.commandPanes, pane.id),
|
|
133
248
|
},
|
|
134
249
|
settings: {
|
|
135
250
|
get: (id) => settings.get(id),
|
|
@@ -187,6 +302,17 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
|
|
|
187
302
|
register: (verb) => record(registrations.actions, verb),
|
|
188
303
|
}
|
|
189
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
|
+
|
|
190
316
|
const store: PluginStoreApi = {
|
|
191
317
|
dispatch: () => {
|
|
192
318
|
/* a slice reducer is the plugin's; a bare context has none to run */
|
|
@@ -207,8 +333,26 @@ export function createTestUiSurface(effects: EffectStack): TestUiSurface {
|
|
|
207
333
|
|
|
208
334
|
return {
|
|
209
335
|
actions,
|
|
336
|
+
askForCommitMessage: async (request) => {
|
|
337
|
+
if (commitProvider === null) throw new Error('the plugin registered no commit provider')
|
|
338
|
+
return commitProvider({ ...EMPTY_REQUEST, ...request }, new AbortController().signal)
|
|
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,
|
|
210
348
|
opened,
|
|
211
349
|
registrations,
|
|
350
|
+
setGitDiff: (path, diff) => {
|
|
351
|
+
diffs.set(path, diff)
|
|
352
|
+
},
|
|
353
|
+
setGitStatus: (status) => {
|
|
354
|
+
git = status
|
|
355
|
+
},
|
|
212
356
|
setSetting: (id, value) => {
|
|
213
357
|
settings.set(id, value)
|
|
214
358
|
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/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
|
@@ -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 {
|
|
@@ -147,6 +159,32 @@ export interface PluginPane {
|
|
|
147
159
|
render: () => PluginNode
|
|
148
160
|
}
|
|
149
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
|
+
|
|
150
188
|
export interface PluginPanesApi {
|
|
151
189
|
/**
|
|
152
190
|
* Declares a pane: a leaf in the layout tree that draws something other than
|
|
@@ -157,6 +195,12 @@ export interface PluginPanesApi {
|
|
|
157
195
|
* Registering does not put it on screen; `open` does.
|
|
158
196
|
*/
|
|
159
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
|
|
160
204
|
/**
|
|
161
205
|
* Splits the pane the user is in and puts this one beside it. Takes the
|
|
162
206
|
* unqualified id. Opening one that is already open does nothing: the id is
|
|
@@ -169,8 +213,112 @@ export interface PluginPanesApi {
|
|
|
169
213
|
* in its own mode — `plugin.pane.<pluginId>.<id>`.
|
|
170
214
|
*/
|
|
171
215
|
open: (id: string, direction?: 'horizontal' | 'vertical') => void
|
|
172
|
-
/**
|
|
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
|
+
*/
|
|
173
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
|
|
174
322
|
}
|
|
175
323
|
|
|
176
324
|
export interface PluginStatusBarSegment {
|
|
@@ -269,7 +417,108 @@ export interface PluginKit {
|
|
|
269
417
|
KeyHint: PluginComponent<{ hints: readonly { keys: string; label: string }[] }>
|
|
270
418
|
}
|
|
271
419
|
|
|
420
|
+
/** One changed file, as the git panel sees it. */
|
|
421
|
+
export interface PluginGitFile {
|
|
422
|
+
path: string
|
|
423
|
+
/** Porcelain-ish status: `modified`, `new`, `deleted`, `renamed`, … */
|
|
424
|
+
status: string
|
|
425
|
+
/** Which half of the panel it sits in — staged or not. */
|
|
426
|
+
section: string
|
|
427
|
+
added: number | null
|
|
428
|
+
removed: number | null
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The working tree as the panel last saw it. A snapshot of aimux's poll, not a
|
|
433
|
+
* fresh `git status`: it is what the user is looking at, which is the point,
|
|
434
|
+
* and it is empty until a project with a path is open.
|
|
435
|
+
*/
|
|
436
|
+
export interface PluginGitStatus {
|
|
437
|
+
branch: string | null
|
|
438
|
+
ahead: number
|
|
439
|
+
behind: number
|
|
440
|
+
files: PluginGitFile[]
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Everything aimux gathered before asking for a commit message. */
|
|
444
|
+
export interface PluginCommitMessageRequest {
|
|
445
|
+
projectId: string
|
|
446
|
+
repoRoot: string
|
|
447
|
+
branch: string
|
|
448
|
+
/**
|
|
449
|
+
* The assistant in the tab the commit is being written for — `claude`,
|
|
450
|
+
* `codex`, … A provider that calls a model headlessly needs to know which
|
|
451
|
+
* one the user is already working with.
|
|
452
|
+
*/
|
|
453
|
+
assistant: string
|
|
454
|
+
/** Staged diff when anything is staged, the working-tree diff otherwise. */
|
|
455
|
+
diff: string
|
|
456
|
+
/** `git log --oneline`, for house style rather than for content. */
|
|
457
|
+
recentCommits: string
|
|
458
|
+
files: PluginGitFile[]
|
|
459
|
+
/** The tail of what the agent in the tab was doing, when there is one. */
|
|
460
|
+
sessionTail?: string
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export interface PluginCommitMessage {
|
|
464
|
+
title: string
|
|
465
|
+
body?: string
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export interface PluginGitCommitInput {
|
|
469
|
+
title: string
|
|
470
|
+
body?: string
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export interface PluginGitApi {
|
|
474
|
+
/** The panel's last refresh. */
|
|
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>
|
|
495
|
+
/**
|
|
496
|
+
* Answers "what should this commit say", replacing the headless model call
|
|
497
|
+
* aimux would otherwise make. Return `null` to decline this one — aimux falls
|
|
498
|
+
* back to its own suggestion rather than leaving the user with nothing.
|
|
499
|
+
*
|
|
500
|
+
* One plugin at a time: the second to ask is refused, and told so in its log,
|
|
501
|
+
* because a message that depends on load order is worse than no message.
|
|
502
|
+
*/
|
|
503
|
+
provideCommitMessage: (
|
|
504
|
+
provider: (
|
|
505
|
+
request: PluginCommitMessageRequest,
|
|
506
|
+
signal: AbortSignal
|
|
507
|
+
) => Promise<PluginCommitMessage | null> | PluginCommitMessage | null
|
|
508
|
+
) => Disposer
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** The screens a plugin may send the user to. */
|
|
512
|
+
export type PluginScreen = 'git' | 'stats' | 'settings' | 'terminal'
|
|
513
|
+
|
|
272
514
|
export interface PluginUiApi {
|
|
515
|
+
/**
|
|
516
|
+
* Opens one of aimux's own screens, or `terminal` to leave the one you are
|
|
517
|
+
* on. Deliberately four names and not an id space: exposing modal or view ids
|
|
518
|
+
* would make them API, and they are not.
|
|
519
|
+
*/
|
|
520
|
+
navigate: (screen: PluginScreen) => void
|
|
521
|
+
git: PluginGitApi
|
|
273
522
|
widgets: PluginWidgetsApi
|
|
274
523
|
views: PluginViewsApi
|
|
275
524
|
modals: PluginModalsApi
|
|
@@ -277,6 +526,8 @@ export interface PluginUiApi {
|
|
|
277
526
|
themes: PluginThemesApi
|
|
278
527
|
toast: PluginToastApi
|
|
279
528
|
panes: PluginPanesApi
|
|
529
|
+
layout: PluginLayoutApi
|
|
530
|
+
notifications: PluginNotificationsApi
|
|
280
531
|
state: PluginStateApi
|
|
281
532
|
stats: PluginStatsApi
|
|
282
533
|
statusBar: PluginStatusBarApi
|
|
@@ -287,13 +538,22 @@ export interface PluginUiApi {
|
|
|
287
538
|
* Keyboard actions and their effects. Registered by unqualified verb; a user's
|
|
288
539
|
* keymap binds the qualified name with `k.plugin('acme.thing.open')`.
|
|
289
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
|
+
|
|
290
547
|
export interface PluginActionsApi {
|
|
291
548
|
/**
|
|
292
549
|
* The action a key produces. Receives the mode context and returns a
|
|
293
550
|
* `KeyResult` — the same value a built-in binding produces — or null for
|
|
294
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.
|
|
295
555
|
*/
|
|
296
|
-
register: (verb: string, handler: (ctx: unknown) => unknown) => Disposer
|
|
556
|
+
register: (verb: string, handler: (ctx: unknown) => unknown, meta?: PluginActionMeta) => Disposer
|
|
297
557
|
/**
|
|
298
558
|
* The side of a binding that is allowed to do things: spawn a tab, write a
|
|
299
559
|
* file, call out. Reached from an action's `KeyResult` as a `plugin-effect`.
|