xtralab 0.11.0 → 0.12.0
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 +37 -229
- 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.d.ts +11 -1
- package/lib/launcher/agents.js +26 -8
- 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 +24 -7
- package/lib/launcher/editors.js +14 -5
- package/lib/launcher/icons.d.ts +0 -37
- package/lib/launcher/icons.js +11 -12
- package/lib/launcher/index.js +5 -2
- 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/launcher.json +4 -4
- 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 +31 -7
- 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 +34 -9
- package/src/launcher/icons.ts +11 -12
- package/src/launcher/index.ts +5 -2
- 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,407 @@
|
|
|
1
|
+
import { PathExt } from '@jupyterlab/coreutils';
|
|
2
|
+
import type { TranslationBundle } from '@jupyterlab/translation';
|
|
3
|
+
import { ReactWidget } from '@jupyterlab/ui-components';
|
|
4
|
+
import { CommandRegistry } from '@lumino/commands';
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
|
|
7
|
+
import type { IAgent } from '../launcher/agents';
|
|
8
|
+
|
|
9
|
+
import { AgentChoices, ISessionTarget, TargetChips } from './targetPicker';
|
|
10
|
+
import type { AskAgentTarget, IAskAgentContext } from './tokens';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Gap between the popup and its anchor rectangle, in pixels.
|
|
14
|
+
*/
|
|
15
|
+
const ANCHOR_GAP = 6;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Minimum distance kept between the popup and the viewport edges.
|
|
19
|
+
*/
|
|
20
|
+
const VIEWPORT_MARGIN = 8;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Short human-readable descriptor of the prompted range, shown in the popup
|
|
24
|
+
* header and the queue panel: file name, notebook cell (when applicable),
|
|
25
|
+
* line range and, for ranges that are not current file content (such as the
|
|
26
|
+
* old side of a diff), a clarifying tag.
|
|
27
|
+
*/
|
|
28
|
+
export function contextSummary(
|
|
29
|
+
context: IAskAgentContext,
|
|
30
|
+
trans: TranslationBundle
|
|
31
|
+
): string {
|
|
32
|
+
const parts = [PathExt.basename(context.path)];
|
|
33
|
+
if (context.cell !== undefined) {
|
|
34
|
+
parts.push(trans.__('cell %1', context.cell.index + 1));
|
|
35
|
+
}
|
|
36
|
+
if (context.startLine !== undefined) {
|
|
37
|
+
const endLine = context.endLine ?? context.startLine;
|
|
38
|
+
parts.push(
|
|
39
|
+
endLine > context.startLine
|
|
40
|
+
? trans.__('L%1–%2', context.startLine, endLine)
|
|
41
|
+
: trans.__('L%1', context.startLine)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (context.note !== undefined && context.note.length > 0) {
|
|
45
|
+
parts.push(context.note);
|
|
46
|
+
}
|
|
47
|
+
return parts.join(' · ');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function AskAgentPopupComponent(props: AskAgentPopup.IOptions): JSX.Element {
|
|
51
|
+
const {
|
|
52
|
+
context,
|
|
53
|
+
anchor,
|
|
54
|
+
agents,
|
|
55
|
+
targets,
|
|
56
|
+
initialAgentId,
|
|
57
|
+
initialTargetName,
|
|
58
|
+
queueCount,
|
|
59
|
+
trans,
|
|
60
|
+
onSubmit,
|
|
61
|
+
onQueue,
|
|
62
|
+
onCancel
|
|
63
|
+
} = props;
|
|
64
|
+
const [instruction, setInstruction] = React.useState('');
|
|
65
|
+
const [agentId, setAgentId] = React.useState<string>(() => {
|
|
66
|
+
const known = agents.find(agent => agent.id === initialAgentId);
|
|
67
|
+
return (known ?? agents[0])?.id ?? '';
|
|
68
|
+
});
|
|
69
|
+
// The running session the prompt goes to, or `null` for a new terminal.
|
|
70
|
+
const [targetName, setTargetName] = React.useState<string | null>(
|
|
71
|
+
initialTargetName
|
|
72
|
+
);
|
|
73
|
+
const [position, setPosition] = React.useState<{
|
|
74
|
+
left: number;
|
|
75
|
+
top: number;
|
|
76
|
+
} | null>(null);
|
|
77
|
+
const popupRef = React.useRef<HTMLDivElement>(null);
|
|
78
|
+
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
|
79
|
+
|
|
80
|
+
// Place the popup once its size is known: below the anchor, flipped above
|
|
81
|
+
// when there is no room, clamped into the viewport. Hidden until placed so
|
|
82
|
+
// the measurement never flashes at the wrong position.
|
|
83
|
+
React.useLayoutEffect(() => {
|
|
84
|
+
const node = popupRef.current;
|
|
85
|
+
if (node === null) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const rect = node.getBoundingClientRect();
|
|
89
|
+
const viewportWidth = window.innerWidth;
|
|
90
|
+
const viewportHeight = window.innerHeight;
|
|
91
|
+
let left: number;
|
|
92
|
+
let top: number;
|
|
93
|
+
if (anchor) {
|
|
94
|
+
left = anchor.left;
|
|
95
|
+
top = anchor.bottom + ANCHOR_GAP;
|
|
96
|
+
if (top + rect.height > viewportHeight - VIEWPORT_MARGIN) {
|
|
97
|
+
top = anchor.top - rect.height - ANCHOR_GAP;
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
left = (viewportWidth - rect.width) / 2;
|
|
101
|
+
top = viewportHeight * 0.2;
|
|
102
|
+
}
|
|
103
|
+
left = Math.max(
|
|
104
|
+
VIEWPORT_MARGIN,
|
|
105
|
+
Math.min(left, viewportWidth - rect.width - VIEWPORT_MARGIN)
|
|
106
|
+
);
|
|
107
|
+
top = Math.max(
|
|
108
|
+
VIEWPORT_MARGIN,
|
|
109
|
+
Math.min(top, viewportHeight - rect.height - VIEWPORT_MARGIN)
|
|
110
|
+
);
|
|
111
|
+
setPosition({ left, top });
|
|
112
|
+
}, [anchor]);
|
|
113
|
+
|
|
114
|
+
// Focus the input only once the popup is placed: while it is still
|
|
115
|
+
// unpositioned it sits `visibility: hidden`, and hidden elements silently
|
|
116
|
+
// refuse focus. The empty state renders no textarea; focus the dialog
|
|
117
|
+
// root itself so it is announced and Escape works without a pointer.
|
|
118
|
+
React.useEffect(() => {
|
|
119
|
+
if (position !== null) {
|
|
120
|
+
(textareaRef.current ?? popupRef.current)?.focus();
|
|
121
|
+
}
|
|
122
|
+
}, [position]);
|
|
123
|
+
|
|
124
|
+
const dismiss = React.useCallback(
|
|
125
|
+
(restoreFocus: boolean) => {
|
|
126
|
+
// Defer so unmounting this React root never happens synchronously
|
|
127
|
+
// inside the event handler that triggered it (same pattern as the
|
|
128
|
+
// omnibox).
|
|
129
|
+
window.setTimeout(() => onCancel(restoreFocus), 0);
|
|
130
|
+
},
|
|
131
|
+
[onCancel]
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Close when the user clicks anywhere outside the popup. Capture phase so
|
|
135
|
+
// a click into widgets that swallow events still dismisses it. The click
|
|
136
|
+
// places focus itself, so none is restored.
|
|
137
|
+
React.useEffect(() => {
|
|
138
|
+
const onPointerDown = (event: PointerEvent): void => {
|
|
139
|
+
const node = popupRef.current;
|
|
140
|
+
if (
|
|
141
|
+
node &&
|
|
142
|
+
event.target instanceof Node &&
|
|
143
|
+
!node.contains(event.target)
|
|
144
|
+
) {
|
|
145
|
+
dismiss(false);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
document.addEventListener('pointerdown', onPointerDown, true);
|
|
149
|
+
return () => {
|
|
150
|
+
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
151
|
+
};
|
|
152
|
+
}, [dismiss]);
|
|
153
|
+
|
|
154
|
+
const selectedAgent = agents.find(agent => agent.id === agentId);
|
|
155
|
+
const selectedSession = targets.find(target => target.name === targetName);
|
|
156
|
+
const canSubmit =
|
|
157
|
+
instruction.trim().length > 0 &&
|
|
158
|
+
(selectedSession !== undefined || selectedAgent !== undefined);
|
|
159
|
+
|
|
160
|
+
// The destination both actions use: send delivers to it now, queue stamps
|
|
161
|
+
// it on the queued prompt (still editable in the panel later).
|
|
162
|
+
const resolveTarget = React.useCallback((): AskAgentTarget | null => {
|
|
163
|
+
const session = targets.find(entry => entry.name === targetName);
|
|
164
|
+
if (session !== undefined) {
|
|
165
|
+
return { kind: 'session', name: session.name };
|
|
166
|
+
}
|
|
167
|
+
const agent = agents.find(entry => entry.id === agentId);
|
|
168
|
+
if (agent !== undefined) {
|
|
169
|
+
return { kind: 'new', agentId: agent.id };
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}, [agents, agentId, targets, targetName]);
|
|
173
|
+
|
|
174
|
+
const submit = React.useCallback(() => {
|
|
175
|
+
const trimmed = instruction.trim();
|
|
176
|
+
const target = resolveTarget();
|
|
177
|
+
if (trimmed.length === 0 || target === null) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// Defer so unmounting this React root never happens synchronously
|
|
181
|
+
// inside the event handler that triggered it.
|
|
182
|
+
window.setTimeout(() => {
|
|
183
|
+
onSubmit(target, trimmed);
|
|
184
|
+
}, 0);
|
|
185
|
+
}, [instruction, resolveTarget, onSubmit]);
|
|
186
|
+
|
|
187
|
+
const queueInstruction = React.useCallback(() => {
|
|
188
|
+
const trimmed = instruction.trim();
|
|
189
|
+
const target = resolveTarget();
|
|
190
|
+
if (trimmed.length === 0 || target === null || onQueue === undefined) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
// Same deferred pattern as submit.
|
|
194
|
+
window.setTimeout(() => {
|
|
195
|
+
onQueue(target, trimmed);
|
|
196
|
+
}, 0);
|
|
197
|
+
}, [instruction, resolveTarget, onQueue]);
|
|
198
|
+
|
|
199
|
+
const onRootKeyDown = (event: React.KeyboardEvent): void => {
|
|
200
|
+
if (event.key === 'Escape') {
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
event.stopPropagation();
|
|
203
|
+
// A keyboard dismissal places no focus of its own; ask the plugin to
|
|
204
|
+
// hand it back to the widget the ask came from.
|
|
205
|
+
dismiss(true);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const onTextareaKeyDown = (
|
|
210
|
+
event: React.KeyboardEvent<HTMLTextAreaElement>
|
|
211
|
+
): void => {
|
|
212
|
+
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if ((event.metaKey || event.ctrlKey) && onQueue !== undefined) {
|
|
216
|
+
event.preventDefault();
|
|
217
|
+
event.stopPropagation();
|
|
218
|
+
queueInstruction();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (!event.shiftKey) {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
event.stopPropagation();
|
|
224
|
+
submit();
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const summary = contextSummary(context, trans);
|
|
229
|
+
const placeholder = trans.__('Describe the change…');
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
<div
|
|
233
|
+
ref={popupRef}
|
|
234
|
+
className="jp-xtralab-AskAgent-popup"
|
|
235
|
+
role="dialog"
|
|
236
|
+
aria-label={trans.__('Ask an agent about %1', summary)}
|
|
237
|
+
// Focusable so clicks on non-interactive popup content keep focus
|
|
238
|
+
// inside the dialog (Escape keeps working) and so the empty state
|
|
239
|
+
// can be focused at all.
|
|
240
|
+
tabIndex={-1}
|
|
241
|
+
style={{
|
|
242
|
+
left: position?.left ?? 0,
|
|
243
|
+
top: position?.top ?? 0,
|
|
244
|
+
visibility: position ? 'visible' : 'hidden'
|
|
245
|
+
}}
|
|
246
|
+
onKeyDown={onRootKeyDown}
|
|
247
|
+
>
|
|
248
|
+
<div className="jp-xtralab-AskAgent-context" title={context.path}>
|
|
249
|
+
{summary}
|
|
250
|
+
</div>
|
|
251
|
+
{agents.length === 0 && targets.length === 0 ? (
|
|
252
|
+
<div className="jp-xtralab-AskAgent-empty">
|
|
253
|
+
{trans.__(
|
|
254
|
+
'No agent accepts an initial prompt. Configure agents in the launcher settings.'
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
) : (
|
|
258
|
+
<>
|
|
259
|
+
<textarea
|
|
260
|
+
ref={textareaRef}
|
|
261
|
+
className="jp-xtralab-AskAgent-input"
|
|
262
|
+
rows={3}
|
|
263
|
+
spellCheck={false}
|
|
264
|
+
placeholder={placeholder}
|
|
265
|
+
aria-label={placeholder}
|
|
266
|
+
value={instruction}
|
|
267
|
+
onChange={event => setInstruction(event.target.value)}
|
|
268
|
+
onKeyDown={onTextareaKeyDown}
|
|
269
|
+
/>
|
|
270
|
+
<TargetChips
|
|
271
|
+
agents={agents}
|
|
272
|
+
targets={targets}
|
|
273
|
+
targetName={targetName}
|
|
274
|
+
trans={trans}
|
|
275
|
+
onSelect={setTargetName}
|
|
276
|
+
/>
|
|
277
|
+
<div className="jp-xtralab-AskAgent-footer">
|
|
278
|
+
{selectedSession === undefined && (
|
|
279
|
+
<AgentChoices
|
|
280
|
+
agents={agents}
|
|
281
|
+
agentId={agentId}
|
|
282
|
+
trans={trans}
|
|
283
|
+
onSelect={setAgentId}
|
|
284
|
+
/>
|
|
285
|
+
)}
|
|
286
|
+
<div className="jp-xtralab-AskAgent-actions">
|
|
287
|
+
{onQueue !== undefined && (
|
|
288
|
+
<button
|
|
289
|
+
type="button"
|
|
290
|
+
className="jp-xtralab-AskAgent-queueButton"
|
|
291
|
+
disabled={!canSubmit}
|
|
292
|
+
title={trans.__(
|
|
293
|
+
'Queue for this destination instead of sending now — review the queue and send everything later (%1)',
|
|
294
|
+
CommandRegistry.formatKeystroke('Accel Enter')
|
|
295
|
+
)}
|
|
296
|
+
onMouseDown={event => event.preventDefault()}
|
|
297
|
+
onClick={queueInstruction}
|
|
298
|
+
>
|
|
299
|
+
{queueCount !== undefined && queueCount > 0
|
|
300
|
+
? trans.__('Queue (%1)', queueCount)
|
|
301
|
+
: trans.__('Queue')}
|
|
302
|
+
</button>
|
|
303
|
+
)}
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
className="jp-xtralab-AskAgent-submit"
|
|
307
|
+
disabled={!canSubmit}
|
|
308
|
+
title={trans.__('Enter to send, Esc to close')}
|
|
309
|
+
onMouseDown={event => event.preventDefault()}
|
|
310
|
+
onClick={submit}
|
|
311
|
+
>
|
|
312
|
+
{selectedSession
|
|
313
|
+
? trans.__('Send to %1', selectedSession.label)
|
|
314
|
+
: selectedAgent
|
|
315
|
+
? trans.__('Ask %1', selectedAgent.label)
|
|
316
|
+
: trans.__('Ask agent')}
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
</>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The floating ask-agent prompt box. Hosts {@link AskAgentPopupComponent} in
|
|
328
|
+
* a React root attached to `document.body`; `jp-ThemedContainer` makes
|
|
329
|
+
* JupyterLab's theme variables resolve outside the shell (the omnibox uses
|
|
330
|
+
* the same arrangement).
|
|
331
|
+
*/
|
|
332
|
+
export class AskAgentPopup extends ReactWidget {
|
|
333
|
+
constructor(options: AskAgentPopup.IOptions) {
|
|
334
|
+
super();
|
|
335
|
+
this._options = options;
|
|
336
|
+
this.id = 'xtralab-ask-agent-popup';
|
|
337
|
+
this.addClass('jp-xtralab-AskAgent');
|
|
338
|
+
this.addClass('jp-ThemedContainer');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
render(): JSX.Element {
|
|
342
|
+
return <AskAgentPopupComponent {...this._options} />;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private _options: AskAgentPopup.IOptions;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* A namespace for `AskAgentPopup` statics.
|
|
350
|
+
*/
|
|
351
|
+
export namespace AskAgentPopup {
|
|
352
|
+
/**
|
|
353
|
+
* Construction options for {@link AskAgentPopup}.
|
|
354
|
+
*/
|
|
355
|
+
export interface IOptions {
|
|
356
|
+
/**
|
|
357
|
+
* The code selection the prompt is about.
|
|
358
|
+
*/
|
|
359
|
+
context: IAskAgentContext;
|
|
360
|
+
/**
|
|
361
|
+
* Viewport rectangle to anchor to; `null` centers near the top.
|
|
362
|
+
*/
|
|
363
|
+
anchor: DOMRect | null;
|
|
364
|
+
/**
|
|
365
|
+
* Snapshot of the prompt-capable agents, taken when the popup opens.
|
|
366
|
+
*/
|
|
367
|
+
agents: IAgent[];
|
|
368
|
+
/**
|
|
369
|
+
* Snapshot of the running agent terminals, taken when the popup opens.
|
|
370
|
+
*/
|
|
371
|
+
targets: ISessionTarget[];
|
|
372
|
+
/**
|
|
373
|
+
* Preferred agent id (the last one used), when still available.
|
|
374
|
+
*/
|
|
375
|
+
initialAgentId: string | null;
|
|
376
|
+
/**
|
|
377
|
+
* Session to preselect as the target, or `null` to preselect the new
|
|
378
|
+
* terminal. Must be `null` or the name of an entry in {@link targets}.
|
|
379
|
+
*/
|
|
380
|
+
initialTargetName: string | null;
|
|
381
|
+
/**
|
|
382
|
+
* Number of prompts already queued, shown on the queue button. Only
|
|
383
|
+
* meaningful together with {@link onQueue}.
|
|
384
|
+
*/
|
|
385
|
+
queueCount?: number;
|
|
386
|
+
/**
|
|
387
|
+
* Translation bundle for the popup's own labels.
|
|
388
|
+
*/
|
|
389
|
+
trans: TranslationBundle;
|
|
390
|
+
/**
|
|
391
|
+
* Send the instruction to the chosen target (the plugin closes the popup).
|
|
392
|
+
*/
|
|
393
|
+
onSubmit: (target: AskAgentTarget, instruction: string) => void;
|
|
394
|
+
/**
|
|
395
|
+
* Add the instruction to the prompt queue instead of sending it, keyed
|
|
396
|
+
* to the same chosen target (the plugin closes the popup). The queue
|
|
397
|
+
* button is hidden when omitted.
|
|
398
|
+
*/
|
|
399
|
+
onQueue?: (target: AskAgentTarget, instruction: string) => void;
|
|
400
|
+
/**
|
|
401
|
+
* Dismiss the popup (the plugin disposes the widget). `restoreFocus` is
|
|
402
|
+
* true when the dismissal placed no focus of its own (Escape), asking
|
|
403
|
+
* the plugin to hand focus back to the widget the ask came from.
|
|
404
|
+
*/
|
|
405
|
+
onCancel: (restoreFocus: boolean) => void;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { PathExt } from '@jupyterlab/coreutils';
|
|
2
|
+
|
|
3
|
+
import type { IAskAgentContext } from './tokens';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The path a context points at, resolved against the server root. Contexts
|
|
7
|
+
* from git diffs carry repository-relative paths plus the repository root in
|
|
8
|
+
* `cwd`; contexts from plain editors are already server-relative.
|
|
9
|
+
*/
|
|
10
|
+
export function serverPath(context: IAskAgentContext): string {
|
|
11
|
+
return context.cwd !== undefined && context.cwd.length > 0
|
|
12
|
+
? PathExt.join(context.cwd, context.path)
|
|
13
|
+
: context.path;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Cap on the snippet embedded in the prompt, in characters. The path and
|
|
18
|
+
* line range are the authoritative pointers — agents open the file
|
|
19
|
+
* themselves — so a very long selection is trimmed instead of being typed
|
|
20
|
+
* into the terminal wholesale.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_SNIPPET_CHARS = 2000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A backtick fence strictly longer than any backtick run inside `text`, so
|
|
26
|
+
* the embedded snippet can never terminate the fence early.
|
|
27
|
+
*/
|
|
28
|
+
function fenceFor(text: string): string {
|
|
29
|
+
let longest = 0;
|
|
30
|
+
for (const run of text.match(/`+/g) ?? []) {
|
|
31
|
+
longest = Math.max(longest, run.length);
|
|
32
|
+
}
|
|
33
|
+
return '`'.repeat(Math.max(3, longest + 1));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Trim `text` to {@link MAX_SNIPPET_CHARS}, cutting at a line boundary.
|
|
38
|
+
* Returns the (possibly shortened) snippet and whether anything was dropped.
|
|
39
|
+
*/
|
|
40
|
+
function clampSnippet(text: string): { snippet: string; truncated: boolean } {
|
|
41
|
+
if (text.length <= MAX_SNIPPET_CHARS) {
|
|
42
|
+
return { snippet: text, truncated: false };
|
|
43
|
+
}
|
|
44
|
+
const head = text.slice(0, MAX_SNIPPET_CHARS);
|
|
45
|
+
const lastBreak = head.lastIndexOf('\n');
|
|
46
|
+
return {
|
|
47
|
+
snippet: lastBreak > 0 ? head.slice(0, lastBreak) : head,
|
|
48
|
+
truncated: true
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Agent-facing description of where `context` points: path, notebook cell,
|
|
54
|
+
* line range and any extra locator, e.g.
|
|
55
|
+
* `` `src/app.ts`, lines 3-9, the old side (HEAD) of the current git diff ``.
|
|
56
|
+
*/
|
|
57
|
+
function locatorFor(context: IAskAgentContext): string {
|
|
58
|
+
const where: string[] = [`\`${context.path}\``];
|
|
59
|
+
if (context.cell !== undefined) {
|
|
60
|
+
// Both orderings so the agent can count cells or index the nbformat
|
|
61
|
+
// `cells` array, whichever its notebook tooling prefers.
|
|
62
|
+
where.push(
|
|
63
|
+
`${context.cell.type} cell ${context.cell.index + 1} ` +
|
|
64
|
+
`(0-based index ${context.cell.index})`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (context.startLine !== undefined) {
|
|
68
|
+
const endLine = context.endLine ?? context.startLine;
|
|
69
|
+
const lines =
|
|
70
|
+
endLine > context.startLine
|
|
71
|
+
? `lines ${context.startLine}-${endLine}`
|
|
72
|
+
: `line ${context.startLine}`;
|
|
73
|
+
where.push(context.cell !== undefined ? `${lines} of the cell` : lines);
|
|
74
|
+
}
|
|
75
|
+
if (context.location !== undefined && context.location.length > 0) {
|
|
76
|
+
where.push(context.location);
|
|
77
|
+
}
|
|
78
|
+
return where.join(', ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The blocks describing one comment: `headline`, the selected snippet in a
|
|
83
|
+
* code fence (when there is one), then the user's instruction. Blocks are
|
|
84
|
+
* joined with blank lines by the callers.
|
|
85
|
+
*/
|
|
86
|
+
function commentParts(
|
|
87
|
+
context: IAskAgentContext,
|
|
88
|
+
instruction: string,
|
|
89
|
+
headline: string
|
|
90
|
+
): string[] {
|
|
91
|
+
const parts: string[] = [headline];
|
|
92
|
+
if (context.text.length > 0) {
|
|
93
|
+
const { snippet, truncated } = clampSnippet(context.text);
|
|
94
|
+
const fence = fenceFor(snippet);
|
|
95
|
+
parts.push(`${fence}\n${snippet}\n${fence}`);
|
|
96
|
+
if (truncated) {
|
|
97
|
+
parts.push('(snippet truncated — read the file for the full range)');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
parts.push(instruction);
|
|
101
|
+
return parts;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Compose the prompt handed to the agent CLI: a one-line locator, the
|
|
106
|
+
* selected snippet in a code fence, then the user's instruction.
|
|
107
|
+
*
|
|
108
|
+
* The scaffold is written in English on purpose — it is consumed by the
|
|
109
|
+
* agent, not shown in the UI, and the agent CLIs are English-first.
|
|
110
|
+
*/
|
|
111
|
+
export function buildPrompt(
|
|
112
|
+
context: IAskAgentContext,
|
|
113
|
+
instruction: string
|
|
114
|
+
): string {
|
|
115
|
+
return commentParts(context, instruction, `In ${locatorFor(context)}:`).join(
|
|
116
|
+
'\n\n'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Compose one prompt out of every queued comment, numbered and in queue
|
|
122
|
+
* order, so a whole review pass reaches the agent as a single task list. A
|
|
123
|
+
* queue of one degrades to the plain {@link buildPrompt} wording.
|
|
124
|
+
*/
|
|
125
|
+
export function buildBatchPrompt(
|
|
126
|
+
items: ReadonlyArray<{ context: IAskAgentContext; instruction: string }>
|
|
127
|
+
): string {
|
|
128
|
+
if (items.length === 1) {
|
|
129
|
+
return buildPrompt(items[0].context, items[0].instruction);
|
|
130
|
+
}
|
|
131
|
+
const parts: string[] = [
|
|
132
|
+
`Please address the following ${items.length} comments.`
|
|
133
|
+
];
|
|
134
|
+
items.forEach((item, index) => {
|
|
135
|
+
parts.push(
|
|
136
|
+
...commentParts(
|
|
137
|
+
item.context,
|
|
138
|
+
item.instruction,
|
|
139
|
+
`Comment ${index + 1} — in ${locatorFor(item.context)}:`
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
return parts.join('\n\n');
|
|
144
|
+
}
|