xtralab 0.11.1 → 0.12.1
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/README.md +38 -231
- package/lib/agentSessions.d.ts +6 -2
- package/lib/askAgent/editorSelection.d.ts +53 -0
- package/lib/askAgent/editorSelection.js +95 -0
- package/lib/askAgent/icons.d.ts +7 -0
- package/lib/askAgent/icons.js +15 -0
- package/lib/askAgent/index.d.ts +32 -0
- package/lib/askAgent/index.js +741 -0
- package/lib/askAgent/popup.d.ts +83 -0
- package/lib/askAgent/popup.js +221 -0
- package/lib/askAgent/prompt.d.ts +24 -0
- package/lib/askAgent/prompt.js +116 -0
- package/lib/askAgent/queue.d.ts +93 -0
- package/lib/askAgent/queue.js +207 -0
- package/lib/askAgent/queuePanel.d.ts +76 -0
- package/lib/askAgent/queuePanel.js +188 -0
- package/lib/askAgent/targetPicker.d.ts +58 -0
- package/lib/askAgent/targetPicker.js +74 -0
- package/lib/askAgent/tokens.d.ts +124 -0
- package/lib/askAgent/tokens.js +13 -0
- package/lib/commandBar/index.d.ts +12 -8
- package/lib/commandBar/index.js +58 -41
- package/lib/editorBreadcrumbs/index.js +9 -4
- package/lib/editorBreadcrumbs/widget.d.ts +7 -7
- package/lib/editorBreadcrumbs/widget.js +6 -8
- package/lib/fileBrowser/commands.d.ts +2 -8
- package/lib/fileBrowser/commands.js +39 -30
- package/lib/fileBrowser/contents.d.ts +8 -3
- package/lib/fileBrowser/fileBrowser.d.ts +2 -1
- package/lib/fileBrowser/widget.d.ts +5 -9
- package/lib/fileBrowser/widget.js +1 -1
- package/lib/fileTypeIcons/index.js +5 -2
- package/lib/git/api.d.ts +0 -26
- package/lib/git/api.js +2 -44
- package/lib/git/askRequest.d.ts +23 -0
- package/lib/git/askRequest.js +90 -0
- package/lib/git/commands.d.ts +14 -2
- package/lib/git/commands.js +16 -13
- package/lib/git/diffModel.d.ts +1 -9
- package/lib/git/diffModel.js +1 -1
- package/lib/git/diffProvider.js +8 -3
- package/lib/git/diffSurface.d.ts +19 -2
- package/lib/git/diffSurface.js +64 -18
- package/lib/git/diffTheme.d.ts +0 -4
- package/lib/git/diffTheme.js +1 -1
- package/lib/git/diffWidget.d.ts +9 -0
- package/lib/git/diffWidget.js +38 -10
- package/lib/git/imageDiff.d.ts +17 -4
- package/lib/git/imageDiff.js +90 -30
- package/lib/git/index.js +7 -2
- package/lib/git/notebookDiff.d.ts +10 -67
- package/lib/git/notebookDiff.js +62 -69
- package/lib/git/tokens.d.ts +5 -2
- package/lib/highlight/index.js +12 -8
- package/lib/index.js +2 -0
- package/lib/launcher/agents.js +3 -4
- package/lib/launcher/commands.d.ts +6 -2
- package/lib/launcher/dashboard.d.ts +8 -1
- package/lib/launcher/dashboard.js +39 -33
- package/lib/launcher/editorRegistry.js +5 -3
- package/lib/launcher/editors.d.ts +15 -5
- package/lib/launcher/icons.d.ts +0 -37
- package/lib/launcher/icons.js +11 -12
- package/lib/launcher/index.js +2 -1
- package/lib/launcher/invocation.d.ts +0 -15
- package/lib/launcher/invocation.js +1 -1
- package/lib/launcher/schemaDefaults.d.ts +3 -1
- package/lib/launcher/schemaDefaults.js +3 -1
- package/lib/mimeData.d.ts +7 -0
- package/lib/mimeData.js +16 -0
- package/lib/omnibox/index.d.ts +4 -0
- package/lib/omnibox/index.js +43 -2
- package/lib/omnibox/model.d.ts +43 -14
- package/lib/omnibox/model.js +117 -14
- package/lib/omnibox/recents.d.ts +65 -0
- package/lib/omnibox/recents.js +109 -0
- package/lib/omnibox/tokens.d.ts +6 -2
- package/lib/omnibox/widget.d.ts +23 -6
- package/lib/omnibox/widget.js +26 -7
- package/lib/showOutput/index.js +15 -18
- package/lib/terminals/agentTerminals.d.ts +63 -0
- package/lib/terminals/agentTerminals.js +157 -0
- package/lib/terminals/index.d.ts +7 -1
- package/lib/terminals/index.js +50 -30
- package/lib/terminals/model.d.ts +11 -2
- package/lib/terminals/model.js +27 -8
- package/lib/terminals/tokens.d.ts +66 -0
- package/lib/terminals/tokens.js +7 -0
- package/lib/terminals/widget.d.ts +3 -6
- package/lib/terminals/widget.js +1 -1
- package/lib/topBar/index.js +3 -3
- package/lib/walkthrough/index.js +14 -3
- package/lib/walkthrough/panel.d.ts +12 -4
- package/lib/walkthrough/panel.js +9 -3
- package/package.json +49 -51
- package/schema/command-bar.json +10 -0
- package/schema/omnibox.json +15 -0
- package/src/agentSessions.ts +6 -2
- package/src/askAgent/editorSelection.ts +138 -0
- package/src/askAgent/icons.ts +16 -0
- package/src/askAgent/index.ts +915 -0
- package/src/askAgent/popup.tsx +407 -0
- package/src/askAgent/prompt.ts +144 -0
- package/src/askAgent/queue.ts +269 -0
- package/src/askAgent/queuePanel.tsx +418 -0
- package/src/askAgent/targetPicker.tsx +209 -0
- package/src/askAgent/tokens.ts +139 -0
- package/src/commandBar/index.ts +75 -49
- package/src/editorBreadcrumbs/index.ts +14 -4
- package/src/editorBreadcrumbs/widget.ts +11 -9
- package/src/fileBrowser/commands.ts +45 -31
- package/src/fileBrowser/contents.ts +7 -3
- package/src/fileBrowser/fileBrowser.tsx +1 -1
- package/src/fileBrowser/widget.tsx +5 -3
- package/src/fileTypeIcons/index.ts +6 -3
- package/src/git/api.ts +2 -61
- package/src/git/askRequest.ts +113 -0
- package/src/git/commands.ts +41 -14
- package/src/git/diffModel.ts +1 -1
- package/src/git/diffProvider.tsx +11 -3
- package/src/git/diffSurface.tsx +114 -22
- package/src/git/diffTheme.ts +1 -1
- package/src/git/diffWidget.tsx +54 -7
- package/src/git/imageDiff.tsx +169 -58
- package/src/git/index.ts +10 -2
- package/src/git/notebookDiff.tsx +91 -57
- package/src/git/tokens.ts +4 -2
- package/src/highlight/index.ts +12 -8
- package/src/index.ts +2 -0
- package/src/launcher/agents.ts +3 -4
- package/src/launcher/commands.ts +6 -2
- package/src/launcher/dashboard.tsx +86 -38
- package/src/launcher/editorRegistry.ts +10 -4
- package/src/launcher/editors.ts +15 -5
- package/src/launcher/icons.ts +11 -12
- package/src/launcher/index.ts +2 -1
- package/src/launcher/invocation.ts +1 -1
- package/src/launcher/schemaDefaults.ts +3 -1
- package/src/mimeData.ts +20 -0
- package/src/omnibox/index.ts +48 -1
- package/src/omnibox/model.ts +173 -33
- package/src/omnibox/recents.ts +155 -0
- package/src/omnibox/tokens.ts +6 -2
- package/src/omnibox/widget.tsx +54 -13
- package/src/showOutput/index.ts +20 -23
- package/src/terminals/agentTerminals.ts +238 -0
- package/src/terminals/index.ts +54 -30
- package/src/terminals/model.ts +28 -7
- package/src/terminals/tokens.ts +77 -0
- package/src/terminals/widget.tsx +4 -2
- package/src/topBar/index.ts +12 -6
- package/src/walkthrough/index.ts +19 -4
- package/src/walkthrough/panel.ts +15 -5
- package/style/askAgent.css +544 -0
- package/style/commandBar.css +12 -33
- package/style/git.css +72 -31
- package/style/index.css +1 -0
- package/style/index.js +2 -0
- package/style/topBar.css +4 -4
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ILabShell,
|
|
3
|
+
ILayoutRestorer,
|
|
4
|
+
JupyterFrontEnd,
|
|
5
|
+
JupyterFrontEndPlugin
|
|
6
|
+
} from '@jupyterlab/application';
|
|
7
|
+
import { ICommandPalette, Notification } from '@jupyterlab/apputils';
|
|
8
|
+
import { IStateDB } from '@jupyterlab/statedb';
|
|
9
|
+
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
10
|
+
import { terminalIcon } from '@jupyterlab/ui-components';
|
|
11
|
+
import { CommandRegistry } from '@lumino/commands';
|
|
12
|
+
import type { ReadonlyPartialJSONValue } from '@lumino/coreutils';
|
|
13
|
+
import { Debouncer } from '@lumino/polling';
|
|
14
|
+
import { Widget } from '@lumino/widgets';
|
|
15
|
+
|
|
16
|
+
import type { IAgent } from '../launcher/agents';
|
|
17
|
+
import { agentCommandId, IAgentRegistry } from '../launcher/tokens';
|
|
18
|
+
import { IAgentTerminals } from '../terminals/tokens';
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
domSelectionInView,
|
|
22
|
+
editorAskRequest,
|
|
23
|
+
resolveEditorTarget
|
|
24
|
+
} from './editorSelection';
|
|
25
|
+
import { askAgentIcon } from './icons';
|
|
26
|
+
import { AskAgentPopup } from './popup';
|
|
27
|
+
import { buildBatchPrompt, buildPrompt, serverPath } from './prompt';
|
|
28
|
+
import {
|
|
29
|
+
deserializeQueuedPrompts,
|
|
30
|
+
IQueuedPrompt,
|
|
31
|
+
PromptQueue,
|
|
32
|
+
serializeQueuedPrompts
|
|
33
|
+
} from './queue';
|
|
34
|
+
import { AskAgentQueuePanel } from './queuePanel';
|
|
35
|
+
import type { ISessionTarget } from './targetPicker';
|
|
36
|
+
import {
|
|
37
|
+
ASK_AGENT_COMMAND,
|
|
38
|
+
AskAgentTarget,
|
|
39
|
+
IAskAgent,
|
|
40
|
+
IAskAgentContext,
|
|
41
|
+
IAskAgentRequest
|
|
42
|
+
} from './tokens';
|
|
43
|
+
|
|
44
|
+
const PLUGIN_ID = 'xtralab:ask-agent';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Command id that reveals the queued-prompts panel.
|
|
48
|
+
*/
|
|
49
|
+
const QUEUE_PANEL_COMMAND = 'xtralab:ask-agent-queue';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Widget id of the queued-prompts side panel.
|
|
53
|
+
*/
|
|
54
|
+
const QUEUE_PANEL_ID = 'xtralab-ask-agent-queue';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* State-database key remembering the last agent picked in the popup.
|
|
58
|
+
*/
|
|
59
|
+
const LAST_AGENT_STATE_KEY = 'xtralab:ask-agent:agent';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* State-database key remembering the last target picked in the popup.
|
|
63
|
+
*/
|
|
64
|
+
const LAST_TARGET_STATE_KEY = 'xtralab:ask-agent:target';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* State-database key holding the queued prompts across page loads.
|
|
68
|
+
*/
|
|
69
|
+
const QUEUE_STATE_KEY = 'xtralab:ask-agent:queue';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Debounce for mirroring the queue into the state database, so typing in
|
|
73
|
+
* the panel's textareas does not issue a write per keystroke.
|
|
74
|
+
*/
|
|
75
|
+
const QUEUE_PERSIST_MS = 500;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Stored target value for "start the agent in a new terminal".
|
|
79
|
+
*/
|
|
80
|
+
const NEW_TARGET_VALUE = 'new';
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Stored-target prefix for an existing session; the session name follows.
|
|
84
|
+
*/
|
|
85
|
+
const SESSION_TARGET_PREFIX = 'session:';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Delay between a selection change and showing the pill, so the affordance
|
|
89
|
+
* appears once the selection settles rather than flickering along a drag.
|
|
90
|
+
*/
|
|
91
|
+
const SELECTION_SETTLE_MS = 250;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Keystroke that opens the popup in a file editor. `Accel I` would be the
|
|
95
|
+
* familiar "inline chat" chord but CodeMirror's default keymap owns `Mod-i`
|
|
96
|
+
* (selectParentSyntax) inside editors and prevents-default before Lumino
|
|
97
|
+
* sees it; `Accel .` — the "quick fix" chord elsewhere — is free in the
|
|
98
|
+
* editor, the browser and JupyterLab.
|
|
99
|
+
*/
|
|
100
|
+
const ASK_AGENT_KEYS = 'Accel .';
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Gap between the pill and the selection anchor, in pixels.
|
|
104
|
+
*/
|
|
105
|
+
const PILL_GAP = 6;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Minimum distance kept between the pill and the viewport edges.
|
|
109
|
+
*/
|
|
110
|
+
const PILL_VIEWPORT_MARGIN = 8;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Select code in a file editor (or pick diff lines) and prompt a coding
|
|
114
|
+
* agent about it.
|
|
115
|
+
*
|
|
116
|
+
* The plugin watches the document selection: a non-empty selection inside a
|
|
117
|
+
* CodeMirror file editor or notebook cell grows a small floating "Ask agent"
|
|
118
|
+
* pill next to the selection. Clicking it (or running `xtralab:ask-agent`,
|
|
119
|
+
* bound to Accel+. in editors and notebooks) opens a popup where the user
|
|
120
|
+
* types an instruction, picks one of the launcher's agents and picks where
|
|
121
|
+
* the prompt goes; submitting either starts that agent in a fresh terminal
|
|
122
|
+
* via `xtralab:start-agent:<id>`, or pastes the prompt into an agent already
|
|
123
|
+
* running in one of the open terminal sessions (via `IAgentTerminals`; the
|
|
124
|
+
* agent CLIs queue prompts that arrive while they are busy). Sends to a
|
|
125
|
+
* running agent stay in the background — focus never leaves the editor, and
|
|
126
|
+
* a success toast offers to open the terminal. Either way the prompt embeds
|
|
127
|
+
* the file path, cell index for notebooks, line range and selected snippet.
|
|
128
|
+
*
|
|
129
|
+
* The popup's Queue button (or Accel+Enter) defers the send instead: the
|
|
130
|
+
* comment lands in a persistent queue, stamped with the popup's selected
|
|
131
|
+
* destination — every queued prompt is independent and may aim at a
|
|
132
|
+
* different agent or session. A right-sidebar panel reviews the queue
|
|
133
|
+
* (edit, retarget, remove) and flushes it in one go, combining prompts that
|
|
134
|
+
* share a destination into a single numbered message. The queue survives
|
|
135
|
+
* page reloads.
|
|
136
|
+
*
|
|
137
|
+
* The same popup is provided on the `IAskAgent` token so the git diff
|
|
138
|
+
* viewers can open it for a selected diff line range.
|
|
139
|
+
*/
|
|
140
|
+
const plugin: JupyterFrontEndPlugin<IAskAgent> = {
|
|
141
|
+
id: PLUGIN_ID,
|
|
142
|
+
description:
|
|
143
|
+
'Prompt a coding agent about the selected code from editors, notebook cells and git diffs.',
|
|
144
|
+
autoStart: true,
|
|
145
|
+
provides: IAskAgent,
|
|
146
|
+
optional: [
|
|
147
|
+
IAgentRegistry,
|
|
148
|
+
ICommandPalette,
|
|
149
|
+
ITranslator,
|
|
150
|
+
IAgentTerminals,
|
|
151
|
+
ILabShell,
|
|
152
|
+
ILayoutRestorer,
|
|
153
|
+
IStateDB
|
|
154
|
+
],
|
|
155
|
+
activate: (
|
|
156
|
+
app: JupyterFrontEnd,
|
|
157
|
+
agentRegistry: IAgentRegistry | null,
|
|
158
|
+
palette: ICommandPalette | null,
|
|
159
|
+
translator: ITranslator | null,
|
|
160
|
+
agentTerminals: IAgentTerminals | null,
|
|
161
|
+
labShell: ILabShell | null,
|
|
162
|
+
restorer: ILayoutRestorer | null,
|
|
163
|
+
state: IStateDB | null
|
|
164
|
+
): IAskAgent => {
|
|
165
|
+
const trans = (translator ?? nullTranslator).load('jupyterlab');
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Best-effort write to the state database; a failure only loses the
|
|
169
|
+
* convenience of restoring this value on the next page load.
|
|
170
|
+
*/
|
|
171
|
+
const saveState = (key: string, value: ReadonlyPartialJSONValue): void => {
|
|
172
|
+
state?.save(key, value).catch(error => {
|
|
173
|
+
console.error('xtralab: failed to persist ask-agent state', error);
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// The last-used picks, restored asynchronously from the state database
|
|
178
|
+
// and kept in memory so the popup can read them synchronously.
|
|
179
|
+
let lastAgentId: string | null = null;
|
|
180
|
+
let lastTarget: string | null = null;
|
|
181
|
+
if (state !== null) {
|
|
182
|
+
void Promise.all([
|
|
183
|
+
state.fetch(LAST_AGENT_STATE_KEY),
|
|
184
|
+
state.fetch(LAST_TARGET_STATE_KEY)
|
|
185
|
+
])
|
|
186
|
+
.then(([agentId, target]) => {
|
|
187
|
+
if (typeof agentId === 'string') {
|
|
188
|
+
lastAgentId = agentId;
|
|
189
|
+
}
|
|
190
|
+
if (typeof target === 'string') {
|
|
191
|
+
lastTarget = target;
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
.catch(() => {
|
|
195
|
+
// Missing or unreadable state just means default picks.
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rememberAgent = (agentId: string): void => {
|
|
200
|
+
lastAgentId = agentId;
|
|
201
|
+
saveState(LAST_AGENT_STATE_KEY, agentId);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const rememberTarget = (value: string): void => {
|
|
205
|
+
lastTarget = value;
|
|
206
|
+
saveState(LAST_TARGET_STATE_KEY, value);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Agents that can receive an initial prompt on their command line.
|
|
211
|
+
*/
|
|
212
|
+
const promptAgents = (): IAgent[] =>
|
|
213
|
+
(agentRegistry?.agents ?? []).filter(
|
|
214
|
+
agent => agent.promptArgs !== undefined
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Running agent terminals offered as prompt targets, each resolved to
|
|
219
|
+
* its agent's icon the same way the terminals panel badges its rows
|
|
220
|
+
* (matching either the configured command or the canonical id, falling
|
|
221
|
+
* back to the plain terminal icon when the agent has since been removed
|
|
222
|
+
* from the settings). Unlike {@link promptAgents} this does not require
|
|
223
|
+
* `promptArgs`: pasting into a running TUI needs no command-line recipe.
|
|
224
|
+
*/
|
|
225
|
+
const sessionTargets = (): ISessionTarget[] =>
|
|
226
|
+
(agentTerminals?.sessions() ?? []).map(session => ({
|
|
227
|
+
name: session.name,
|
|
228
|
+
label: session.label,
|
|
229
|
+
activity: session.activity,
|
|
230
|
+
icon:
|
|
231
|
+
(agentRegistry?.agents ?? []).find(
|
|
232
|
+
agent =>
|
|
233
|
+
agent.command === session.command || agent.id === session.command
|
|
234
|
+
)?.icon ?? terminalIcon
|
|
235
|
+
}));
|
|
236
|
+
|
|
237
|
+
let popup: AskAgentPopup | null = null;
|
|
238
|
+
let pill: HTMLButtonElement | null = null;
|
|
239
|
+
let pillRequest: IAskAgentRequest | null = null;
|
|
240
|
+
let pointerIsDown = false;
|
|
241
|
+
|
|
242
|
+
const closePopup = (): void => {
|
|
243
|
+
const widget = popup;
|
|
244
|
+
popup = null;
|
|
245
|
+
widget?.dispose();
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const hidePill = (): void => {
|
|
249
|
+
pillRequest = null;
|
|
250
|
+
if (pill !== null) {
|
|
251
|
+
pill.style.display = 'none';
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Open the named terminal's tab (the success toast's call to action).
|
|
257
|
+
*/
|
|
258
|
+
const openTerminal = (name: string): void => {
|
|
259
|
+
app.commands.execute('terminal:open', { name }).catch(reason => {
|
|
260
|
+
console.error('xtralab: failed to open the terminal', reason);
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Paste `prompt` into the running agent in session `name`. Delivery is
|
|
266
|
+
* background — focus stays where it is — so `successMessage` is toasted
|
|
267
|
+
* with an "Open" action instead. Failures are toasted too; the promise
|
|
268
|
+
* still rejects so callers can chain their own cleanup to success only.
|
|
269
|
+
*/
|
|
270
|
+
const deliverToSession = async (
|
|
271
|
+
name: string,
|
|
272
|
+
prompt: string,
|
|
273
|
+
successMessage: string
|
|
274
|
+
): Promise<void> => {
|
|
275
|
+
if (agentTerminals === null) {
|
|
276
|
+
// Session targets are only offered when the terminals plugin is
|
|
277
|
+
// present; fail loudly rather than resolving as a delivered send.
|
|
278
|
+
throw new Error('xtralab: agent terminals are unavailable');
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
await agentTerminals.sendPrompt(name, prompt);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
console.error(
|
|
284
|
+
'xtralab: failed to send the prompt to the terminal',
|
|
285
|
+
error
|
|
286
|
+
);
|
|
287
|
+
const detail = error instanceof Error ? error.message : '';
|
|
288
|
+
Notification.error(
|
|
289
|
+
detail.length > 0
|
|
290
|
+
? trans.__('Failed to send the prompt: %1', detail)
|
|
291
|
+
: trans.__('Failed to send the prompt — see the browser console.'),
|
|
292
|
+
{ autoClose: 5000 }
|
|
293
|
+
);
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
Notification.success(successMessage, {
|
|
297
|
+
autoClose: 3000,
|
|
298
|
+
actions: [
|
|
299
|
+
{ label: trans.__('Open'), callback: () => openTerminal(name) }
|
|
300
|
+
]
|
|
301
|
+
});
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Start `agentId` in a fresh terminal with `prompt` on its command
|
|
306
|
+
* line. Failures are toasted; the promise still rejects so callers can
|
|
307
|
+
* chain their own cleanup to success only.
|
|
308
|
+
*/
|
|
309
|
+
const startAgentTerminal = async (
|
|
310
|
+
agentId: string,
|
|
311
|
+
prompt: string,
|
|
312
|
+
cwd?: string
|
|
313
|
+
): Promise<void> => {
|
|
314
|
+
const args: { prompt: string; cwd?: string } = { prompt };
|
|
315
|
+
if (cwd !== undefined) {
|
|
316
|
+
args.cwd = cwd;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
await app.commands.execute(agentCommandId(agentId), args);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
console.error('xtralab: failed to start the agent', error);
|
|
322
|
+
Notification.error(
|
|
323
|
+
trans.__('Failed to start the agent — see the browser console.'),
|
|
324
|
+
{ autoClose: 5000 }
|
|
325
|
+
);
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// The queue feature needs a side area to dock the review panel into, so
|
|
331
|
+
// everything below stays off (and the popup hides its Queue button)
|
|
332
|
+
// without the full Lab shell.
|
|
333
|
+
let queue: PromptQueue | null = null;
|
|
334
|
+
let queuePrompt:
|
|
335
|
+
| ((
|
|
336
|
+
request: IAskAgentRequest,
|
|
337
|
+
target: AskAgentTarget,
|
|
338
|
+
instruction: string
|
|
339
|
+
) => void)
|
|
340
|
+
| null = null;
|
|
341
|
+
|
|
342
|
+
if (labShell !== null) {
|
|
343
|
+
const promptQueue = new PromptQueue();
|
|
344
|
+
queue = promptQueue;
|
|
345
|
+
let panel: AskAgentQueuePanel | null = null;
|
|
346
|
+
let flushing = false;
|
|
347
|
+
|
|
348
|
+
// Mirror every queue change into the state database, debounced so
|
|
349
|
+
// typing in the panel's textareas does not write on each keystroke.
|
|
350
|
+
const persistQueue = new Debouncer(() => {
|
|
351
|
+
saveState(QUEUE_STATE_KEY, serializeQueuedPrompts(promptQueue.items));
|
|
352
|
+
}, QUEUE_PERSIST_MS);
|
|
353
|
+
promptQueue.changed.connect(() => {
|
|
354
|
+
void persistQueue.invoke();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The prompt (and working directory) for a batch sent to a new
|
|
359
|
+
* terminal. When every item agrees on a repository root the terminal
|
|
360
|
+
* starts there and the item paths stay relative to it; a mixed batch
|
|
361
|
+
* starts at the server root instead, with every locator rewritten to
|
|
362
|
+
* a server-relative path so none of them depends on the terminal's
|
|
363
|
+
* cwd.
|
|
364
|
+
*/
|
|
365
|
+
const newTerminalBatch = (
|
|
366
|
+
items: readonly IQueuedPrompt[]
|
|
367
|
+
): { prompt: string; cwd?: string } => {
|
|
368
|
+
const cwds = new Set(items.map(item => item.context.cwd ?? ''));
|
|
369
|
+
if (cwds.size === 1) {
|
|
370
|
+
const shared = [...cwds][0];
|
|
371
|
+
return {
|
|
372
|
+
prompt: buildBatchPrompt(items),
|
|
373
|
+
...(shared.length > 0 ? { cwd: shared } : {})
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const normalized = items.map(({ context, instruction }) => {
|
|
377
|
+
const flattened: IAskAgentContext = {
|
|
378
|
+
...context,
|
|
379
|
+
path: serverPath(context)
|
|
380
|
+
};
|
|
381
|
+
delete flattened.cwd;
|
|
382
|
+
return { context: flattened, instruction };
|
|
383
|
+
});
|
|
384
|
+
return { prompt: buildBatchPrompt(normalized) };
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Flush the queue: prompts are grouped by destination — every prompt
|
|
389
|
+
* is independent and may target a different agent or session — and
|
|
390
|
+
* each group goes out as one numbered message. A group's prompts
|
|
391
|
+
* leave the queue only when its delivery succeeds, so one dead
|
|
392
|
+
* session never loses the comments aimed elsewhere. One flush runs
|
|
393
|
+
* at a time — deliveries span server round trips, and a second click
|
|
394
|
+
* meanwhile would re-send every still-queued group.
|
|
395
|
+
*/
|
|
396
|
+
const sendQueue = (): void => {
|
|
397
|
+
if (flushing) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const groups = new Map<
|
|
401
|
+
string,
|
|
402
|
+
{ target: AskAgentTarget; items: IQueuedPrompt[] }
|
|
403
|
+
>();
|
|
404
|
+
for (const item of promptQueue.items) {
|
|
405
|
+
if (item.target === null) {
|
|
406
|
+
// The panel disables sending while a target is missing; skip
|
|
407
|
+
// defensively if a send races a target loss.
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const key =
|
|
411
|
+
item.target.kind === 'session'
|
|
412
|
+
? `session:${item.target.name}`
|
|
413
|
+
: `new:${item.target.agentId}`;
|
|
414
|
+
const group = groups.get(key) ?? { target: item.target, items: [] };
|
|
415
|
+
group.items.push(item);
|
|
416
|
+
groups.set(key, group);
|
|
417
|
+
}
|
|
418
|
+
const deliveries: Promise<void>[] = [];
|
|
419
|
+
for (const { target, items } of groups.values()) {
|
|
420
|
+
/**
|
|
421
|
+
* Remove the delivered prompts — except any edited or retargeted
|
|
422
|
+
* while the send was in flight: an edit makes a new object, so
|
|
423
|
+
* identity is the "unchanged" check, and the edited prompt stays
|
|
424
|
+
* queued for the next flush instead of being dropped unsent.
|
|
425
|
+
*/
|
|
426
|
+
const delivered = (): void => {
|
|
427
|
+
promptQueue.removeMany(
|
|
428
|
+
items
|
|
429
|
+
.filter(item => promptQueue.items.includes(item))
|
|
430
|
+
.map(item => item.id)
|
|
431
|
+
);
|
|
432
|
+
};
|
|
433
|
+
if (target.kind === 'session') {
|
|
434
|
+
const label =
|
|
435
|
+
sessionTargets().find(entry => entry.name === target.name)
|
|
436
|
+
?.label ?? target.name;
|
|
437
|
+
deliveries.push(
|
|
438
|
+
deliverToSession(
|
|
439
|
+
target.name,
|
|
440
|
+
buildBatchPrompt(items),
|
|
441
|
+
trans._n(
|
|
442
|
+
'Sent %1 prompt to %2',
|
|
443
|
+
'Sent %1 prompts to %2',
|
|
444
|
+
items.length,
|
|
445
|
+
label
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
.then(delivered)
|
|
449
|
+
.catch(() => {
|
|
450
|
+
// Reported by the helper; this group is kept for a retry.
|
|
451
|
+
})
|
|
452
|
+
);
|
|
453
|
+
} else {
|
|
454
|
+
const { prompt, cwd } = newTerminalBatch(items);
|
|
455
|
+
deliveries.push(
|
|
456
|
+
startAgentTerminal(target.agentId, prompt, cwd)
|
|
457
|
+
.then(delivered)
|
|
458
|
+
.catch(() => {
|
|
459
|
+
// Reported by the helper; this group is kept for a retry.
|
|
460
|
+
})
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (deliveries.length === 0) {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
flushing = true;
|
|
468
|
+
panel?.update();
|
|
469
|
+
// Every delivery caught its own failure above, so this resolves
|
|
470
|
+
// once all groups have settled either way.
|
|
471
|
+
void Promise.all(deliveries).then(() => {
|
|
472
|
+
flushing = false;
|
|
473
|
+
panel?.update();
|
|
474
|
+
});
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Empty the queue, with an undo toast (typed comments are work).
|
|
479
|
+
*/
|
|
480
|
+
const clearQueue = (): void => {
|
|
481
|
+
const removed = promptQueue.items;
|
|
482
|
+
if (removed.length === 0) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
promptQueue.clear();
|
|
486
|
+
Notification.info(
|
|
487
|
+
trans._n(
|
|
488
|
+
'Removed %1 queued prompt',
|
|
489
|
+
'Removed %1 queued prompts',
|
|
490
|
+
removed.length
|
|
491
|
+
),
|
|
492
|
+
{
|
|
493
|
+
autoClose: 5000,
|
|
494
|
+
actions: [
|
|
495
|
+
{
|
|
496
|
+
label: trans.__('Undo'),
|
|
497
|
+
// Prompts queued after the clear are newer; keep them,
|
|
498
|
+
// after the restored ones.
|
|
499
|
+
callback: () =>
|
|
500
|
+
promptQueue.reset([...removed, ...promptQueue.items])
|
|
501
|
+
}
|
|
502
|
+
]
|
|
503
|
+
}
|
|
504
|
+
);
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const ensurePanel = (): AskAgentQueuePanel => {
|
|
508
|
+
if (panel === null || panel.isDisposed) {
|
|
509
|
+
const created = new AskAgentQueuePanel({
|
|
510
|
+
queue: promptQueue,
|
|
511
|
+
commands: app.commands,
|
|
512
|
+
agents: promptAgents,
|
|
513
|
+
targets: sessionTargets,
|
|
514
|
+
sending: () => flushing,
|
|
515
|
+
trans,
|
|
516
|
+
onSend: sendQueue,
|
|
517
|
+
onClear: clearQueue
|
|
518
|
+
});
|
|
519
|
+
created.id = QUEUE_PANEL_ID;
|
|
520
|
+
created.title.icon = askAgentIcon;
|
|
521
|
+
created.title.label = trans.__('Prompt Queue');
|
|
522
|
+
created.title.caption = trans.__('Queued ask-agent prompts');
|
|
523
|
+
// Closable so it gets a close button if the user drags it out of
|
|
524
|
+
// the side area; closing disposes it, and it is recreated on
|
|
525
|
+
// demand (same arrangement as the walkthrough panel).
|
|
526
|
+
created.title.closable = true;
|
|
527
|
+
// The panel re-renders on queue changes itself; the chips also
|
|
528
|
+
// mirror the live agent list and terminal sessions.
|
|
529
|
+
agentRegistry?.changed.connect(created.update, created);
|
|
530
|
+
agentTerminals?.changed.connect(created.update, created);
|
|
531
|
+
labShell.add(created, 'right', { rank: 900 });
|
|
532
|
+
restorer?.add(created, QUEUE_PANEL_ID);
|
|
533
|
+
created.disposed.connect(() => {
|
|
534
|
+
if (panel === created) {
|
|
535
|
+
panel = null;
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
panel = created;
|
|
539
|
+
}
|
|
540
|
+
return panel;
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const revealPanel = (): void => {
|
|
544
|
+
labShell.activateById(ensurePanel().id);
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
queuePrompt = (request, target, instruction) => {
|
|
548
|
+
const wasEmpty = promptQueue.items.length === 0;
|
|
549
|
+
// Queueing is a target choice like sending: remember it for the
|
|
550
|
+
// next popup's preselection.
|
|
551
|
+
if (target.kind === 'session') {
|
|
552
|
+
rememberTarget(SESSION_TARGET_PREFIX + target.name);
|
|
553
|
+
} else {
|
|
554
|
+
rememberAgent(target.agentId);
|
|
555
|
+
rememberTarget(NEW_TARGET_VALUE);
|
|
556
|
+
}
|
|
557
|
+
promptQueue.add(request.context, instruction, target);
|
|
558
|
+
// Queueing is background like a session send: hand focus straight
|
|
559
|
+
// back to the widget the ask came from.
|
|
560
|
+
app.shell.currentWidget?.activate();
|
|
561
|
+
const queuePanel = ensurePanel();
|
|
562
|
+
if (queuePanel.isVisible) {
|
|
563
|
+
// The list visibly grows; no extra feedback needed.
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (wasEmpty) {
|
|
567
|
+
// First prompt of a batch: reveal the panel once, so the user
|
|
568
|
+
// sees where queued prompts accumulate. Later additions respect
|
|
569
|
+
// a deliberately closed sidebar and toast instead.
|
|
570
|
+
labShell.activateById(queuePanel.id);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
Notification.info(
|
|
574
|
+
trans._n(
|
|
575
|
+
'%1 prompt queued',
|
|
576
|
+
'%1 prompts queued',
|
|
577
|
+
promptQueue.items.length
|
|
578
|
+
),
|
|
579
|
+
{
|
|
580
|
+
autoClose: 3000,
|
|
581
|
+
actions: [{ label: trans.__('Review'), callback: revealPanel }]
|
|
582
|
+
}
|
|
583
|
+
);
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
app.commands.addCommand(QUEUE_PANEL_COMMAND, {
|
|
587
|
+
label: trans.__('Show Ask-Agent Prompt Queue'),
|
|
588
|
+
caption: trans.__(
|
|
589
|
+
'Review the queued ask-agent prompts and send them together'
|
|
590
|
+
),
|
|
591
|
+
icon: askAgentIcon,
|
|
592
|
+
execute: revealPanel
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// Restore the queue persisted by a previous page load. Anything
|
|
596
|
+
// queued before the fetch resolves is newer and stays, after the
|
|
597
|
+
// restored prompts. A restored queue should be discoverable without
|
|
598
|
+
// queueing anything new, so its sidebar tab is recreated (collapsed).
|
|
599
|
+
if (state !== null) {
|
|
600
|
+
void state
|
|
601
|
+
.fetch(QUEUE_STATE_KEY)
|
|
602
|
+
.then(value => {
|
|
603
|
+
const restored = deserializeQueuedPrompts(value);
|
|
604
|
+
if (restored.length > 0) {
|
|
605
|
+
promptQueue.reset([...restored, ...promptQueue.items]);
|
|
606
|
+
ensurePanel();
|
|
607
|
+
}
|
|
608
|
+
})
|
|
609
|
+
.catch(() => {
|
|
610
|
+
// Missing or unreadable state just means an empty queue.
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const open = (request: IAskAgentRequest): void => {
|
|
616
|
+
hidePill();
|
|
617
|
+
closePopup();
|
|
618
|
+
const agents = promptAgents();
|
|
619
|
+
const targets = sessionTargets();
|
|
620
|
+
// Preselect where the prompt goes. Queueing into a running agent is
|
|
621
|
+
// the common follow-up loop, so a live session wins by default — the
|
|
622
|
+
// last-used one when it is still running, otherwise the first — unless
|
|
623
|
+
// the user explicitly chose a new terminal last time (and one can
|
|
624
|
+
// still be started).
|
|
625
|
+
const storedTarget = lastTarget;
|
|
626
|
+
let initialTargetName: string | null =
|
|
627
|
+
targets.length > 0 ? targets[0].name : null;
|
|
628
|
+
if (storedTarget === NEW_TARGET_VALUE && agents.length > 0) {
|
|
629
|
+
initialTargetName = null;
|
|
630
|
+
} else if (
|
|
631
|
+
storedTarget !== null &&
|
|
632
|
+
storedTarget.startsWith(SESSION_TARGET_PREFIX)
|
|
633
|
+
) {
|
|
634
|
+
const name = storedTarget.slice(SESSION_TARGET_PREFIX.length);
|
|
635
|
+
if (targets.some(target => target.name === name)) {
|
|
636
|
+
initialTargetName = name;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const enqueue = queuePrompt;
|
|
640
|
+
const widget = new AskAgentPopup({
|
|
641
|
+
context: request.context,
|
|
642
|
+
anchor: request.anchor,
|
|
643
|
+
agents,
|
|
644
|
+
targets,
|
|
645
|
+
initialAgentId: lastAgentId,
|
|
646
|
+
initialTargetName,
|
|
647
|
+
queueCount: queue?.items.length,
|
|
648
|
+
trans,
|
|
649
|
+
onCancel: restoreFocus => {
|
|
650
|
+
closePopup();
|
|
651
|
+
if (restoreFocus) {
|
|
652
|
+
app.shell.currentWidget?.activate();
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
onQueue:
|
|
656
|
+
enqueue === null
|
|
657
|
+
? undefined
|
|
658
|
+
: (target, instruction) => {
|
|
659
|
+
closePopup();
|
|
660
|
+
enqueue(request, target, instruction);
|
|
661
|
+
},
|
|
662
|
+
onSubmit: (target, instruction) => {
|
|
663
|
+
closePopup();
|
|
664
|
+
const prompt = buildPrompt(request.context, instruction);
|
|
665
|
+
if (target.kind === 'session') {
|
|
666
|
+
rememberTarget(SESSION_TARGET_PREFIX + target.name);
|
|
667
|
+
// The send is background, so hand focus straight back to the
|
|
668
|
+
// widget the ask came from (the editor or diff under the popup);
|
|
669
|
+
// disposing the popup alone would drop it on `document.body`.
|
|
670
|
+
app.shell.currentWidget?.activate();
|
|
671
|
+
const label =
|
|
672
|
+
targets.find(entry => entry.name === target.name)?.label ??
|
|
673
|
+
target.name;
|
|
674
|
+
deliverToSession(
|
|
675
|
+
target.name,
|
|
676
|
+
prompt,
|
|
677
|
+
trans.__('Prompt sent to %1', label)
|
|
678
|
+
).catch(() => {
|
|
679
|
+
// Reported by the helper.
|
|
680
|
+
});
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
rememberAgent(target.agentId);
|
|
684
|
+
rememberTarget(NEW_TARGET_VALUE);
|
|
685
|
+
startAgentTerminal(target.agentId, prompt, request.context.cwd).catch(
|
|
686
|
+
() => {
|
|
687
|
+
// Reported by the helper.
|
|
688
|
+
}
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
popup = widget;
|
|
693
|
+
widget.disposed.connect(() => {
|
|
694
|
+
if (popup === widget) {
|
|
695
|
+
popup = null;
|
|
696
|
+
}
|
|
697
|
+
});
|
|
698
|
+
Widget.attach(widget, document.body);
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const ensurePill = (): HTMLButtonElement => {
|
|
702
|
+
if (pill !== null) {
|
|
703
|
+
return pill;
|
|
704
|
+
}
|
|
705
|
+
const button = document.createElement('button');
|
|
706
|
+
button.type = 'button';
|
|
707
|
+
button.className = 'jp-xtralab-AskAgent-pill jp-ThemedContainer';
|
|
708
|
+
const icon = document.createElement('span');
|
|
709
|
+
icon.className = 'jp-xtralab-AskAgent-pillIcon';
|
|
710
|
+
askAgentIcon.element({ container: icon });
|
|
711
|
+
const label = document.createElement('span');
|
|
712
|
+
label.textContent = trans.__('Ask agent');
|
|
713
|
+
button.append(icon, label);
|
|
714
|
+
button.title = trans.__(
|
|
715
|
+
'Prompt a coding agent about the selected code (%1)',
|
|
716
|
+
CommandRegistry.formatKeystroke(ASK_AGENT_KEYS)
|
|
717
|
+
);
|
|
718
|
+
// Keep the editor focused (and its selection alive) while clicking.
|
|
719
|
+
button.addEventListener('pointerdown', event => event.preventDefault());
|
|
720
|
+
button.addEventListener('click', () => {
|
|
721
|
+
const request = pillRequest;
|
|
722
|
+
hidePill();
|
|
723
|
+
if (request !== null) {
|
|
724
|
+
open(request);
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
document.body.appendChild(button);
|
|
728
|
+
pill = button;
|
|
729
|
+
return button;
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
const showPill = (request: IAskAgentRequest): void => {
|
|
733
|
+
const anchor = request.anchor;
|
|
734
|
+
if (anchor === null) {
|
|
735
|
+
hidePill();
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const button = ensurePill();
|
|
739
|
+
pillRequest = request;
|
|
740
|
+
button.style.display = 'flex';
|
|
741
|
+
// Measure after display so the rect is real, then sit the pill just
|
|
742
|
+
// above the selection head (below it when that would leave the
|
|
743
|
+
// viewport).
|
|
744
|
+
const rect = button.getBoundingClientRect();
|
|
745
|
+
let left = anchor.left;
|
|
746
|
+
let top = anchor.top - rect.height - PILL_GAP;
|
|
747
|
+
if (top < PILL_VIEWPORT_MARGIN) {
|
|
748
|
+
top = anchor.bottom + PILL_GAP;
|
|
749
|
+
}
|
|
750
|
+
left = Math.max(
|
|
751
|
+
PILL_VIEWPORT_MARGIN,
|
|
752
|
+
Math.min(left, window.innerWidth - rect.width - PILL_VIEWPORT_MARGIN)
|
|
753
|
+
);
|
|
754
|
+
button.style.left = `${left}px`;
|
|
755
|
+
button.style.top = `${top}px`;
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const evaluateSelection = (): void => {
|
|
759
|
+
if (popup !== null) {
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const selection = document.getSelection();
|
|
763
|
+
if (
|
|
764
|
+
selection === null ||
|
|
765
|
+
selection.isCollapsed ||
|
|
766
|
+
selection.rangeCount === 0
|
|
767
|
+
) {
|
|
768
|
+
hidePill();
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const target = resolveEditorTarget(app.shell.currentWidget);
|
|
772
|
+
if (target === null || !domSelectionInView(target.view, selection)) {
|
|
773
|
+
hidePill();
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
// The popup can send somewhere as long as an agent takes a prompt on
|
|
777
|
+
// its command line (new terminal) or one is already running (existing
|
|
778
|
+
// session) — only with neither is there nothing to offer.
|
|
779
|
+
if (
|
|
780
|
+
promptAgents().length === 0 &&
|
|
781
|
+
(agentTerminals?.sessions() ?? []).length === 0
|
|
782
|
+
) {
|
|
783
|
+
hidePill();
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const request = editorAskRequest(app);
|
|
787
|
+
if (request === null) {
|
|
788
|
+
hidePill();
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
showPill(request);
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// Each invocation resets the timer, so evaluation runs once the
|
|
795
|
+
// selection has settled. Mid-drag the selection is still moving; the
|
|
796
|
+
// pointerup listener invokes it again once the drag ends.
|
|
797
|
+
const settled = new Debouncer(() => {
|
|
798
|
+
if (!pointerIsDown) {
|
|
799
|
+
evaluateSelection();
|
|
800
|
+
}
|
|
801
|
+
}, SELECTION_SETTLE_MS);
|
|
802
|
+
|
|
803
|
+
document.addEventListener('selectionchange', () => {
|
|
804
|
+
if (popup !== null) {
|
|
805
|
+
// Typing in the popup's textarea moves the document selection; the
|
|
806
|
+
// pill must not resurface underneath the open popup.
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
hidePill();
|
|
810
|
+
void settled.invoke();
|
|
811
|
+
});
|
|
812
|
+
document.addEventListener(
|
|
813
|
+
'pointerdown',
|
|
814
|
+
() => {
|
|
815
|
+
pointerIsDown = true;
|
|
816
|
+
},
|
|
817
|
+
true
|
|
818
|
+
);
|
|
819
|
+
document.addEventListener(
|
|
820
|
+
'pointerup',
|
|
821
|
+
() => {
|
|
822
|
+
pointerIsDown = false;
|
|
823
|
+
void settled.invoke();
|
|
824
|
+
},
|
|
825
|
+
true
|
|
826
|
+
);
|
|
827
|
+
// Scrolling moves the selection away from the pill's fixed position;
|
|
828
|
+
// capture phase because scroll events do not bubble.
|
|
829
|
+
window.addEventListener(
|
|
830
|
+
'scroll',
|
|
831
|
+
() => {
|
|
832
|
+
if (popup === null) {
|
|
833
|
+
hidePill();
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
true
|
|
837
|
+
);
|
|
838
|
+
window.addEventListener('resize', () => {
|
|
839
|
+
if (popup === null) {
|
|
840
|
+
hidePill();
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
document.addEventListener('keydown', event => {
|
|
844
|
+
if (event.key !== 'Escape') {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (popup !== null) {
|
|
848
|
+
// Focus is outside the popup — its own key handler consumes Escape
|
|
849
|
+
// when focus is inside — so close it here and hand focus back to
|
|
850
|
+
// the widget the ask came from.
|
|
851
|
+
closePopup();
|
|
852
|
+
app.shell.currentWidget?.activate();
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
hidePill();
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
app.commands.addCommand(ASK_AGENT_COMMAND, {
|
|
859
|
+
label: trans.__('Ask Agent About Selection…'),
|
|
860
|
+
caption: trans.__(
|
|
861
|
+
'Prompt a coding agent about the selected code in a new or running terminal'
|
|
862
|
+
),
|
|
863
|
+
icon: askAgentIcon,
|
|
864
|
+
isEnabled: () => resolveEditorTarget(app.shell.currentWidget) !== null,
|
|
865
|
+
execute: () => {
|
|
866
|
+
const request = editorAskRequest(app, { allowEmpty: true });
|
|
867
|
+
if (request === null) {
|
|
868
|
+
Notification.warning(
|
|
869
|
+
trans.__('Select code in a text editor or notebook cell first.'),
|
|
870
|
+
{ autoClose: 3000 }
|
|
871
|
+
);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
open(request);
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// One binding per editing surface: file editors and notebooks.
|
|
879
|
+
for (const selector of ['.jp-FileEditor', '.jp-Notebook']) {
|
|
880
|
+
app.commands.addKeyBinding({
|
|
881
|
+
command: ASK_AGENT_COMMAND,
|
|
882
|
+
keys: [ASK_AGENT_KEYS],
|
|
883
|
+
selector
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
app.contextMenu.addItem({
|
|
888
|
+
command: ASK_AGENT_COMMAND,
|
|
889
|
+
selector: '.jp-FileEditor',
|
|
890
|
+
rank: 1
|
|
891
|
+
});
|
|
892
|
+
app.contextMenu.addItem({
|
|
893
|
+
command: ASK_AGENT_COMMAND,
|
|
894
|
+
selector: '.jp-Notebook .jp-Cell',
|
|
895
|
+
rank: 1
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
if (palette) {
|
|
899
|
+
palette.addItem({
|
|
900
|
+
command: ASK_AGENT_COMMAND,
|
|
901
|
+
category: trans.__('Other')
|
|
902
|
+
});
|
|
903
|
+
if (queue !== null) {
|
|
904
|
+
palette.addItem({
|
|
905
|
+
command: QUEUE_PANEL_COMMAND,
|
|
906
|
+
category: trans.__('Other')
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
return { open, close: closePopup };
|
|
912
|
+
}
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
export default plugin;
|