@marimo-team/islands 0.23.14-dev43 → 0.23.14-dev45

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 (29) hide show
  1. package/dist/{chat-ui-CO8WuXGb.js → chat-ui-DXPRhRO2.js} +3132 -3132
  2. package/dist/{code-visibility-BCkKHNom.js → code-visibility-D-7iipEs.js} +717 -708
  3. package/dist/{html-to-image-DLSOS-bE.js → html-to-image-DOqQBSQc.js} +2236 -2208
  4. package/dist/main.js +252 -248
  5. package/dist/{process-output-B9ysqk1y.js → process-output-kGk2Jc2x.js} +1 -1
  6. package/dist/{reveal-component-CD1M60a6.js → reveal-component-BrJ-d3iB.js} +2 -2
  7. package/package.json +1 -1
  8. package/src/__mocks__/requests.ts +1 -0
  9. package/src/components/app-config/user-config-form.tsx +26 -0
  10. package/src/components/debugger/debugger-code.tsx +7 -2
  11. package/src/components/editor/notebook-cell.tsx +7 -0
  12. package/src/components/editor/output/MarimoTracebackOutput.tsx +18 -1
  13. package/src/core/cells/__tests__/cell.test.ts +39 -0
  14. package/src/core/cells/cell.ts +5 -3
  15. package/src/core/codemirror/cells/debugger-decorations.ts +188 -0
  16. package/src/core/codemirror/cells/debugger-state.ts +92 -0
  17. package/src/core/codemirror/cells/extensions.ts +22 -0
  18. package/src/core/config/feature-flag.tsx +2 -0
  19. package/src/core/islands/bootstrap.ts +1 -0
  20. package/src/core/islands/bridge.ts +1 -0
  21. package/src/core/network/requests-lazy.ts +1 -0
  22. package/src/core/network/requests-network.ts +8 -0
  23. package/src/core/network/requests-static.ts +1 -0
  24. package/src/core/network/requests-toasting.tsx +1 -0
  25. package/src/core/network/types.ts +2 -0
  26. package/src/core/wasm/bridge.ts +4 -0
  27. package/src/core/websocket/useMarimoKernelConnection.tsx +9 -0
  28. package/src/plugins/core/RenderHTML.tsx +35 -1
  29. package/src/plugins/core/__test__/RenderHTML.test.ts +44 -0
@@ -1,6 +1,6 @@
1
1
  import { s as __toESM } from "./chunk-BNovOVIE.js";
2
2
  import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
3
- import { at as parseHtmlContent, it as ansiToPlainText } from "./html-to-image-DLSOS-bE.js";
3
+ import { at as parseHtmlContent, it as ansiToPlainText } from "./html-to-image-DOqQBSQc.js";
4
4
  import { u as createLucideIcon } from "./dist--2Bqjvs0.js";
5
5
  import { t as Strings } from "./strings-Dq_j3Rxw.js";
6
6
  import { t as require_jsx_runtime } from "./jsx-runtime-DebpN0FN.js";
@@ -6,10 +6,10 @@ import { s as __toESM } from "./chunk-BNovOVIE.js";
6
6
  import { _ as Logger, g as cn, h as Events, l as useEventListener, t as Button } from "./button-BacYv-bE.js";
7
7
  import { t as require_react } from "./react-DA-nE2FX.js";
8
8
  import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
9
- import { lt as kioskModeAtom, pt as outputIsStale } from "./html-to-image-DLSOS-bE.js";
9
+ import { lt as kioskModeAtom, pt as outputIsStale } from "./html-to-image-DOqQBSQc.js";
10
10
  import "./chunk-5FQGJX7Z-BbqSm5gU.js";
11
11
  import { u as createLucideIcon } from "./dist--2Bqjvs0.js";
12
- import { a as DEFAULT_SLIDE_TYPE, c as Slide, ct as PanelGroup, i as DEFAULT_DECK_TRANSITION, lt as PanelResizeHandle, on as EyeOff, s as SlideSidebar, sn as Expand, st as Panel, t as useNotebookCodeAvailable, un as Code } from "./code-visibility-BCkKHNom.js";
12
+ import { a as DEFAULT_SLIDE_TYPE, c as Slide, ct as PanelGroup, i as DEFAULT_DECK_TRANSITION, lt as PanelResizeHandle, on as EyeOff, s as SlideSidebar, sn as Expand, st as Panel, t as useNotebookCodeAvailable, un as Code } from "./code-visibility-D-7iipEs.js";
13
13
  import { X as useDebouncedCallback } from "./input-BSdZp5Ng.js";
14
14
  import "./toDate-D1Z7ZXWh.js";
15
15
  import "./react-dom-BTJzcVJ9.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.14-dev43",
3
+ "version": "0.23.14-dev45",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -45,6 +45,7 @@ export const MockRequestClient = {
45
45
  openFile: vi.fn().mockResolvedValue({}),
46
46
  getUsageStats: vi.fn().mockResolvedValue({}),
47
47
  sendPdb: vi.fn().mockResolvedValue({}),
48
+ sendSetBreakpoints: vi.fn().mockResolvedValue({}),
48
49
  sendListFiles: vi.fn().mockResolvedValue({ files: [] }),
49
50
  sendSearchFiles: vi
50
51
  .fn()
@@ -1288,6 +1288,32 @@ export const UserConfigForm: React.FC = () => {
1288
1288
  </div>
1289
1289
  )}
1290
1290
  />
1291
+ <OverriddenFormField
1292
+ control={form.control}
1293
+ name="experimental.debugger"
1294
+ render={({ field, override }) => (
1295
+ <div className="flex flex-col gap-y-1">
1296
+ <FormItem className={formItemClasses}>
1297
+ <FormLabel className="font-normal">Debugger</FormLabel>
1298
+ <FormControl>
1299
+ <Checkbox
1300
+ data-testid="debugger-checkbox"
1301
+ checked={override.value === true}
1302
+ disabled={override.isOverridden}
1303
+ onCheckedChange={field.onChange}
1304
+ />
1305
+ </FormControl>
1306
+ </FormItem>
1307
+ <IsOverridden override={override} />
1308
+ <FormDescription>
1309
+ Enable the live debugger: a clickable breakpoint gutter,
1310
+ current-line highlighting while a cell runs, and dropping
1311
+ into <Kbd className="inline">pdb</Kbd> at breakpoints. This
1312
+ change requires a page refresh to take effect.
1313
+ </FormDescription>
1314
+ </div>
1315
+ )}
1316
+ />
1291
1317
  </SettingGroup>
1292
1318
  );
1293
1319
  }
@@ -208,7 +208,7 @@ export const DebuggerControls: React.FC<{
208
208
  </Button>
209
209
  </Tooltip>
210
210
  {onClear && (
211
- <Tooltip content="Clear">
211
+ <Tooltip content="Quit and clear">
212
212
  <Button
213
213
  variant="text"
214
214
  size="icon"
@@ -217,7 +217,12 @@ export const DebuggerControls: React.FC<{
217
217
  buttonClasses,
218
218
  "text-(--red-11) hover:text-(--red-11) hover:bg-(--red-2) hover:border-(--red-8)",
219
219
  )}
220
- onClick={onClear}
220
+ onClick={() => {
221
+ // Quit the debugger (`q`) before tearing down the console, so
222
+ // the stdin response targets the still-present pdb prompt.
223
+ onSubmit("q");
224
+ onClear();
225
+ }}
221
226
  >
222
227
  <TrashIcon fontSize={36} className={iconClasses} />
223
228
  </Button>
@@ -29,6 +29,7 @@ import { outputIsLoading, outputIsStale } from "@/core/cells/cell";
29
29
  import { isOutputEmpty } from "@/core/cells/outputs";
30
30
  import { useIsPendingCut } from "@/core/cells/pending-cut-service";
31
31
  import { autocompletionKeymap } from "@/core/codemirror/cm";
32
+ import { clearCellBreakpoints } from "@/core/codemirror/cells/debugger-state";
32
33
  import type { LanguageAdapterType } from "@/core/codemirror/language/types";
33
34
  import { CSSClasses } from "@/core/constants";
34
35
  import { canCollapseOutline } from "@/core/dom/outline";
@@ -746,6 +747,9 @@ const EditableCellComponent = ({
746
747
  onRefactorWithAI={handleRefactorWithAI}
747
748
  onClear={() => {
748
749
  actions.clearCellConsoleOutput({ cellId });
750
+ // The debugger "Clear" (trashcan) also drops this cell's
751
+ // breakpoints; no-op when none are set.
752
+ clearCellBreakpoints(cellId);
749
753
  }}
750
754
  onSubmitDebugger={(text, index) => {
751
755
  actions.setStdinResponse({
@@ -1211,6 +1215,9 @@ const SetupCellComponent = ({
1211
1215
  onRefactorWithAI={handleRefactorWithAI}
1212
1216
  onClear={() => {
1213
1217
  actions.clearCellConsoleOutput({ cellId });
1218
+ // The debugger "Clear" (trashcan) also drops this cell's
1219
+ // breakpoints; no-op when none are set.
1220
+ clearCellBreakpoints(cellId);
1214
1221
  }}
1215
1222
  onSubmitDebugger={(text, index) => {
1216
1223
  actions.setStdinResponse({
@@ -24,8 +24,10 @@ import { Tooltip } from "@/components/ui/tooltip";
24
24
  import { getCellEditorView } from "@/core/cells/cells";
25
25
  import type { CellId } from "@/core/cells/ids";
26
26
  import { SCRATCH_CELL_ID } from "@/core/cells/ids";
27
+ import { toggleBreakpoint } from "@/core/codemirror/cells/debugger-state";
27
28
  import { insertDebuggerAtLine } from "@/core/codemirror/editing/debugging";
28
29
  import { aiFeaturesEnabledAtom } from "@/core/config/config";
30
+ import { getFeatureFlag } from "@/core/config/feature-flag";
29
31
  import { getRequestClient } from "@/core/network/requests";
30
32
  import { isStaticNotebook } from "@/core/static/static-state";
31
33
  import { isWasm } from "@/core/wasm/utils";
@@ -212,7 +214,14 @@ function lastLine(text: string): string {
212
214
  export const replaceTracebackFilenames = (domNode: DOMNode) => {
213
215
  const info = getTracebackInfo(domNode);
214
216
  if (info?.kind === "cell") {
215
- const tooltipContent = <InsertBreakpointContent />;
217
+ // With the live debugger enabled, toggle a real gutter breakpoint
218
+ // instead of mutating the user's code with a `breakpoint()` statement.
219
+ const debuggerEnabled = getFeatureFlag("debugger");
220
+ const tooltipContent = debuggerEnabled ? (
221
+ <ToggleBreakpointContent />
222
+ ) : (
223
+ <InsertBreakpointContent />
224
+ );
216
225
  return (
217
226
  <span className="nb">
218
227
  <span className="inline-flex items-center">
@@ -228,6 +237,10 @@ export const replaceTracebackFilenames = (domNode: DOMNode) => {
228
237
  >
229
238
  <BugPlayIcon
230
239
  onClick={() => {
240
+ if (debuggerEnabled && !isStaticNotebook()) {
241
+ toggleBreakpoint(info.cellId, info.lineNumber);
242
+ return;
243
+ }
231
244
  const view = getCellEditorView(info.cellId);
232
245
  if (view) {
233
246
  insertDebuggerAtLine(view, info.lineNumber);
@@ -294,3 +307,7 @@ const InsertBreakpointContent = () => {
294
307
  </>
295
308
  );
296
309
  };
310
+
311
+ const ToggleBreakpointContent = () => {
312
+ return <>Toggle a breakpoint at this line</>;
313
+ };
@@ -218,3 +218,42 @@ describe("transitionCell serialization", () => {
218
218
  );
219
219
  });
220
220
  });
221
+
222
+ describe("transitionCell stdin resolution", () => {
223
+ function stdinOutput(): OutputMessage {
224
+ return {
225
+ channel: "stdin",
226
+ mimetype: "text/plain",
227
+ data: "(Pdb) ",
228
+ timestamp: Date.now() as Seconds,
229
+ } as OutputMessage;
230
+ }
231
+
232
+ it("resolves a dangling pdb prompt when the cell goes idle", () => {
233
+ // A paused-pdb cell shows an unanswered stdin prompt (the debugger box);
234
+ // once it finishes (e.g. after quitting with `q`), the box is removed.
235
+ const cell = createCellRuntimeState({
236
+ status: "running",
237
+ debuggerActive: true,
238
+ consoleOutputs: [stdinOutput()],
239
+ });
240
+ const next = transitionCell(cell, {
241
+ cell_id: "cell-1",
242
+ status: "idle",
243
+ } as CellMessage);
244
+ expect(next.debuggerActive).toBe(false);
245
+ expect(next.consoleOutputs[0].response).toBe("");
246
+ });
247
+
248
+ it("leaves an unanswered prompt interactive while still running", () => {
249
+ const cell = createCellRuntimeState({
250
+ status: "running",
251
+ consoleOutputs: [stdinOutput()],
252
+ });
253
+ const next = transitionCell(cell, {
254
+ cell_id: "cell-1",
255
+ status: null,
256
+ } as CellMessage);
257
+ expect(next.consoleOutputs[0].response).toBeUndefined();
258
+ });
259
+ });
@@ -118,9 +118,11 @@ export function transitionCell(
118
118
  // Coalesce console outputs, which are streamed during execution.
119
119
  let consoleOutputs = cell.consoleOutputs;
120
120
 
121
- // If interrupted on the incoming message,
122
- // remove the debugger and resolve all stdin for previous console outputs
123
- if (didInterruptFromThisMessage) {
121
+ // When the cell is interrupted or finishes (idle), there is no live
122
+ // stdin/pdb session, so resolve any dangling stdin prompt. This removes the
123
+ // debugger box — e.g. after quitting pdb with `q`, which stops the cell
124
+ // (idle) rather than interrupting it.
125
+ if (didInterruptFromThisMessage || message.status === "idle") {
124
126
  nextCell.debuggerActive = false;
125
127
  consoleOutputs = consoleOutputs.map((output) => {
126
128
  if (output.channel === "stdin") {
@@ -0,0 +1,188 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import {
4
+ type EditorState,
5
+ type Extension,
6
+ RangeSet,
7
+ RangeSetBuilder,
8
+ StateEffect,
9
+ StateField,
10
+ } from "@codemirror/state";
11
+ import {
12
+ Decoration,
13
+ type DecorationSet,
14
+ EditorView,
15
+ gutter,
16
+ GutterMarker,
17
+ type PluginValue,
18
+ ViewPlugin,
19
+ type ViewUpdate,
20
+ } from "@codemirror/view";
21
+ import type { CellId } from "@/core/cells/ids";
22
+ import type { Observable } from "@/core/state/observable";
23
+ import { Logger } from "@/utils/Logger";
24
+ import { toggleBreakpoint } from "./debugger-state";
25
+
26
+ // --- Current-line highlight -------------------------------------------------
27
+
28
+ function createLineDecoration(
29
+ state: EditorState,
30
+ line: number | null,
31
+ ): DecorationSet {
32
+ if (line == null || line < 1 || line > state.doc.lines) {
33
+ return Decoration.none;
34
+ }
35
+ const builder = new RangeSetBuilder<Decoration>();
36
+ try {
37
+ const doc = state.doc.line(line);
38
+ builder.add(
39
+ doc.from,
40
+ doc.from,
41
+ Decoration.line({ class: "cm-debugger-current-line" }),
42
+ );
43
+ } catch (error) {
44
+ Logger.debug("Invalid debugger line", { error });
45
+ }
46
+ return builder.finish();
47
+ }
48
+
49
+ class CurrentLineHighlighter implements PluginValue {
50
+ private unsubscribe: () => void;
51
+ decorations: DecorationSet;
52
+
53
+ constructor(view: EditorView, lineObservable: Observable<number | null>) {
54
+ this.decorations = createLineDecoration(view.state, lineObservable.get());
55
+ this.unsubscribe = lineObservable.sub((line) => {
56
+ this.decorations = createLineDecoration(view.state, line);
57
+ // Force a re-render; the decoration set changed out of band.
58
+ view.dispatch({ userEvent: "marimo.debugger-line-update" });
59
+ });
60
+ }
61
+
62
+ update(update: ViewUpdate) {
63
+ this.decorations = this.decorations.map(update.changes);
64
+ }
65
+
66
+ destroy() {
67
+ this.unsubscribe();
68
+ }
69
+ }
70
+
71
+ /** Highlight the line a cell's frame watcher is currently executing. */
72
+ export function debuggerLineHighlighter(
73
+ lineObservable: Observable<number | null>,
74
+ ): Extension {
75
+ return [
76
+ ViewPlugin.define(
77
+ (view) => new CurrentLineHighlighter(view, lineObservable),
78
+ { decorations: (plugin) => plugin.decorations },
79
+ ),
80
+ EditorView.theme({
81
+ ".cm-debugger-current-line": {
82
+ backgroundColor: "color-mix(in srgb, var(--amber-4) 50%, transparent)",
83
+ },
84
+ }),
85
+ ];
86
+ }
87
+
88
+ // --- Breakpoint gutter ------------------------------------------------------
89
+
90
+ class BreakpointMarker extends GutterMarker {
91
+ override toDOM() {
92
+ const dot = document.createElement("div");
93
+ dot.className = "cm-breakpoint-marker";
94
+ return dot;
95
+ }
96
+ }
97
+
98
+ const breakpointMarker = new BreakpointMarker();
99
+
100
+ const setBreakpointLines = StateEffect.define<ReadonlySet<number>>();
101
+
102
+ const breakpointLinesField = StateField.define<ReadonlySet<number>>({
103
+ create: () => new Set<number>(),
104
+ update(value, tr) {
105
+ for (const effect of tr.effects) {
106
+ if (effect.is(setBreakpointLines)) {
107
+ return effect.value;
108
+ }
109
+ }
110
+ return value;
111
+ },
112
+ });
113
+
114
+ function buildBreakpointMarkers(state: EditorState): RangeSet<GutterMarker> {
115
+ const lines = state.field(breakpointLinesField);
116
+ if (lines.size === 0) {
117
+ return RangeSet.empty;
118
+ }
119
+ const builder = new RangeSetBuilder<GutterMarker>();
120
+ for (const lineNo of [...lines].toSorted((a, b) => a - b)) {
121
+ if (lineNo >= 1 && lineNo <= state.doc.lines) {
122
+ const line = state.doc.line(lineNo);
123
+ builder.add(line.from, line.from, breakpointMarker);
124
+ }
125
+ }
126
+ return builder.finish();
127
+ }
128
+
129
+ /** Keep `breakpointLinesField` in sync with the per-cell breakpoints atom. */
130
+ class BreakpointSync implements PluginValue {
131
+ private unsubscribe: () => void;
132
+
133
+ constructor(view: EditorView, observable: Observable<ReadonlySet<number>>) {
134
+ const apply = (lines: ReadonlySet<number>) => {
135
+ view.dispatch({ effects: setBreakpointLines.of(lines) });
136
+ };
137
+ // Dispatching during view construction is disallowed; defer the initial
138
+ // sync (e.g. for editors that mount after breakpoints already exist).
139
+ // Read the value fresh inside the microtask so a `sub` update that lands
140
+ // first isn't clobbered by a stale snapshot.
141
+ if (observable.get().size > 0) {
142
+ queueMicrotask(() => apply(observable.get()));
143
+ }
144
+ this.unsubscribe = observable.sub(apply);
145
+ }
146
+
147
+ destroy() {
148
+ this.unsubscribe();
149
+ }
150
+ }
151
+
152
+ /** A clickable gutter for toggling breakpoints on a cell's lines. */
153
+ export function breakpointGutter(
154
+ cellId: CellId,
155
+ breakpointsObservable: Observable<ReadonlySet<number>>,
156
+ ): Extension {
157
+ return [
158
+ breakpointLinesField,
159
+ ViewPlugin.define(
160
+ (view) => new BreakpointSync(view, breakpointsObservable),
161
+ ),
162
+ gutter({
163
+ class: "cm-breakpoint-gutter",
164
+ markers: (view) => buildBreakpointMarkers(view.state),
165
+ initialSpacer: () => breakpointMarker,
166
+ domEventHandlers: {
167
+ mousedown: (view, line) => {
168
+ const lineNo = view.state.doc.lineAt(line.from).number;
169
+ toggleBreakpoint(cellId, lineNo);
170
+ return true;
171
+ },
172
+ },
173
+ }),
174
+ EditorView.theme({
175
+ ".cm-breakpoint-gutter": {
176
+ width: "14px",
177
+ cursor: "pointer",
178
+ },
179
+ ".cm-breakpoint-marker": {
180
+ width: "8px",
181
+ height: "8px",
182
+ borderRadius: "50%",
183
+ backgroundColor: "var(--red-9)",
184
+ margin: "4px auto",
185
+ },
186
+ }),
187
+ ];
188
+ }
@@ -0,0 +1,92 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { atom } from "jotai";
4
+ import type { CellId } from "@/core/cells/ids";
5
+ import { getRequestClient } from "@/core/network/requests";
6
+ import { store } from "@/core/state/jotai";
7
+ import { Logger } from "@/utils/Logger";
8
+
9
+ /**
10
+ * State for the experimental live debugger.
11
+ *
12
+ * - `debuggerCurrentLineAtom` holds the cell + line the kernel's frame watcher
13
+ * is currently executing (driven by `active-line` notifications). Only one
14
+ * cell runs at a time, so a single global value suffices.
15
+ * - `breakpointsAtom` holds the user's gutter breakpoints, session-only. It is
16
+ * the source of truth; mutations are mirrored to the kernel via
17
+ * `sendSetBreakpoints`.
18
+ */
19
+
20
+ export interface DebuggerLine {
21
+ cellId: CellId;
22
+ line: number;
23
+ }
24
+
25
+ export const debuggerCurrentLineAtom = atom<DebuggerLine | null>(null);
26
+
27
+ export const breakpointsAtom = atom<ReadonlyMap<CellId, ReadonlySet<number>>>(
28
+ new Map<CellId, ReadonlySet<number>>(),
29
+ );
30
+
31
+ const EMPTY_LINES: ReadonlySet<number> = new Set();
32
+
33
+ /** Per-cell derived atom: the current debug line for `cellId`, or `null`. */
34
+ export function createDebuggerLineAtom(cellId: CellId) {
35
+ return atom((get) => {
36
+ const current = get(debuggerCurrentLineAtom);
37
+ return current?.cellId === cellId ? current.line : null;
38
+ });
39
+ }
40
+
41
+ /** Per-cell derived atom: the breakpoint lines for `cellId`. */
42
+ export function createCellBreakpointsAtom(cellId: CellId) {
43
+ return atom((get) => get(breakpointsAtom).get(cellId) ?? EMPTY_LINES);
44
+ }
45
+
46
+ function sendBreakpoints(map: ReadonlyMap<CellId, ReadonlySet<number>>): void {
47
+ const breakpoints: Record<string, number[]> = {};
48
+ for (const [cellId, lines] of map) {
49
+ if (lines.size > 0) {
50
+ breakpoints[cellId] = [...lines].toSorted((a, b) => a - b);
51
+ }
52
+ }
53
+ // Fire-and-forget: the request layer rethrows on failure (e.g. static or
54
+ // wasm mode with no live kernel). Swallow it so toggling a breakpoint never
55
+ // surfaces as an unhandled promise rejection.
56
+ getRequestClient()
57
+ .sendSetBreakpoints({ breakpoints })
58
+ .catch((error) => {
59
+ Logger.debug("Failed to sync breakpoints to kernel", { error });
60
+ });
61
+ }
62
+
63
+ /** Remove all breakpoints for a cell and sync the full set to the kernel. */
64
+ export function clearCellBreakpoints(cellId: CellId): void {
65
+ const prev = store.get(breakpointsAtom);
66
+ if (!prev.has(cellId)) {
67
+ return;
68
+ }
69
+ const next = new Map(prev);
70
+ next.delete(cellId);
71
+ store.set(breakpointsAtom, next);
72
+ sendBreakpoints(next);
73
+ }
74
+
75
+ /** Toggle a breakpoint at `(cellId, line)` and sync the full set to the kernel. */
76
+ export function toggleBreakpoint(cellId: CellId, line: number): void {
77
+ const prev = store.get(breakpointsAtom);
78
+ const next = new Map(prev);
79
+ const lines = new Set(next.get(cellId) ?? []);
80
+ if (lines.has(line)) {
81
+ lines.delete(line);
82
+ } else {
83
+ lines.add(line);
84
+ }
85
+ if (lines.size > 0) {
86
+ next.set(cellId, lines);
87
+ } else {
88
+ next.delete(cellId);
89
+ }
90
+ store.set(breakpointsAtom, next);
91
+ sendBreakpoints(next);
92
+ }
@@ -17,6 +17,7 @@ import {
17
17
  import { loroSyncAnnotation } from "@/core/codemirror/rtc/loro/sync";
18
18
  import type { KeymapConfig } from "@/core/config/config-schema";
19
19
  import type { HotkeyProvider } from "@/core/hotkeys/hotkeys";
20
+ import { getFeatureFlag } from "@/core/config/feature-flag";
20
21
  import { duplicateWithCtrlModifier } from "@/core/hotkeys/shortcuts";
21
22
  import { store } from "@/core/state/jotai";
22
23
  import { createObservable } from "@/core/state/observable";
@@ -25,6 +26,14 @@ import { formattingChangeEffect } from "../format";
25
26
  import { goToDefinitionAtCursorPosition } from "../go-to-definition/utils";
26
27
  import { getEditorCodeAsPython } from "../language/utils";
27
28
  import { isAtEndOfEditor, isAtStartOfEditor } from "../utils";
29
+ import {
30
+ breakpointGutter,
31
+ debuggerLineHighlighter,
32
+ } from "./debugger-decorations";
33
+ import {
34
+ createCellBreakpointsAtom,
35
+ createDebuggerLineAtom,
36
+ } from "./debugger-state";
28
37
  import {
29
38
  type CodemirrorCellActions,
30
39
  cellActionsState,
@@ -428,6 +437,19 @@ export function cellBundle({
428
437
  errorLineHighlighter(
429
438
  createObservable(createTracebackInfoAtom(cellId), store),
430
439
  ),
440
+ // Experimental live debugger: clickable breakpoint gutter + current-line
441
+ // highlight. Gated so there is no gutter/overhead when disabled.
442
+ getFeatureFlag("debugger")
443
+ ? [
444
+ breakpointGutter(
445
+ cellId,
446
+ createObservable(createCellBreakpointsAtom(cellId), store),
447
+ ),
448
+ debuggerLineHighlighter(
449
+ createObservable(createDebuggerLineAtom(cellId), store),
450
+ ),
451
+ ]
452
+ : [],
431
453
  ];
432
454
  }
433
455
 
@@ -11,6 +11,7 @@ export interface ExperimentalFeatures {
11
11
  rtc_v2: boolean;
12
12
  cache_panel: boolean;
13
13
  external_agents: boolean;
14
+ debugger: boolean; // Live frame-watching debugger (gutter breakpoints + pdb)
14
15
  // Add new feature flags here
15
16
  }
16
17
 
@@ -20,6 +21,7 @@ const defaultValues: ExperimentalFeatures = {
20
21
  rtc_v2: false,
21
22
  cache_panel: false,
22
23
  external_agents: import.meta.env.DEV,
24
+ debugger: false,
23
25
  };
24
26
 
25
27
  export function getFeatureFlag<T extends keyof ExperimentalFeatures>(
@@ -174,6 +174,7 @@ function handleMessage(
174
174
  case "completion-result":
175
175
  case "reload":
176
176
  case "focus-cell":
177
+ case "active-line":
177
178
  case "variables":
178
179
  case "variable-values":
179
180
  case "data-column-preview":
@@ -244,6 +244,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
244
244
  sendListFiles = throwNotImplemented;
245
245
  sendSearchFiles = throwNotImplemented;
246
246
  sendPdb = throwNotImplemented;
247
+ sendSetBreakpoints = throwNotImplemented;
247
248
  sendCreateFileOrFolder = throwNotImplemented;
248
249
  sendDeleteFileOrFolder = throwNotImplemented;
249
250
  sendCopyFileOrFolder = throwNotImplemented;
@@ -108,6 +108,7 @@ const ACTIONS: Record<keyof AllRequests, Action> = {
108
108
  sendStdin: "waitForConnectionOpen",
109
109
  sendInterrupt: "waitForConnectionOpen",
110
110
  sendPdb: "waitForConnectionOpen",
111
+ sendSetBreakpoints: "waitForConnectionOpen",
111
112
  sendInstallMissingPackages: "waitForConnectionOpen",
112
113
  readSnippets: "waitForConnectionOpen",
113
114
  previewDatasetColumn: "waitForConnectionOpen",
@@ -293,6 +293,14 @@ export function createNetworkRequests(): EditRequests & RunRequests {
293
293
  })
294
294
  .then(handleResponseReturnNull);
295
295
  },
296
+ sendSetBreakpoints: (request) => {
297
+ return getClient()
298
+ .POST("/api/kernel/pdb/breakpoints", {
299
+ body: request,
300
+ params: getParams(),
301
+ })
302
+ .then(handleResponseReturnNull);
303
+ },
296
304
  sendListFiles: async (request) => {
297
305
  await waitForConnectionOpen();
298
306
  return getClient()
@@ -68,6 +68,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
68
68
  sendListFiles: throwNotInEditMode,
69
69
  sendSearchFiles: throwNotInEditMode,
70
70
  sendPdb: throwNotInEditMode,
71
+ sendSetBreakpoints: throwNotInEditMode,
71
72
  sendCreateFileOrFolder: throwNotInEditMode,
72
73
  sendDeleteFileOrFolder: throwNotInEditMode,
73
74
  sendCopyFileOrFolder: throwNotInEditMode,
@@ -49,6 +49,7 @@ export function createErrorToastingRequests(
49
49
  sendListFiles: "Failed to list files",
50
50
  sendSearchFiles: "Failed to search files",
51
51
  sendPdb: "Failed to start debug session",
52
+ sendSetBreakpoints: "", // No toast
52
53
  sendCreateFileOrFolder: "Failed to create file or folder",
53
54
  sendDeleteFileOrFolder: "Failed to delete file or folder",
54
55
  sendCopyFileOrFolder: "Failed to duplicate file or folder",
@@ -66,6 +66,7 @@ export type ListDataSourceConnectionRequest =
66
66
  schemas["ListDataSourceConnectionRequest"];
67
67
  export type ValidateSQLRequest = schemas["ValidateSQLRequest"];
68
68
  export type DebugCellRequest = schemas["DebugCellRequest"];
69
+ export type SetBreakpointsRequest = schemas["SetBreakpointsRequest"];
69
70
  export type ReadCodeResponse = schemas["ReadCodeResponse"];
70
71
  export type RecentFilesResponse = schemas["RecentFilesResponse"];
71
72
  export type RenameNotebookRequest = schemas["RenameNotebookRequest"];
@@ -172,6 +173,7 @@ export interface EditRequests {
172
173
  getUsageStats: () => Promise<UsageResponse>;
173
174
  // Debugger
174
175
  sendPdb: (request: DebugCellRequest) => Promise<null>;
176
+ sendSetBreakpoints: (request: SetBreakpointsRequest) => Promise<null>;
175
177
  // File explorer requests
176
178
  sendListFiles: (request: FileListRequest) => Promise<FileListResponse>;
177
179
  sendSearchFiles: (request: FileSearchRequest) => Promise<FileSearchResponse>;
@@ -257,6 +257,10 @@ export class PyodideBridge implements RunRequests, EditRequests {
257
257
  throwNotImplemented();
258
258
  };
259
259
 
260
+ sendSetBreakpoints: EditRequests["sendSetBreakpoints"] = async () => {
261
+ throwNotImplemented();
262
+ };
263
+
260
264
  sendRun: EditRequests["sendRun"] = async (request) => {
261
265
  await this.rpc.proxy.request.loadPackages(request.codes.join("\n"));
262
266