@plmbr/notebook-intelligence 5.0.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 (137) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +412 -0
  3. package/lib/api.d.ts +288 -0
  4. package/lib/api.js +927 -0
  5. package/lib/cell-output-bundle.d.ts +25 -0
  6. package/lib/cell-output-bundle.js +129 -0
  7. package/lib/cell-output-toolbar.d.ts +26 -0
  8. package/lib/cell-output-toolbar.js +188 -0
  9. package/lib/chat-progress-feedback.d.ts +3 -0
  10. package/lib/chat-progress-feedback.js +27 -0
  11. package/lib/chat-sidebar.d.ts +92 -0
  12. package/lib/chat-sidebar.js +3452 -0
  13. package/lib/command-ids.d.ts +39 -0
  14. package/lib/command-ids.js +44 -0
  15. package/lib/components/ask-user-question.d.ts +2 -0
  16. package/lib/components/ask-user-question.js +85 -0
  17. package/lib/components/checkbox.d.ts +2 -0
  18. package/lib/components/checkbox.js +30 -0
  19. package/lib/components/claude-mcp-panel.d.ts +2 -0
  20. package/lib/components/claude-mcp-panel.js +275 -0
  21. package/lib/components/claude-mcp-paste.d.ts +7 -0
  22. package/lib/components/claude-mcp-paste.js +104 -0
  23. package/lib/components/claude-session-picker.d.ts +8 -0
  24. package/lib/components/claude-session-picker.js +127 -0
  25. package/lib/components/form-dialog.d.ts +25 -0
  26. package/lib/components/form-dialog.js +35 -0
  27. package/lib/components/launcher-picker.d.ts +6 -0
  28. package/lib/components/launcher-picker.js +135 -0
  29. package/lib/components/mcp-util.d.ts +2 -0
  30. package/lib/components/mcp-util.js +37 -0
  31. package/lib/components/notebook-generation-popover.d.ts +7 -0
  32. package/lib/components/notebook-generation-popover.js +60 -0
  33. package/lib/components/pill.d.ts +2 -0
  34. package/lib/components/pill.js +5 -0
  35. package/lib/components/plugins-panel.d.ts +3 -0
  36. package/lib/components/plugins-panel.js +466 -0
  37. package/lib/components/settings-panel.d.ts +11 -0
  38. package/lib/components/settings-panel.js +742 -0
  39. package/lib/components/skills-panel.d.ts +2 -0
  40. package/lib/components/skills-panel.js +1264 -0
  41. package/lib/handler.d.ts +8 -0
  42. package/lib/handler.js +36 -0
  43. package/lib/icons.d.ts +45 -0
  44. package/lib/icons.js +54 -0
  45. package/lib/index.d.ts +8 -0
  46. package/lib/index.js +2079 -0
  47. package/lib/markdown-renderer.d.ts +10 -0
  48. package/lib/markdown-renderer.js +64 -0
  49. package/lib/notebook-generation-toolbar.d.ts +16 -0
  50. package/lib/notebook-generation-toolbar.js +197 -0
  51. package/lib/notebook-generation.d.ts +8 -0
  52. package/lib/notebook-generation.js +12 -0
  53. package/lib/open-file-refresh-watcher-env.d.ts +4 -0
  54. package/lib/open-file-refresh-watcher-env.js +33 -0
  55. package/lib/open-file-refresh-watcher.d.ts +97 -0
  56. package/lib/open-file-refresh-watcher.js +190 -0
  57. package/lib/shell-utils.d.ts +6 -0
  58. package/lib/shell-utils.js +9 -0
  59. package/lib/task-target-notebook.d.ts +2 -0
  60. package/lib/task-target-notebook.js +28 -0
  61. package/lib/terminal-drag-format.d.ts +9 -0
  62. package/lib/terminal-drag-format.js +23 -0
  63. package/lib/terminal-drag.d.ts +12 -0
  64. package/lib/terminal-drag.js +268 -0
  65. package/lib/tokens.d.ts +149 -0
  66. package/lib/tokens.js +88 -0
  67. package/lib/tour/tour-anchors.d.ts +18 -0
  68. package/lib/tour/tour-anchors.js +18 -0
  69. package/lib/tour/tour-config.d.ts +66 -0
  70. package/lib/tour/tour-config.js +99 -0
  71. package/lib/tour/tour-defaults.json +58 -0
  72. package/lib/tour/tour-events.d.ts +19 -0
  73. package/lib/tour/tour-events.js +30 -0
  74. package/lib/tour/tour-overlay.d.ts +6 -0
  75. package/lib/tour/tour-overlay.js +350 -0
  76. package/lib/tour/tour-state.d.ts +20 -0
  77. package/lib/tour/tour-state.js +81 -0
  78. package/lib/tour/tour-steps.d.ts +33 -0
  79. package/lib/tour/tour-steps.js +216 -0
  80. package/lib/utils.d.ts +53 -0
  81. package/lib/utils.js +385 -0
  82. package/package.json +258 -0
  83. package/schema/plugin.json +42 -0
  84. package/src/api.ts +1424 -0
  85. package/src/cell-output-bundle.ts +176 -0
  86. package/src/cell-output-toolbar.ts +232 -0
  87. package/src/chat-progress-feedback.ts +35 -0
  88. package/src/chat-sidebar.tsx +5147 -0
  89. package/src/command-ids.ts +67 -0
  90. package/src/components/ask-user-question.tsx +151 -0
  91. package/src/components/checkbox.tsx +62 -0
  92. package/src/components/claude-mcp-panel.tsx +543 -0
  93. package/src/components/claude-mcp-paste.ts +132 -0
  94. package/src/components/claude-session-picker.tsx +214 -0
  95. package/src/components/form-dialog.tsx +75 -0
  96. package/src/components/launcher-picker.tsx +237 -0
  97. package/src/components/mcp-util.ts +53 -0
  98. package/src/components/notebook-generation-popover.tsx +127 -0
  99. package/src/components/pill.tsx +15 -0
  100. package/src/components/plugins-panel.tsx +774 -0
  101. package/src/components/settings-panel.tsx +1631 -0
  102. package/src/components/skills-panel.tsx +2084 -0
  103. package/src/handler.ts +51 -0
  104. package/src/icons.ts +71 -0
  105. package/src/index.ts +2583 -0
  106. package/src/markdown-renderer.tsx +153 -0
  107. package/src/notebook-generation-toolbar.tsx +281 -0
  108. package/src/notebook-generation.ts +23 -0
  109. package/src/open-file-refresh-watcher-env.ts +52 -0
  110. package/src/open-file-refresh-watcher.ts +260 -0
  111. package/src/shell-utils.ts +10 -0
  112. package/src/svg.d.ts +4 -0
  113. package/src/task-target-notebook.ts +37 -0
  114. package/src/terminal-drag-format.ts +29 -0
  115. package/src/terminal-drag.ts +382 -0
  116. package/src/tokens.ts +171 -0
  117. package/src/tour/tour-anchors.ts +21 -0
  118. package/src/tour/tour-config.ts +160 -0
  119. package/src/tour/tour-events.ts +34 -0
  120. package/src/tour/tour-overlay.tsx +474 -0
  121. package/src/tour/tour-state.ts +87 -0
  122. package/src/tour/tour-steps.ts +281 -0
  123. package/src/utils.ts +455 -0
  124. package/style/base.css +3238 -0
  125. package/style/icons/cell-toolbar-bug.svg +5 -0
  126. package/style/icons/cell-toolbar-chat.svg +5 -0
  127. package/style/icons/cell-toolbar-sparkle.svg +5 -0
  128. package/style/icons/claude.svg +1 -0
  129. package/style/icons/copilot-warning.svg +1 -0
  130. package/style/icons/copilot.svg +1 -0
  131. package/style/icons/copy.svg +1 -0
  132. package/style/icons/openai.svg +1 -0
  133. package/style/icons/opencode.svg +1 -0
  134. package/style/icons/sparkles-warning.svg +5 -0
  135. package/style/icons/sparkles.svg +1 -0
  136. package/style/index.css +1 -0
  137. package/style/index.js +1 -0
package/src/utils.ts ADDED
@@ -0,0 +1,455 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+
3
+ import { CodeCell } from '@jupyterlab/cells';
4
+ import { PartialJSONObject } from '@lumino/coreutils';
5
+ import { CodeEditor } from '@jupyterlab/codeeditor';
6
+ import { IDocumentManager } from '@jupyterlab/docmanager';
7
+ import { FileDialog } from '@jupyterlab/filebrowser';
8
+ import { encoding_for_model } from 'tiktoken';
9
+ import { NotebookPanel } from '@jupyterlab/notebook';
10
+
11
+ import { shellSingleQuote } from './shell-utils';
12
+
13
+ const tiktoken_encoding = encoding_for_model('gpt-4o');
14
+
15
+ export function removeAnsiChars(str: string): string {
16
+ return str.replace(
17
+ // eslint-disable-next-line no-control-regex
18
+ /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
19
+ ''
20
+ );
21
+ }
22
+
23
+ export async function waitForDuration(duration: number): Promise<void> {
24
+ return new Promise(resolve => {
25
+ setTimeout(() => {
26
+ resolve();
27
+ }, duration);
28
+ });
29
+ }
30
+
31
+ export function moveCodeSectionBoundaryMarkersToNewLine(
32
+ source: string
33
+ ): string {
34
+ const existingLines = source.split('\n');
35
+ const newLines = [];
36
+ for (const line of existingLines) {
37
+ if (line.length > 3 && line.startsWith('```')) {
38
+ newLines.push('```');
39
+ let remaining = line.substring(3);
40
+ if (remaining.startsWith('python')) {
41
+ if (remaining.length === 6) {
42
+ continue;
43
+ }
44
+ remaining = remaining.substring(6);
45
+ }
46
+ if (remaining.endsWith('```')) {
47
+ newLines.push(remaining.substring(0, remaining.length - 3));
48
+ newLines.push('```');
49
+ } else {
50
+ newLines.push(remaining);
51
+ }
52
+ } else if (line.length > 3 && line.endsWith('```')) {
53
+ newLines.push(line.substring(0, line.length - 3));
54
+ newLines.push('```');
55
+ } else {
56
+ newLines.push(line);
57
+ }
58
+ }
59
+ return newLines.join('\n');
60
+ }
61
+
62
+ export function extractLLMGeneratedCode(code: string): string {
63
+ // Strip our backend-emitted stream-interruption marker. The Claude inline
64
+ // handler pushes it into the same text channel so the diff pane shows
65
+ // what went wrong, but we never want it landing verbatim in the user's
66
+ // file when fresh-generation auto-inserts the result (or when the user
67
+ // accepts a truncated diff). The pattern is anchored to end-of-string
68
+ // because the backend always emits the marker as the last delta, and
69
+ // its closing bracket is required so legitimate generated code that
70
+ // happens to contain the phrase mid-buffer (e.g.
71
+ // ``print("[Stream interrupted: demo]")``) is not stripped. Greedy
72
+ // ``[^\n]*\]`` backtracks to the last ``]`` on the marker line, so
73
+ // bracketed exception strings such as
74
+ // ``[SSL: CERTIFICATE_VERIFY_FAILED] unable to get local issuer certificate``
75
+ // and ``[Errno 11001] getaddrinfo failed`` are still matched in full.
76
+ code = code.replace(/\n*\[Stream interrupted:[^\n]*\]\n*$/, '');
77
+ if (code.endsWith('```')) {
78
+ code = code.slice(0, -3);
79
+ }
80
+
81
+ const lines = code.split('\n');
82
+ if (lines.length < 2) {
83
+ return code;
84
+ }
85
+
86
+ const numLines = lines.length;
87
+ let startLine = -1;
88
+ let endLine = numLines;
89
+
90
+ for (let i = 0; i < numLines; i++) {
91
+ if (startLine === -1) {
92
+ if (lines[i].trimStart().startsWith('```')) {
93
+ startLine = i;
94
+ continue;
95
+ }
96
+ } else {
97
+ if (lines[i].trimStart().startsWith('```')) {
98
+ endLine = i;
99
+ break;
100
+ }
101
+ }
102
+ }
103
+
104
+ if (startLine !== -1) {
105
+ return lines.slice(startLine + 1, endLine).join('\n');
106
+ }
107
+
108
+ return code;
109
+ }
110
+
111
+ export function isDarkTheme(): boolean {
112
+ return document.body.getAttribute('data-jp-theme-light') === 'false';
113
+ }
114
+
115
+ export function markdownToComment(source: string): string {
116
+ return source
117
+ .split('\n')
118
+ .map(line => `# ${line}`)
119
+ .join('\n');
120
+ }
121
+
122
+ export function formatJupyterError(output: any): string {
123
+ const head = `${output.ename ?? 'Error'}: ${output.evalue ?? ''}`.trim();
124
+ const tb = Array.isArray(output.traceback)
125
+ ? output.traceback.map((line: string) => removeAnsiChars(line)).join('\n')
126
+ : '';
127
+ return tb ? `${head}\n${tb}` : head;
128
+ }
129
+
130
+ // True when the output area contains at least one error output. Avoids the
131
+ // full toJSON() serialization callers used to do for a 1-bit check.
132
+ export function cellOutputHasError(cell: CodeCell): boolean {
133
+ const model = cell.outputArea.model;
134
+ for (let i = 0; i < model.length; i++) {
135
+ if (model.get(i).type === 'error') {
136
+ return true;
137
+ }
138
+ }
139
+ return false;
140
+ }
141
+
142
+ export function cellOutputAsText(cell: CodeCell): string {
143
+ let content = '';
144
+ const outputs = cell.outputArea.model.toJSON();
145
+ for (const output of outputs) {
146
+ if (output.output_type === 'execute_result') {
147
+ const data =
148
+ typeof output.data === 'object' && output.data !== null
149
+ ? (output.data as PartialJSONObject)['text/plain']
150
+ : undefined;
151
+ content += joinMultilineString(data);
152
+ } else if (output.output_type === 'stream') {
153
+ content += joinMultilineString(output.text) + '\n';
154
+ } else if (output.output_type === 'error') {
155
+ // Skip errors without a traceback to match historical behavior of this
156
+ // function; the head-only case is intentional here.
157
+ if (Array.isArray(output.traceback)) {
158
+ content += formatJupyterError(output) + '\n';
159
+ }
160
+ }
161
+ }
162
+
163
+ return content;
164
+ }
165
+
166
+ // nbformat allows text-shaped output fields (stream `text`, `data['text/plain']`,
167
+ // `data['text/html']`, etc.) to be either a single string or a list of strings,
168
+ // joined with the empty string. Some kernels (e.g. older IPython, R) emit the
169
+ // list form for multi-line output. Plain `String([...])` coerces a list to
170
+ // `"a,b,c"` — wrong for both display and tokenization. Centralize the join.
171
+ export function joinMultilineString(value: unknown): string {
172
+ if (value === null || value === undefined) {
173
+ return '';
174
+ }
175
+ if (Array.isArray(value)) {
176
+ return value
177
+ .map(v => (v === null || v === undefined ? '' : String(v)))
178
+ .join('');
179
+ }
180
+ return String(value);
181
+ }
182
+
183
+ export function getTokenCount(source: string): number {
184
+ const tokens = tiktoken_encoding.encode(source);
185
+ return tokens.length;
186
+ }
187
+
188
+ // Encode once, slice the token array, decode back. Avoids the O(log n)
189
+ // re-encoding a binary search would do on every truncation. Returns
190
+ // `truncated: true` when the input exceeded the cap so callers don't need a
191
+ // second `getTokenCount` pass to detect truncation.
192
+ export function truncateToTokenCount(
193
+ text: string,
194
+ maxTokens: number
195
+ ): { text: string; size: number; truncated: boolean } {
196
+ if (maxTokens <= 0 || text.length === 0) {
197
+ return { text: '', size: 0, truncated: text.length > 0 };
198
+ }
199
+ const tokens = tiktoken_encoding.encode(text);
200
+ if (tokens.length <= maxTokens) {
201
+ return { text, size: tokens.length, truncated: false };
202
+ }
203
+ const sliced = tokens.slice(0, maxTokens);
204
+ const bytes = tiktoken_encoding.decode(sliced);
205
+ const decoded = new TextDecoder('utf-8').decode(bytes);
206
+ return { text: decoded, size: sliced.length, truncated: true };
207
+ }
208
+
209
+ export function compareSelectionPoints(
210
+ lhs: CodeEditor.IPosition,
211
+ rhs: CodeEditor.IPosition
212
+ ): boolean {
213
+ return lhs.line === rhs.line && lhs.column === rhs.column;
214
+ }
215
+
216
+ export function compareSelections(
217
+ lhs: CodeEditor.IRange,
218
+ rhs: CodeEditor.IRange
219
+ ): boolean {
220
+ // if one undefined
221
+ if ((!lhs || !rhs) && !(!lhs && !rhs)) {
222
+ return true;
223
+ }
224
+
225
+ return (
226
+ lhs === rhs ||
227
+ (compareSelectionPoints(lhs.start, rhs.start) &&
228
+ compareSelectionPoints(lhs.end, rhs.end))
229
+ );
230
+ }
231
+
232
+ export function isSelectionEmpty(selection: CodeEditor.IRange): boolean {
233
+ return (
234
+ selection.start.line === selection.end.line &&
235
+ selection.start.column === selection.end.column
236
+ );
237
+ }
238
+
239
+ export function getSelectionInEditor(editor: CodeEditor.IEditor): string {
240
+ const selection = editor.getSelection();
241
+ const startOffset = editor.getOffsetAt(selection.start);
242
+ const endOffset = editor.getOffsetAt(selection.end);
243
+ return editor.model.sharedModel.getSource().substring(startOffset, endOffset);
244
+ }
245
+
246
+ export function getWholeNotebookContent(np: NotebookPanel): string {
247
+ let content = '';
248
+ for (const cell of np.content.widgets) {
249
+ const cellModel = cell.model.sharedModel;
250
+ if (cellModel.cell_type === 'code') {
251
+ content += cellModel.source + '\n';
252
+ } else if (cellModel.cell_type === 'markdown') {
253
+ content += markdownToComment(cellModel.source) + '\n';
254
+ }
255
+ }
256
+
257
+ return content;
258
+ }
259
+
260
+ export function applyCodeToSelectionInEditor(
261
+ editor: CodeEditor.IEditor,
262
+ code: string
263
+ ) {
264
+ const selection = editor.getSelection();
265
+ const selectionStartOffset = editor.getOffsetAt(selection.start);
266
+ const selectionEndOffset = editor.getOffsetAt(selection.end);
267
+ const startOffset = Math.min(selectionStartOffset, selectionEndOffset);
268
+ const endOffset = Math.max(selectionStartOffset, selectionEndOffset);
269
+ const cursorOffset = startOffset + code.length;
270
+ const codeMirrorEditor = editor as CodeEditor.IEditor & {
271
+ editor?: {
272
+ dispatch: (spec: {
273
+ changes: { from: number; to: number; insert: string };
274
+ selection: { anchor: number };
275
+ scrollIntoView: boolean;
276
+ }) => void;
277
+ };
278
+ };
279
+
280
+ if (codeMirrorEditor.editor?.dispatch) {
281
+ codeMirrorEditor.editor.dispatch({
282
+ changes: { from: startOffset, to: endOffset, insert: code },
283
+ selection: { anchor: cursorOffset },
284
+ scrollIntoView: true
285
+ });
286
+ } else {
287
+ editor.model.sharedModel.updateSource(startOffset, endOffset, code);
288
+ }
289
+
290
+ const cursorLine = Math.min(
291
+ editor.getPositionAt(cursorOffset).line,
292
+ editor.lineCount - 1
293
+ );
294
+ const cursorColumn = editor.getLine(cursorLine)?.length || 0;
295
+ editor.setCursorPosition({
296
+ line: cursorLine,
297
+ column: cursorColumn
298
+ });
299
+ }
300
+
301
+ export { shellSingleQuote };
302
+
303
+ const SAFE_ANCHOR_SCHEMES = new Set(['http', 'https', 'mailto']);
304
+ const SCHEME_RE = /^([A-Za-z][A-Za-z0-9+.-]*):/;
305
+ // Hard cap on URI length to short-circuit pathological inputs. Mirrors
306
+ // the Python side; modern browsers truncate URLs well below this and an
307
+ // anchor URI any longer is almost certainly hostile or malformed.
308
+ const MAX_ANCHOR_URI_LEN = 8192;
309
+
310
+ function isDisallowedUriCodepoint(code: number): boolean {
311
+ // C0 + DEL are stripped from the scheme by some browser URL parsers
312
+ // ahead of evaluation, so a tab/newline inside "javascript" would unmask.
313
+ // C1 (0x80-0x9F) plus the Unicode format/BiDi/zero-width marks listed
314
+ // below do not un-mask a forbidden scheme in modern browsers, but they
315
+ // can visually impersonate the URI in the title, so reject them too.
316
+ // Ranges intentionally mirror the Python ``_DISALLOWED_URI_CODEPOINTS``
317
+ // set so a URI rejected on one side is rejected on the other.
318
+ if (code <= 0x1f || code === 0x7f) {
319
+ return true;
320
+ }
321
+ if (code >= 0x80 && code <= 0x9f) {
322
+ return true;
323
+ }
324
+ if (code === 0x0085 || code === 0x00a0) {
325
+ return true;
326
+ }
327
+ if (code === 0x2028 || code === 0x2029 || code === 0xfeff) {
328
+ return true;
329
+ }
330
+ if (code >= 0x200b && code <= 0x200f) {
331
+ return true;
332
+ }
333
+ if (code >= 0x202a && code <= 0x202e) {
334
+ return true;
335
+ }
336
+ if (code >= 0x2066 && code <= 0x206f) {
337
+ return true;
338
+ }
339
+ return false;
340
+ }
341
+
342
+ /**
343
+ * Return `uri` if its scheme is in the chat-anchor allowlist, else null.
344
+ * Mirrors the server-side `safe_anchor_uri` check so that anchor parts
345
+ * coming from arbitrary LLM/tool output cannot render `javascript:`,
346
+ * `data:`, `vbscript:`, `blob:`, or other dangerous schemes through React's
347
+ * `href` attribute. The server applies the same filter at emit time; this
348
+ * is defense in depth for stream replays, persisted history, and any path
349
+ * that injects anchor parts directly into the React tree.
350
+ */
351
+ export function safeAnchorUri(uri: string | undefined | null): string | null {
352
+ if (typeof uri !== 'string') {
353
+ return null;
354
+ }
355
+ if (uri.length > MAX_ANCHOR_URI_LEN) {
356
+ return null;
357
+ }
358
+ // Scan the original input. String.prototype.trim() drops NBSP, NEL, LS,
359
+ // PS, BOM, and other Unicode whitespace, so a check after trim would let
360
+ // those codepoints slip past as a trailing edge.
361
+ for (let i = 0; i < uri.length; i++) {
362
+ if (isDisallowedUriCodepoint(uri.charCodeAt(i))) {
363
+ return null;
364
+ }
365
+ }
366
+ const stripped = uri.trim();
367
+ if (stripped.length === 0) {
368
+ return null;
369
+ }
370
+ const match = SCHEME_RE.exec(stripped);
371
+ if (!match) {
372
+ return null;
373
+ }
374
+ if (!SAFE_ANCHOR_SCHEMES.has(match[1].toLowerCase())) {
375
+ return null;
376
+ }
377
+ return stripped;
378
+ }
379
+
380
+ /**
381
+ * Build a `claude --resume <id>` command wrapped in `cd <cwd>` so the
382
+ * resulting one-liner works from any terminal. `claude --resume` is
383
+ * cwd-scoped — it looks up the transcript under the encoded form of the
384
+ * user's CURRENT shell cwd — so the bare id alone only works when the
385
+ * user happens to be in the JupyterLab working directory.
386
+ */
387
+ export function buildResumeCommand(cwd: string, sessionId: string): string {
388
+ if (!cwd) {
389
+ return `claude --resume ${sessionId}`;
390
+ }
391
+ return `cd ${shellSingleQuote(cwd)} && claude --resume ${sessionId}`;
392
+ }
393
+
394
+ /**
395
+ * Write `text` to the system clipboard. Falls back to a hidden textarea +
396
+ * `document.execCommand('copy')` when the async Clipboard API is unavailable
397
+ * or rejects (e.g. missing permission, insecure context).
398
+ */
399
+ export async function writeTextToClipboard(text: string): Promise<boolean> {
400
+ try {
401
+ if (
402
+ typeof navigator !== 'undefined' &&
403
+ navigator.clipboard &&
404
+ typeof navigator.clipboard.writeText === 'function'
405
+ ) {
406
+ await navigator.clipboard.writeText(text);
407
+ return true;
408
+ }
409
+ } catch {
410
+ // fall through to legacy path
411
+ }
412
+
413
+ if (typeof document === 'undefined') {
414
+ return false;
415
+ }
416
+ try {
417
+ const textarea = document.createElement('textarea');
418
+ textarea.value = text;
419
+ textarea.setAttribute('readonly', '');
420
+ textarea.style.position = 'absolute';
421
+ textarea.style.left = '-9999px';
422
+ document.body.appendChild(textarea);
423
+ textarea.select();
424
+ const ok = document.execCommand('copy');
425
+ document.body.removeChild(textarea);
426
+ return ok;
427
+ } catch {
428
+ return false;
429
+ }
430
+ }
431
+
432
+ // Prompt the user for a start directory for a coding-agent terminal.
433
+ // Returns the chosen path relative to the Jupyter server root (`''`
434
+ // means the root itself — a valid selection, not a sentinel), or
435
+ // `undefined` if the user cancelled the dialog.
436
+ export async function chooseWorkspaceDirectory(
437
+ docManager: IDocumentManager,
438
+ label: string,
439
+ defaultPath?: string
440
+ ): Promise<string | undefined> {
441
+ const result = await FileDialog.getExistingDirectory({
442
+ manager: docManager,
443
+ title: label,
444
+ label,
445
+ defaultPath
446
+ });
447
+ if (!result.button.accept) {
448
+ return undefined;
449
+ }
450
+ // JupyterLab's FileDialog falls back to the file browser's current
451
+ // path when nothing is selected, so result.value is normally a
452
+ // single-element array. The empty-array branch is purely defensive.
453
+ const value = result.value;
454
+ return value && value.length > 0 ? value[0].path : (defaultPath ?? '');
455
+ }