xtralab 0.11.1 → 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.
Files changed (159) hide show
  1. package/README.md +37 -231
  2. package/lib/agentSessions.d.ts +6 -2
  3. package/lib/askAgent/editorSelection.d.ts +53 -0
  4. package/lib/askAgent/editorSelection.js +95 -0
  5. package/lib/askAgent/icons.d.ts +7 -0
  6. package/lib/askAgent/icons.js +15 -0
  7. package/lib/askAgent/index.d.ts +32 -0
  8. package/lib/askAgent/index.js +741 -0
  9. package/lib/askAgent/popup.d.ts +83 -0
  10. package/lib/askAgent/popup.js +221 -0
  11. package/lib/askAgent/prompt.d.ts +24 -0
  12. package/lib/askAgent/prompt.js +116 -0
  13. package/lib/askAgent/queue.d.ts +93 -0
  14. package/lib/askAgent/queue.js +207 -0
  15. package/lib/askAgent/queuePanel.d.ts +76 -0
  16. package/lib/askAgent/queuePanel.js +188 -0
  17. package/lib/askAgent/targetPicker.d.ts +58 -0
  18. package/lib/askAgent/targetPicker.js +74 -0
  19. package/lib/askAgent/tokens.d.ts +124 -0
  20. package/lib/askAgent/tokens.js +13 -0
  21. package/lib/commandBar/index.d.ts +12 -8
  22. package/lib/commandBar/index.js +58 -41
  23. package/lib/editorBreadcrumbs/index.js +9 -4
  24. package/lib/editorBreadcrumbs/widget.d.ts +7 -7
  25. package/lib/editorBreadcrumbs/widget.js +6 -8
  26. package/lib/fileBrowser/commands.d.ts +2 -8
  27. package/lib/fileBrowser/commands.js +39 -30
  28. package/lib/fileBrowser/contents.d.ts +8 -3
  29. package/lib/fileBrowser/fileBrowser.d.ts +2 -1
  30. package/lib/fileBrowser/widget.d.ts +5 -9
  31. package/lib/fileBrowser/widget.js +1 -1
  32. package/lib/fileTypeIcons/index.js +5 -2
  33. package/lib/git/api.d.ts +0 -26
  34. package/lib/git/api.js +2 -44
  35. package/lib/git/askRequest.d.ts +23 -0
  36. package/lib/git/askRequest.js +90 -0
  37. package/lib/git/commands.d.ts +14 -2
  38. package/lib/git/commands.js +16 -13
  39. package/lib/git/diffModel.d.ts +1 -9
  40. package/lib/git/diffModel.js +1 -1
  41. package/lib/git/diffProvider.js +8 -3
  42. package/lib/git/diffSurface.d.ts +19 -2
  43. package/lib/git/diffSurface.js +64 -18
  44. package/lib/git/diffTheme.d.ts +0 -4
  45. package/lib/git/diffTheme.js +1 -1
  46. package/lib/git/diffWidget.d.ts +9 -0
  47. package/lib/git/diffWidget.js +38 -10
  48. package/lib/git/imageDiff.d.ts +17 -4
  49. package/lib/git/imageDiff.js +90 -30
  50. package/lib/git/index.js +7 -2
  51. package/lib/git/notebookDiff.d.ts +10 -67
  52. package/lib/git/notebookDiff.js +62 -69
  53. package/lib/git/tokens.d.ts +5 -2
  54. package/lib/highlight/index.js +12 -8
  55. package/lib/index.js +2 -0
  56. package/lib/launcher/agents.js +3 -4
  57. package/lib/launcher/commands.d.ts +6 -2
  58. package/lib/launcher/dashboard.d.ts +8 -1
  59. package/lib/launcher/dashboard.js +39 -33
  60. package/lib/launcher/editorRegistry.js +5 -3
  61. package/lib/launcher/editors.d.ts +15 -5
  62. package/lib/launcher/icons.d.ts +0 -37
  63. package/lib/launcher/icons.js +11 -12
  64. package/lib/launcher/index.js +2 -1
  65. package/lib/launcher/invocation.d.ts +0 -15
  66. package/lib/launcher/invocation.js +1 -1
  67. package/lib/launcher/schemaDefaults.d.ts +3 -1
  68. package/lib/launcher/schemaDefaults.js +3 -1
  69. package/lib/mimeData.d.ts +7 -0
  70. package/lib/mimeData.js +16 -0
  71. package/lib/omnibox/index.d.ts +4 -0
  72. package/lib/omnibox/index.js +43 -2
  73. package/lib/omnibox/model.d.ts +43 -14
  74. package/lib/omnibox/model.js +117 -14
  75. package/lib/omnibox/recents.d.ts +65 -0
  76. package/lib/omnibox/recents.js +109 -0
  77. package/lib/omnibox/tokens.d.ts +6 -2
  78. package/lib/omnibox/widget.d.ts +23 -6
  79. package/lib/omnibox/widget.js +26 -7
  80. package/lib/showOutput/index.js +15 -18
  81. package/lib/terminals/agentTerminals.d.ts +63 -0
  82. package/lib/terminals/agentTerminals.js +157 -0
  83. package/lib/terminals/index.d.ts +7 -1
  84. package/lib/terminals/index.js +50 -30
  85. package/lib/terminals/model.d.ts +11 -2
  86. package/lib/terminals/model.js +27 -8
  87. package/lib/terminals/tokens.d.ts +66 -0
  88. package/lib/terminals/tokens.js +7 -0
  89. package/lib/terminals/widget.d.ts +3 -6
  90. package/lib/terminals/widget.js +1 -1
  91. package/lib/topBar/index.js +3 -3
  92. package/lib/walkthrough/index.js +14 -3
  93. package/lib/walkthrough/panel.d.ts +12 -4
  94. package/lib/walkthrough/panel.js +9 -3
  95. package/package.json +49 -51
  96. package/schema/command-bar.json +10 -0
  97. package/schema/omnibox.json +15 -0
  98. package/src/agentSessions.ts +6 -2
  99. package/src/askAgent/editorSelection.ts +138 -0
  100. package/src/askAgent/icons.ts +16 -0
  101. package/src/askAgent/index.ts +915 -0
  102. package/src/askAgent/popup.tsx +407 -0
  103. package/src/askAgent/prompt.ts +144 -0
  104. package/src/askAgent/queue.ts +269 -0
  105. package/src/askAgent/queuePanel.tsx +418 -0
  106. package/src/askAgent/targetPicker.tsx +209 -0
  107. package/src/askAgent/tokens.ts +139 -0
  108. package/src/commandBar/index.ts +75 -49
  109. package/src/editorBreadcrumbs/index.ts +14 -4
  110. package/src/editorBreadcrumbs/widget.ts +11 -9
  111. package/src/fileBrowser/commands.ts +45 -31
  112. package/src/fileBrowser/contents.ts +7 -3
  113. package/src/fileBrowser/fileBrowser.tsx +1 -1
  114. package/src/fileBrowser/widget.tsx +5 -3
  115. package/src/fileTypeIcons/index.ts +6 -3
  116. package/src/git/api.ts +2 -61
  117. package/src/git/askRequest.ts +113 -0
  118. package/src/git/commands.ts +41 -14
  119. package/src/git/diffModel.ts +1 -1
  120. package/src/git/diffProvider.tsx +11 -3
  121. package/src/git/diffSurface.tsx +114 -22
  122. package/src/git/diffTheme.ts +1 -1
  123. package/src/git/diffWidget.tsx +54 -7
  124. package/src/git/imageDiff.tsx +169 -58
  125. package/src/git/index.ts +10 -2
  126. package/src/git/notebookDiff.tsx +91 -57
  127. package/src/git/tokens.ts +4 -2
  128. package/src/highlight/index.ts +12 -8
  129. package/src/index.ts +2 -0
  130. package/src/launcher/agents.ts +3 -4
  131. package/src/launcher/commands.ts +6 -2
  132. package/src/launcher/dashboard.tsx +86 -38
  133. package/src/launcher/editorRegistry.ts +10 -4
  134. package/src/launcher/editors.ts +15 -5
  135. package/src/launcher/icons.ts +11 -12
  136. package/src/launcher/index.ts +2 -1
  137. package/src/launcher/invocation.ts +1 -1
  138. package/src/launcher/schemaDefaults.ts +3 -1
  139. package/src/mimeData.ts +20 -0
  140. package/src/omnibox/index.ts +48 -1
  141. package/src/omnibox/model.ts +173 -33
  142. package/src/omnibox/recents.ts +155 -0
  143. package/src/omnibox/tokens.ts +6 -2
  144. package/src/omnibox/widget.tsx +54 -13
  145. package/src/showOutput/index.ts +20 -23
  146. package/src/terminals/agentTerminals.ts +238 -0
  147. package/src/terminals/index.ts +54 -30
  148. package/src/terminals/model.ts +28 -7
  149. package/src/terminals/tokens.ts +77 -0
  150. package/src/terminals/widget.tsx +4 -2
  151. package/src/topBar/index.ts +12 -6
  152. package/src/walkthrough/index.ts +19 -4
  153. package/src/walkthrough/panel.ts +15 -5
  154. package/style/askAgent.css +544 -0
  155. package/style/commandBar.css +12 -33
  156. package/style/git.css +72 -31
  157. package/style/index.css +1 -0
  158. package/style/index.js +2 -0
  159. 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
+ }