@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
@@ -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
+ }