@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.
- package/LICENSE +674 -0
- package/README.md +412 -0
- package/lib/api.d.ts +288 -0
- package/lib/api.js +927 -0
- package/lib/cell-output-bundle.d.ts +25 -0
- package/lib/cell-output-bundle.js +129 -0
- package/lib/cell-output-toolbar.d.ts +26 -0
- package/lib/cell-output-toolbar.js +188 -0
- package/lib/chat-progress-feedback.d.ts +3 -0
- package/lib/chat-progress-feedback.js +27 -0
- package/lib/chat-sidebar.d.ts +92 -0
- package/lib/chat-sidebar.js +3452 -0
- package/lib/command-ids.d.ts +39 -0
- package/lib/command-ids.js +44 -0
- package/lib/components/ask-user-question.d.ts +2 -0
- package/lib/components/ask-user-question.js +85 -0
- package/lib/components/checkbox.d.ts +2 -0
- package/lib/components/checkbox.js +30 -0
- package/lib/components/claude-mcp-panel.d.ts +2 -0
- package/lib/components/claude-mcp-panel.js +275 -0
- package/lib/components/claude-mcp-paste.d.ts +7 -0
- package/lib/components/claude-mcp-paste.js +104 -0
- package/lib/components/claude-session-picker.d.ts +8 -0
- package/lib/components/claude-session-picker.js +127 -0
- package/lib/components/form-dialog.d.ts +25 -0
- package/lib/components/form-dialog.js +35 -0
- package/lib/components/launcher-picker.d.ts +6 -0
- package/lib/components/launcher-picker.js +135 -0
- package/lib/components/mcp-util.d.ts +2 -0
- package/lib/components/mcp-util.js +37 -0
- package/lib/components/notebook-generation-popover.d.ts +7 -0
- package/lib/components/notebook-generation-popover.js +60 -0
- package/lib/components/pill.d.ts +2 -0
- package/lib/components/pill.js +5 -0
- package/lib/components/plugins-panel.d.ts +3 -0
- package/lib/components/plugins-panel.js +466 -0
- package/lib/components/settings-panel.d.ts +11 -0
- package/lib/components/settings-panel.js +742 -0
- package/lib/components/skills-panel.d.ts +2 -0
- package/lib/components/skills-panel.js +1264 -0
- package/lib/handler.d.ts +8 -0
- package/lib/handler.js +36 -0
- package/lib/icons.d.ts +45 -0
- package/lib/icons.js +54 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +2079 -0
- package/lib/markdown-renderer.d.ts +10 -0
- package/lib/markdown-renderer.js +64 -0
- package/lib/notebook-generation-toolbar.d.ts +16 -0
- package/lib/notebook-generation-toolbar.js +197 -0
- package/lib/notebook-generation.d.ts +8 -0
- package/lib/notebook-generation.js +12 -0
- package/lib/open-file-refresh-watcher-env.d.ts +4 -0
- package/lib/open-file-refresh-watcher-env.js +33 -0
- package/lib/open-file-refresh-watcher.d.ts +97 -0
- package/lib/open-file-refresh-watcher.js +190 -0
- package/lib/shell-utils.d.ts +6 -0
- package/lib/shell-utils.js +9 -0
- package/lib/task-target-notebook.d.ts +2 -0
- package/lib/task-target-notebook.js +28 -0
- package/lib/terminal-drag-format.d.ts +9 -0
- package/lib/terminal-drag-format.js +23 -0
- package/lib/terminal-drag.d.ts +12 -0
- package/lib/terminal-drag.js +268 -0
- package/lib/tokens.d.ts +149 -0
- package/lib/tokens.js +88 -0
- package/lib/tour/tour-anchors.d.ts +18 -0
- package/lib/tour/tour-anchors.js +18 -0
- package/lib/tour/tour-config.d.ts +66 -0
- package/lib/tour/tour-config.js +99 -0
- package/lib/tour/tour-defaults.json +58 -0
- package/lib/tour/tour-events.d.ts +19 -0
- package/lib/tour/tour-events.js +30 -0
- package/lib/tour/tour-overlay.d.ts +6 -0
- package/lib/tour/tour-overlay.js +350 -0
- package/lib/tour/tour-state.d.ts +20 -0
- package/lib/tour/tour-state.js +81 -0
- package/lib/tour/tour-steps.d.ts +33 -0
- package/lib/tour/tour-steps.js +216 -0
- package/lib/utils.d.ts +53 -0
- package/lib/utils.js +385 -0
- package/package.json +258 -0
- package/schema/plugin.json +42 -0
- package/src/api.ts +1424 -0
- package/src/cell-output-bundle.ts +176 -0
- package/src/cell-output-toolbar.ts +232 -0
- package/src/chat-progress-feedback.ts +35 -0
- package/src/chat-sidebar.tsx +5147 -0
- package/src/command-ids.ts +67 -0
- package/src/components/ask-user-question.tsx +151 -0
- package/src/components/checkbox.tsx +62 -0
- package/src/components/claude-mcp-panel.tsx +543 -0
- package/src/components/claude-mcp-paste.ts +132 -0
- package/src/components/claude-session-picker.tsx +214 -0
- package/src/components/form-dialog.tsx +75 -0
- package/src/components/launcher-picker.tsx +237 -0
- package/src/components/mcp-util.ts +53 -0
- package/src/components/notebook-generation-popover.tsx +127 -0
- package/src/components/pill.tsx +15 -0
- package/src/components/plugins-panel.tsx +774 -0
- package/src/components/settings-panel.tsx +1631 -0
- package/src/components/skills-panel.tsx +2084 -0
- package/src/handler.ts +51 -0
- package/src/icons.ts +71 -0
- package/src/index.ts +2583 -0
- package/src/markdown-renderer.tsx +153 -0
- package/src/notebook-generation-toolbar.tsx +281 -0
- package/src/notebook-generation.ts +23 -0
- package/src/open-file-refresh-watcher-env.ts +52 -0
- package/src/open-file-refresh-watcher.ts +260 -0
- package/src/shell-utils.ts +10 -0
- package/src/svg.d.ts +4 -0
- package/src/task-target-notebook.ts +37 -0
- package/src/terminal-drag-format.ts +29 -0
- package/src/terminal-drag.ts +382 -0
- package/src/tokens.ts +171 -0
- package/src/tour/tour-anchors.ts +21 -0
- package/src/tour/tour-config.ts +160 -0
- package/src/tour/tour-events.ts +34 -0
- package/src/tour/tour-overlay.tsx +474 -0
- package/src/tour/tour-state.ts +87 -0
- package/src/tour/tour-steps.ts +281 -0
- package/src/utils.ts +455 -0
- package/style/base.css +3238 -0
- package/style/icons/cell-toolbar-bug.svg +5 -0
- package/style/icons/cell-toolbar-chat.svg +5 -0
- package/style/icons/cell-toolbar-sparkle.svg +5 -0
- package/style/icons/claude.svg +1 -0
- package/style/icons/copilot-warning.svg +1 -0
- package/style/icons/copilot.svg +1 -0
- package/style/icons/copy.svg +1 -0
- package/style/icons/openai.svg +1 -0
- package/style/icons/opencode.svg +1 -0
- package/style/icons/sparkles-warning.svg +5 -0
- package/style/icons/sparkles.svg +1 -0
- package/style/index.css +1 -0
- 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
|
+
}
|