@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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
|
|
3
|
+
import { CodeCell } from '@jupyterlab/cells';
|
|
4
|
+
import {
|
|
5
|
+
formatJupyterError,
|
|
6
|
+
joinMultilineString,
|
|
7
|
+
truncateToTokenCount
|
|
8
|
+
} from './utils';
|
|
9
|
+
import { IOutputContextItem } from './tokens';
|
|
10
|
+
|
|
11
|
+
export const MAX_TOKENS_PER_OUTPUT = 4000;
|
|
12
|
+
export const MAX_TOKENS_PER_TURN = 8000;
|
|
13
|
+
// Above this raw base64 length (~150 KiB decoded), we drop the image and
|
|
14
|
+
// emit a placeholder rather than blow up the context window.
|
|
15
|
+
const MAX_IMAGE_BASE64_BYTES = 200_000;
|
|
16
|
+
|
|
17
|
+
export type IMimeBundle = IOutputContextItem['mimeBundles'][number];
|
|
18
|
+
export type IOutputContextBundle = IOutputContextItem;
|
|
19
|
+
|
|
20
|
+
export interface IBundleOptions {
|
|
21
|
+
maxTokensPerOutput?: number;
|
|
22
|
+
maxTokensPerTurn?: number;
|
|
23
|
+
supportsVision?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ICellLike {
|
|
27
|
+
outputArea: { model: { toJSON: () => any[] } };
|
|
28
|
+
model?: { sharedModel?: { getSource: () => string } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function cellOutputAsContextBundle(
|
|
32
|
+
cell: CodeCell | ICellLike,
|
|
33
|
+
opts: IBundleOptions = {}
|
|
34
|
+
): IOutputContextBundle {
|
|
35
|
+
const maxOut = opts.maxTokensPerOutput ?? MAX_TOKENS_PER_OUTPUT;
|
|
36
|
+
const maxTurn = opts.maxTokensPerTurn ?? MAX_TOKENS_PER_TURN;
|
|
37
|
+
const supportsVision = opts.supportsVision ?? false;
|
|
38
|
+
|
|
39
|
+
const cellLike = cell as ICellLike;
|
|
40
|
+
const cellSource = cellLike.model?.sharedModel?.getSource() ?? '';
|
|
41
|
+
const outputs = cellLike.outputArea.model.toJSON();
|
|
42
|
+
|
|
43
|
+
const mimeBundles: IMimeBundle[] = [];
|
|
44
|
+
let truncated = false;
|
|
45
|
+
let isError = false;
|
|
46
|
+
let totalTokens = 0;
|
|
47
|
+
|
|
48
|
+
const remaining = (): number => Math.max(0, maxTurn - totalTokens);
|
|
49
|
+
|
|
50
|
+
const pushBundle = (mimeType: string, data: string): void => {
|
|
51
|
+
const perOutCap = Math.min(maxOut, remaining());
|
|
52
|
+
const {
|
|
53
|
+
text,
|
|
54
|
+
size,
|
|
55
|
+
truncated: didTruncate
|
|
56
|
+
} = truncateToTokenCount(data, perOutCap);
|
|
57
|
+
if (didTruncate) {
|
|
58
|
+
truncated = true;
|
|
59
|
+
}
|
|
60
|
+
if (size === 0 && text.length === 0) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
mimeBundles.push({ mimeType, data: text, sizeTokens: size });
|
|
64
|
+
totalTokens += size;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
for (const output of outputs) {
|
|
68
|
+
if (remaining() === 0) {
|
|
69
|
+
truncated = true;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (output.output_type === 'error') {
|
|
74
|
+
isError = true;
|
|
75
|
+
pushBundle('application/vnd.jupyter.error', formatJupyterError(output));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (output.output_type === 'stream') {
|
|
80
|
+
pushBundle('text/plain', joinMultilineString(output.text));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// execute_result and display_data
|
|
85
|
+
const data = (output.data ?? {}) as Record<string, any>;
|
|
86
|
+
|
|
87
|
+
// DataFrames render as both text/html and text/plain; prefer the plain
|
|
88
|
+
// form — it's the formatted ASCII table, cheaper, and an LLM reads it
|
|
89
|
+
// just fine. text/html alone is stripped to plain text below.
|
|
90
|
+
// Use ``in`` rather than truthy check: an empty array ``[]`` is legal
|
|
91
|
+
// (if rare) nbformat for ``text/plain`` but falsy in JS, which would
|
|
92
|
+
// otherwise let the html branch fire over an explicit-but-empty plain
|
|
93
|
+
// entry.
|
|
94
|
+
if ('text/plain' in data) {
|
|
95
|
+
pushBundle('text/plain', joinMultilineString(data['text/plain']));
|
|
96
|
+
} else if ('text/html' in data) {
|
|
97
|
+
pushBundle(
|
|
98
|
+
'text/html',
|
|
99
|
+
stripHtml(joinMultilineString(data['text/html']))
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (data['application/vnd.plotly.v1+json']) {
|
|
104
|
+
pushBundle(
|
|
105
|
+
'application/vnd.plotly.v1+json',
|
|
106
|
+
summarizePlotly(data['application/vnd.plotly.v1+json'])
|
|
107
|
+
);
|
|
108
|
+
} else if (data['application/json']) {
|
|
109
|
+
pushBundle(
|
|
110
|
+
'application/json',
|
|
111
|
+
JSON.stringify(data['application/json'], null, 2)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const imgMime of ['image/png', 'image/jpeg']) {
|
|
116
|
+
if (!data[imgMime]) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const raw = joinMultilineString(data[imgMime]);
|
|
120
|
+
if (!supportsVision) {
|
|
121
|
+
pushBundle(imgMime, '<image omitted: model lacks vision support>');
|
|
122
|
+
} else if (raw.length > MAX_IMAGE_BASE64_BYTES) {
|
|
123
|
+
pushBundle(imgMime, '<image omitted: too large for inline attachment>');
|
|
124
|
+
truncated = true;
|
|
125
|
+
} else if (!isValidBase64(raw)) {
|
|
126
|
+
pushBundle(imgMime, '<image omitted: invalid base64 payload>');
|
|
127
|
+
} else {
|
|
128
|
+
// Send raw base64; the server constructs the data URL after
|
|
129
|
+
// re-validating, so a forged POST can't inject markdown.
|
|
130
|
+
pushBundle(imgMime, raw);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { cellSource, mimeBundles, isError, truncated };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isValidBase64(s: string): boolean {
|
|
139
|
+
// Standard base64 alphabet only. No URL-safe chars; no characters that
|
|
140
|
+
// could break out of a markdown image URL on the server side.
|
|
141
|
+
const compact = s.replace(/\s+/g, '');
|
|
142
|
+
return /^[A-Za-z0-9+/]+=*$/.test(compact);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function stripHtml(html: string): string {
|
|
146
|
+
// NOTE: not a sanitizer. Output is intended only for LLM context; never
|
|
147
|
+
// route this back into the DOM without a real sanitizer.
|
|
148
|
+
// Replace block-level closers with a newline so structure survives, then
|
|
149
|
+
// strip the remaining tags. Cheap and good enough for LLM consumption.
|
|
150
|
+
return html
|
|
151
|
+
.replace(/<\/(p|div|tr|li|h[1-6]|br)\s*\/?>/gi, '\n')
|
|
152
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
153
|
+
.replace(/<[^>]+>/g, '')
|
|
154
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
155
|
+
.trim();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function summarizePlotly(payload: any): string {
|
|
159
|
+
const lines: string[] = [];
|
|
160
|
+
const traces = Array.isArray(payload?.data) ? payload.data : [];
|
|
161
|
+
const types = traces.map((t: any) => t?.type ?? 'trace').join(', ');
|
|
162
|
+
if (types) {
|
|
163
|
+
lines.push(`Plotly figure traces: ${types}`);
|
|
164
|
+
}
|
|
165
|
+
const layout = payload?.layout ?? {};
|
|
166
|
+
if (layout.title?.text) {
|
|
167
|
+
lines.push(`Title: ${layout.title.text}`);
|
|
168
|
+
}
|
|
169
|
+
if (layout.xaxis?.title?.text) {
|
|
170
|
+
lines.push(`X axis: ${layout.xaxis.title.text}`);
|
|
171
|
+
}
|
|
172
|
+
if (layout.yaxis?.title?.text) {
|
|
173
|
+
lines.push(`Y axis: ${layout.yaxis.title.text}`);
|
|
174
|
+
}
|
|
175
|
+
return lines.join('\n');
|
|
176
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
|
|
3
|
+
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
4
|
+
import { CodeCell } from '@jupyterlab/cells';
|
|
5
|
+
import { NotebookPanel } from '@jupyterlab/notebook';
|
|
6
|
+
import { LabIcon } from '@jupyterlab/ui-components';
|
|
7
|
+
import { CommandRegistry } from '@lumino/commands';
|
|
8
|
+
import { IDisposable } from '@lumino/disposable';
|
|
9
|
+
import { CellOutputActionFlag, NBIAPI } from './api';
|
|
10
|
+
import { cellOutputHasError } from './utils';
|
|
11
|
+
import sparkleSvgstr from '../style/icons/cell-toolbar-sparkle.svg';
|
|
12
|
+
import chatSvgstr from '../style/icons/cell-toolbar-chat.svg';
|
|
13
|
+
import bugSvgstr from '../style/icons/cell-toolbar-bug.svg';
|
|
14
|
+
|
|
15
|
+
interface IToolbarAction {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
title: string;
|
|
19
|
+
icon: LabIcon;
|
|
20
|
+
command: string;
|
|
21
|
+
/** Hide the button when this feature flag is disabled. */
|
|
22
|
+
featureFlag: CellOutputActionFlag;
|
|
23
|
+
/** Only show when the cell has at least one error output. */
|
|
24
|
+
requireError?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const TOOLBAR_CLASS = 'nbi-cell-output-toolbar';
|
|
28
|
+
const BUTTON_CLASS = 'nbi-cell-output-toolbar-button';
|
|
29
|
+
|
|
30
|
+
// SVGs live under style/icons/ and are loaded via LabIcon to match the rest
|
|
31
|
+
// of the project's icon plumbing. The source assets are Microsoft's
|
|
32
|
+
// vscode-codicons (sparkle, comment-discussion, bug), CC BY 4.0 — the
|
|
33
|
+
// license header lives inside each .svg file.
|
|
34
|
+
const sparkleIcon = new LabIcon({
|
|
35
|
+
name: 'notebook-intelligence:cell-toolbar-sparkle',
|
|
36
|
+
svgstr: sparkleSvgstr
|
|
37
|
+
});
|
|
38
|
+
const chatIcon = new LabIcon({
|
|
39
|
+
name: 'notebook-intelligence:cell-toolbar-chat',
|
|
40
|
+
svgstr: chatSvgstr
|
|
41
|
+
});
|
|
42
|
+
const bugIcon = new LabIcon({
|
|
43
|
+
name: 'notebook-intelligence:cell-toolbar-bug',
|
|
44
|
+
svgstr: bugSvgstr
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const ACTIONS: IToolbarAction[] = [
|
|
48
|
+
{
|
|
49
|
+
id: 'explain',
|
|
50
|
+
label: 'Explain',
|
|
51
|
+
title: "Explain this cell's output",
|
|
52
|
+
icon: sparkleIcon,
|
|
53
|
+
command: 'notebook-intelligence:editor-explain-this-output',
|
|
54
|
+
featureFlag: 'output_followup'
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: 'ask',
|
|
58
|
+
label: 'Ask',
|
|
59
|
+
title: 'Ask about this output',
|
|
60
|
+
icon: chatIcon,
|
|
61
|
+
command: 'notebook-intelligence:editor-ask-about-this-output',
|
|
62
|
+
featureFlag: 'output_followup'
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'troubleshoot',
|
|
66
|
+
label: 'Troubleshoot',
|
|
67
|
+
title: 'Troubleshoot the error in this cell',
|
|
68
|
+
icon: bugIcon,
|
|
69
|
+
command: 'notebook-intelligence:editor-troubleshoot-this-output',
|
|
70
|
+
featureFlag: 'explain_error',
|
|
71
|
+
requireError: true
|
|
72
|
+
}
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Show a hover toolbar over Jupyter cell outputs that surfaces the existing
|
|
77
|
+
* Explain / Ask / Troubleshoot commands as one-click buttons.
|
|
78
|
+
*
|
|
79
|
+
* The toolbar respects the `output_toolbar` feature flag (whole-toolbar
|
|
80
|
+
* gate) and the per-action `explain_error` / `output_followup` flags so a
|
|
81
|
+
* locked-off feature stays locked off here too.
|
|
82
|
+
*/
|
|
83
|
+
export class CellOutputHoverToolbar implements IDisposable {
|
|
84
|
+
private _app: JupyterFrontEnd;
|
|
85
|
+
private _commands: CommandRegistry;
|
|
86
|
+
private _disposed = false;
|
|
87
|
+
private _activeArea: HTMLElement | null = null;
|
|
88
|
+
private _onMouseOver: (event: MouseEvent) => void;
|
|
89
|
+
private _onMouseLeave: () => void;
|
|
90
|
+
|
|
91
|
+
constructor(app: JupyterFrontEnd, commands: CommandRegistry) {
|
|
92
|
+
this._app = app;
|
|
93
|
+
// The Explain / Ask / Troubleshoot commands live on the context-menu's
|
|
94
|
+
// private CommandRegistry, not on `app.commands`, so callers must pass
|
|
95
|
+
// the same registry the menu uses.
|
|
96
|
+
this._commands = commands;
|
|
97
|
+
this._onMouseOver = this._handleMouseOver.bind(this);
|
|
98
|
+
// mouseleave only fires when the cursor exits the area entirely —
|
|
99
|
+
// descendants (including the toolbar itself) don't trigger it.
|
|
100
|
+
this._onMouseLeave = this._removeActiveToolbar.bind(this);
|
|
101
|
+
document.body.addEventListener('mouseover', this._onMouseOver);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get isDisposed(): boolean {
|
|
105
|
+
return this._disposed;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
dispose(): void {
|
|
109
|
+
if (this._disposed) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this._disposed = true;
|
|
113
|
+
document.body.removeEventListener('mouseover', this._onMouseOver);
|
|
114
|
+
this._removeActiveToolbar();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private _handleMouseOver(event: MouseEvent): void {
|
|
118
|
+
if (!NBIAPI.config.cellOutputFeatures.output_toolbar.enabled) {
|
|
119
|
+
this._removeActiveToolbar();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const target = event.target as HTMLElement | null;
|
|
123
|
+
if (!target) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const area = target.closest<HTMLElement>('.jp-Cell-outputArea');
|
|
127
|
+
if (!area) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (area === this._activeArea) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this._removeActiveToolbar();
|
|
134
|
+
const cellEl = area.closest<HTMLElement>('.jp-Cell');
|
|
135
|
+
if (!cellEl) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const located = this._locateCell(cellEl);
|
|
139
|
+
if (!located) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const toolbar = this._buildToolbar(
|
|
143
|
+
located.panel,
|
|
144
|
+
located.cellIndex,
|
|
145
|
+
located.cell
|
|
146
|
+
);
|
|
147
|
+
if (!toolbar) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
area.appendChild(toolbar);
|
|
151
|
+
this._activeArea = area;
|
|
152
|
+
area.addEventListener('mouseleave', this._onMouseLeave);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private _removeActiveToolbar(): void {
|
|
156
|
+
if (!this._activeArea) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this._activeArea.removeEventListener('mouseleave', this._onMouseLeave);
|
|
160
|
+
const existing = this._activeArea.querySelector(`.${TOOLBAR_CLASS}`);
|
|
161
|
+
if (existing) {
|
|
162
|
+
existing.remove();
|
|
163
|
+
}
|
|
164
|
+
this._activeArea = null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private _locateCell(
|
|
168
|
+
cellEl: HTMLElement
|
|
169
|
+
): { panel: NotebookPanel; cell: CodeCell; cellIndex: number } | null {
|
|
170
|
+
const widget = this._app.shell.currentWidget;
|
|
171
|
+
if (!(widget instanceof NotebookPanel)) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const widgets = widget.content.widgets;
|
|
175
|
+
for (let i = 0; i < widgets.length; i++) {
|
|
176
|
+
const cell = widgets[i];
|
|
177
|
+
if (cell.node === cellEl && cell instanceof CodeCell) {
|
|
178
|
+
return { panel: widget, cell, cellIndex: i };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private _buildToolbar(
|
|
185
|
+
panel: NotebookPanel,
|
|
186
|
+
cellIndex: number,
|
|
187
|
+
cell: CodeCell
|
|
188
|
+
): HTMLElement | null {
|
|
189
|
+
const features = NBIAPI.config.cellOutputFeatures;
|
|
190
|
+
const hasError = cellOutputHasError(cell);
|
|
191
|
+
|
|
192
|
+
const visible = ACTIONS.filter(a => {
|
|
193
|
+
if (!features[a.featureFlag].enabled) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
if (a.requireError && !hasError) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
});
|
|
201
|
+
if (visible.length === 0) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const toolbar = document.createElement('div');
|
|
206
|
+
toolbar.className = TOOLBAR_CLASS;
|
|
207
|
+
toolbar.setAttribute('role', 'toolbar');
|
|
208
|
+
toolbar.setAttribute('aria-label', 'Notebook Intelligence cell actions');
|
|
209
|
+
|
|
210
|
+
for (const action of visible) {
|
|
211
|
+
const button = document.createElement('button');
|
|
212
|
+
button.type = 'button';
|
|
213
|
+
button.className = BUTTON_CLASS;
|
|
214
|
+
button.title = action.title;
|
|
215
|
+
button.setAttribute('aria-label', action.title);
|
|
216
|
+
action.icon.element({ container: button, tag: 'span' });
|
|
217
|
+
const label = document.createElement('span');
|
|
218
|
+
label.className = `${BUTTON_CLASS}-label`;
|
|
219
|
+
label.textContent = action.label;
|
|
220
|
+
button.appendChild(label);
|
|
221
|
+
button.addEventListener('click', event => {
|
|
222
|
+
event.stopPropagation();
|
|
223
|
+
// The editor commands act on the active cell, so activate the
|
|
224
|
+
// hovered one first.
|
|
225
|
+
panel.content.activeCellIndex = cellIndex;
|
|
226
|
+
void this._commands.execute(action.command);
|
|
227
|
+
});
|
|
228
|
+
toolbar.appendChild(button);
|
|
229
|
+
}
|
|
230
|
+
return toolbar;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
|
|
3
|
+
// Small helpers for the chat sidebar's "Generating" progress indicator.
|
|
4
|
+
// Pulled out so each piece (elapsed-time formatting, heartbeat staleness)
|
|
5
|
+
// can be unit-tested without dragging JupyterLab into Jest's import graph.
|
|
6
|
+
|
|
7
|
+
// 30 seconds without a heartbeat counts as "stalled" — long enough that a
|
|
8
|
+
// healthy round-trip + retransmission delay can't account for it, short
|
|
9
|
+
// enough that a real hang is signaled while there's still time to act.
|
|
10
|
+
// The Claude heartbeat fires every 20s server-side, so 30s gives roughly
|
|
11
|
+
// 1.5 expected intervals of slack before we change copy.
|
|
12
|
+
export const HEARTBEAT_STALE_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
export function formatElapsedSeconds(totalSeconds: number): string {
|
|
15
|
+
const safe = Math.max(0, Math.floor(totalSeconds));
|
|
16
|
+
const hours = Math.floor(safe / 3600);
|
|
17
|
+
const minutes = Math.floor((safe % 3600) / 60);
|
|
18
|
+
const seconds = safe % 60;
|
|
19
|
+
const ss = String(seconds).padStart(2, '0');
|
|
20
|
+
if (hours > 0) {
|
|
21
|
+
return `${hours}:${String(minutes).padStart(2, '0')}:${ss}`;
|
|
22
|
+
}
|
|
23
|
+
return `${minutes}:${ss}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isHeartbeatStale(
|
|
27
|
+
lastHeartbeatAt: number | null,
|
|
28
|
+
now: number,
|
|
29
|
+
staleMs: number = HEARTBEAT_STALE_MS
|
|
30
|
+
): boolean {
|
|
31
|
+
if (lastHeartbeatAt === null) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return now - lastHeartbeatAt > staleMs;
|
|
35
|
+
}
|