@plmbr/notebook-intelligence 5.0.0 → 5.1.0-a.1

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.
@@ -0,0 +1,146 @@
1
+ import React, { useState } from 'react';
2
+ import {
3
+ VscChevronDown,
4
+ VscChevronRight,
5
+ VscClose,
6
+ VscEdit,
7
+ VscError,
8
+ VscEye,
9
+ VscPassFilled,
10
+ VscSync,
11
+ VscTerminal,
12
+ VscTools
13
+ } from '../icons';
14
+
15
+ export interface IToolCallDiffLine {
16
+ type: 'add' | 'remove' | 'context' | string;
17
+ content: string;
18
+ }
19
+
20
+ export interface IToolCallDiff {
21
+ path: string;
22
+ lines: IToolCallDiffLine[];
23
+ truncated?: boolean;
24
+ }
25
+
26
+ /**
27
+ * A single agent tool call surfaced as a persistent chat card. Mirrors the
28
+ * `ToolCallData` payload emitted by the server: it stays in the transcript
29
+ * after the turn ends and carries its final status, unlike the transient
30
+ * single progress line it replaces. File-edit tools also carry inline diffs.
31
+ */
32
+ export interface IToolCall {
33
+ id: string;
34
+ title: string;
35
+ // Coarse category, used only to pick the leading icon.
36
+ kind: 'read' | 'edit' | 'execute' | 'other' | string;
37
+ status: 'in_progress' | 'completed' | 'failed' | 'cancelled' | string;
38
+ diffs?: IToolCallDiff[];
39
+ }
40
+
41
+ const KIND_ICONS: Record<string, React.FC<any>> = {
42
+ read: VscEye,
43
+ edit: VscEdit,
44
+ execute: VscTerminal,
45
+ other: VscTools
46
+ };
47
+
48
+ const STATUS_LABELS: Record<string, string> = {
49
+ in_progress: 'in progress',
50
+ completed: 'completed',
51
+ failed: 'failed',
52
+ cancelled: 'cancelled'
53
+ };
54
+
55
+ const GUTTER: Record<string, string> = { add: '+', remove: '-' };
56
+
57
+ function ToolCallDiffView(props: { diffs: IToolCallDiff[] }): JSX.Element {
58
+ return (
59
+ <div className="nbi-tool-call-diffs">
60
+ {props.diffs.map((diff, i) => (
61
+ <div className="nbi-tool-call-diff" key={i}>
62
+ {diff.path ? (
63
+ <div className="nbi-tool-call-diff-path" title={diff.path}>
64
+ {diff.path}
65
+ </div>
66
+ ) : null}
67
+ <div className="nbi-tool-call-diff-body">
68
+ {diff.lines.map((line, j) => (
69
+ <div
70
+ className={`nbi-tool-call-diff-line nbi-diff-${line.type}`}
71
+ key={j}
72
+ >
73
+ <span className="nbi-diff-gutter" aria-hidden="true">
74
+ {GUTTER[line.type] ?? ' '}
75
+ </span>
76
+ {/* The +/- gutter is decorative; give screen readers the
77
+ add/remove distinction as text instead. */}
78
+ {line.type === 'add' || line.type === 'remove' ? (
79
+ <span className="nbi-sr-only">
80
+ {line.type === 'add' ? 'added ' : 'removed '}
81
+ </span>
82
+ ) : null}
83
+ <span className="nbi-diff-text">{line.content}</span>
84
+ </div>
85
+ ))}
86
+ </div>
87
+ {diff.truncated ? (
88
+ <div className="nbi-tool-call-diff-truncated">diff truncated</div>
89
+ ) : null}
90
+ </div>
91
+ ))}
92
+ </div>
93
+ );
94
+ }
95
+
96
+ export function ToolCallCard(props: { toolCall: IToolCall }): JSX.Element {
97
+ const { title, kind, status, diffs } = props.toolCall;
98
+ const [expanded, setExpanded] = useState(true);
99
+
100
+ const KindIcon = KIND_ICONS[kind] ?? VscTools;
101
+
102
+ let StatusIcon = VscSync;
103
+ if (status === 'completed') {
104
+ StatusIcon = VscPassFilled;
105
+ } else if (status === 'failed') {
106
+ StatusIcon = VscError;
107
+ } else if (status === 'cancelled') {
108
+ StatusIcon = VscClose;
109
+ }
110
+
111
+ const statusLabel = STATUS_LABELS[status] ?? status;
112
+ const statusModifier = status.replace(/_/g, '-');
113
+ const hasDiffs = Array.isArray(diffs) && diffs.length > 0;
114
+
115
+ return (
116
+ <div className="nbi-tool-call-wrapper">
117
+ <div className={`nbi-tool-call nbi-tool-call-${statusModifier}`}>
118
+ <KindIcon className="nbi-tool-call-kind-icon" aria-hidden="true" />
119
+ <span className="nbi-tool-call-title" title={title}>
120
+ {title}
121
+ </span>
122
+ {hasDiffs ? (
123
+ <button
124
+ type="button"
125
+ className="nbi-tool-call-diff-toggle"
126
+ onClick={() => setExpanded(e => !e)}
127
+ aria-expanded={expanded}
128
+ aria-label={expanded ? 'Hide diff' : 'Show diff'}
129
+ >
130
+ {expanded ? (
131
+ <VscChevronDown aria-hidden="true" />
132
+ ) : (
133
+ <VscChevronRight aria-hidden="true" />
134
+ )}
135
+ </button>
136
+ ) : null}
137
+ <StatusIcon className="nbi-tool-call-status-icon" aria-hidden="true" />
138
+ {/* Status reaches screen readers as text; the icons are decorative.
139
+ The visible title is already in the accessibility tree, so this
140
+ span carries only the status to avoid announcing the title twice. */}
141
+ <span className="nbi-sr-only">{statusLabel}</span>
142
+ </div>
143
+ {hasDiffs && expanded ? <ToolCallDiffView diffs={diffs!} /> : null}
144
+ </div>
145
+ );
146
+ }
@@ -0,0 +1,65 @@
1
+ import React, { useState } from 'react';
2
+ import { VscChevronDown, VscChevronRight } from '../icons';
3
+ import { IToolCall, ToolCallCard } from './tool-call-card';
4
+
5
+ // Groups with more calls than this start collapsed so a tool-heavy turn (or a
6
+ // reloaded transcript) doesn't flood the chat. A live turn mounts the group at
7
+ // length 1, so it starts expanded and stays visible as calls stream in.
8
+ const GROUP_COLLAPSE_THRESHOLD = 3;
9
+
10
+ /**
11
+ * Renders a run of consecutive tool calls. A single call is shown as a bare
12
+ * card; multiple calls are wrapped in one collapsible group with a summary
13
+ * header, so consecutive agent activity reads as one unit instead of a wall
14
+ * of rows.
15
+ */
16
+ export function ToolCallGroup(props: { toolCalls: IToolCall[] }): JSX.Element {
17
+ const { toolCalls } = props;
18
+ // Hooks must run unconditionally and in a stable order: the group's length
19
+ // grows as calls stream in, so call useState before any length branch.
20
+ // Start collapsed only for a large group whose calls are all settled --
21
+ // never hide a still-running or failed call (matters on transcript reload,
22
+ // where a group can mount already large).
23
+ const [expanded, setExpanded] = useState(
24
+ () =>
25
+ toolCalls.length <= GROUP_COLLAPSE_THRESHOLD ||
26
+ toolCalls.some(t => t.status === 'in_progress' || t.status === 'failed')
27
+ );
28
+
29
+ if (toolCalls.length <= 1) {
30
+ return toolCalls.length === 1 ? (
31
+ <ToolCallCard toolCall={toolCalls[0]} />
32
+ ) : (
33
+ <></>
34
+ );
35
+ }
36
+
37
+ const failed = toolCalls.filter(t => t.status === 'failed').length;
38
+ const summary =
39
+ `${toolCalls.length} tool calls` + (failed ? ` (${failed} failed)` : '');
40
+
41
+ return (
42
+ <div className="nbi-tool-call-group">
43
+ <button
44
+ type="button"
45
+ className="nbi-tool-call-group-header"
46
+ onClick={() => setExpanded(e => !e)}
47
+ aria-expanded={expanded}
48
+ >
49
+ {expanded ? (
50
+ <VscChevronDown aria-hidden="true" />
51
+ ) : (
52
+ <VscChevronRight aria-hidden="true" />
53
+ )}
54
+ <span className="nbi-tool-call-group-summary">{summary}</span>
55
+ </button>
56
+ {expanded ? (
57
+ <div className="nbi-tool-call-group-body">
58
+ {toolCalls.map(toolCall => (
59
+ <ToolCallCard key={toolCall.id} toolCall={toolCall} />
60
+ ))}
61
+ </div>
62
+ ) : null}
63
+ </div>
64
+ );
65
+ }
package/src/icons.ts CHANGED
@@ -41,6 +41,7 @@ export const VscClose = asIcon(Vsc.VscClose);
41
41
  export const VscCloudUpload = asIcon(Vsc.VscCloudUpload);
42
42
  export const VscCopy = asIcon(Vsc.VscCopy);
43
43
  export const VscEdit = asIcon(Vsc.VscEdit);
44
+ export const VscError = asIcon(Vsc.VscError);
44
45
  export const VscEye = asIcon(Vsc.VscEye);
45
46
  export const VscEyeClosed = asIcon(Vsc.VscEyeClosed);
46
47
  export const VscFile = asIcon(Vsc.VscFile);
@@ -55,6 +56,8 @@ export const VscSend = asIcon(Vsc.VscSend);
55
56
  export const VscSettingsGear = asIcon(Vsc.VscSettingsGear);
56
57
  export const VscSparkle = asIcon(Vsc.VscSparkle);
57
58
  export const VscStopCircle = asIcon(Vsc.VscStopCircle);
59
+ export const VscSync = asIcon(Vsc.VscSync);
60
+ export const VscTerminal = asIcon(Vsc.VscTerminal);
58
61
  export const VscThumbsdown = asIcon(Vsc.VscThumbsdown);
59
62
  export const VscThumbsdownFilled = asIcon(Vsc.VscThumbsdownFilled);
60
63
  export const VscThumbsup = asIcon(Vsc.VscThumbsup);
package/src/index.ts CHANGED
@@ -106,6 +106,7 @@ import {
106
106
  cellOutputHasError,
107
107
  chooseWorkspaceDirectory,
108
108
  compareSelections,
109
+ buildResumeCommand,
109
110
  extractLLMGeneratedCode,
110
111
  getSelectionInEditor,
111
112
  getTokenCount,
@@ -1403,10 +1404,9 @@ const plugin: JupyterFrontEndPlugin<INotebookIntelligence> = {
1403
1404
  return React.createElement(LauncherPicker, {
1404
1405
  onSessionSelected: (session: IClaudeSessionInfo) => {
1405
1406
  dialog.close();
1406
- const cmd = session.cwd
1407
- ? `cd ${session.cwd} && claude --resume ${session.session_id}`
1408
- : `claude --resume ${session.session_id}`;
1409
- launchCliInTerminal(cmd);
1407
+ launchCliInTerminal(
1408
+ buildResumeCommand(session.cwd ?? '', session.session_id)
1409
+ );
1410
1410
  }
1411
1411
  });
1412
1412
  }
@@ -1734,7 +1734,7 @@ const plugin: JupyterFrontEndPlugin<INotebookIntelligence> = {
1734
1734
  source: args.source as string
1735
1735
  });
1736
1736
 
1737
- return true;
1737
+ return { cellIndex: newCellIndex };
1738
1738
  }
1739
1739
  });
1740
1740
 
@@ -1755,7 +1755,7 @@ const plugin: JupyterFrontEndPlugin<INotebookIntelligence> = {
1755
1755
  source: args.source as string
1756
1756
  });
1757
1757
 
1758
- return true;
1758
+ return { cellIndex: newCellIndex };
1759
1759
  }
1760
1760
  });
1761
1761
 
@@ -12,6 +12,8 @@ import {
12
12
  } from 'react-syntax-highlighter/dist/cjs/styles/prism';
13
13
  import { VscNewFile, VscInsert, VscCopy, VscNotebook, VscAdd } from './icons';
14
14
  import { JupyterFrontEnd } from '@jupyterlab/application';
15
+ import { PathExt } from '@jupyterlab/coreutils';
16
+ import { MarkdownLink } from './components/markdown-link';
15
17
  import { isDarkTheme, writeTextToClipboard } from './utils';
16
18
  import { IActiveDocumentInfo } from './tokens';
17
19
 
@@ -29,11 +31,39 @@ export function MarkdownRenderer({
29
31
  const app = getApp();
30
32
  const activeDocumentInfo = getActiveDocumentInfo();
31
33
  const isNotebook = activeDocumentInfo.filename.endsWith('.ipynb');
34
+ // Resolve workspace-relative LLM links against the active document's
35
+ // directory so `[file](README.md)` from a chat scoped to
36
+ // `notebooks/proj/work.ipynb` lands at `notebooks/proj/README.md` (the
37
+ // user's mental model) rather than the server-root README.
38
+ const linkBaseDir = activeDocumentInfo.filePath
39
+ ? PathExt.dirname(activeDocumentInfo.filePath)
40
+ : '';
32
41
 
33
42
  return (
43
+ // No `rehype-raw` plugin: raw HTML in chat markdown (e.g. an LLM
44
+ // emitting `<a href="javascript:...">`) renders as literal text, not
45
+ // a DOM anchor, so the only anchor sink is the CommonMark/GFM `a`
46
+ // node handled by `SafeAnchor` below. Any future change that enables
47
+ // raw HTML needs to add a rehype-sanitize pass alongside.
34
48
  <Markdown
35
49
  remarkPlugins={[remarkGfm]}
36
50
  components={{
51
+ // CommonMark `<https://...>` autolinks, `[text](url)`, and
52
+ // reference-style links all normalize to the same `a` node.
53
+ // `MarkdownLink` routes fragment-only and workspace-relative
54
+ // hrefs through Lab's docmanager so an LLM-emitted link can't
55
+ // replace the JupyterLab shell, and hands everything else to
56
+ // SafeAnchor for the `_blank` + scheme-allowlist treatment.
57
+ a: ({ href, title, children }: any) => (
58
+ <MarkdownLink
59
+ app={app}
60
+ baseDir={linkBaseDir}
61
+ href={href}
62
+ title={title}
63
+ >
64
+ {children}
65
+ </MarkdownLink>
66
+ ),
37
67
  code({ node, inline, className, children, getApp, ...props }: any) {
38
68
  const match = /language-(\w+)/.exec(className || '');
39
69
  const codeString = String(children).replace(/\n$/, '');
package/src/tokens.ts CHANGED
@@ -48,6 +48,7 @@ export enum ResponseStreamDataType {
48
48
  Button = 'button',
49
49
  Anchor = 'anchor',
50
50
  Progress = 'progress',
51
+ ToolCall = 'tool-call',
51
52
  Confirmation = 'confirmation',
52
53
  AskUserQuestion = 'ask-user-question'
53
54
  }
@@ -0,0 +1,44 @@
1
+ import { UUID } from '@lumino/coreutils';
2
+ import { ResponseStreamDataType } from './tokens';
3
+
4
+ /**
5
+ * The subset of a chat message's stream-content item this helper needs. A
6
+ * structural subset of `IChatMessageContent` (defined in chat-sidebar) so it
7
+ * can be unit-tested without importing the sidebar (and creating a cycle).
8
+ */
9
+ export interface IToolCallStreamItem {
10
+ id: string;
11
+ type: ResponseStreamDataType;
12
+ content: any;
13
+ created: Date;
14
+ }
15
+
16
+ /**
17
+ * Merge a streamed tool-call payload into `contents` by its tool-call id.
18
+ *
19
+ * A tool call streams twice under one id (once when it starts, once when it
20
+ * finishes). The first emission pushes a new card; the second updates that
21
+ * card's content (its status) in place, so the call stays a single persistent
22
+ * row rather than appending a duplicate. Mutates `contents`.
23
+ */
24
+ export function upsertToolCallContent(
25
+ contents: IToolCallStreamItem[],
26
+ content: { id: string; [key: string]: any },
27
+ created: Date
28
+ ): void {
29
+ const existing = contents.find(
30
+ c =>
31
+ c.type === ResponseStreamDataType.ToolCall &&
32
+ c.content?.id === content?.id
33
+ );
34
+ if (existing) {
35
+ existing.content = content;
36
+ } else {
37
+ contents.push({
38
+ id: UUID.uuid4(),
39
+ type: ResponseStreamDataType.ToolCall,
40
+ content,
41
+ created
42
+ });
43
+ }
44
+ }
package/src/utils.ts CHANGED
@@ -339,6 +339,28 @@ function isDisallowedUriCodepoint(code: number): boolean {
339
339
  return false;
340
340
  }
341
341
 
342
+ /**
343
+ * True when `s` contains any codepoint in the same set `safeAnchorUri`
344
+ * rejects (C0/DEL/C1, NEL/NBSP/LS/PS/BOM, ZWSP, bidi-override controls).
345
+ * Mirrors the Python `has_dangerous_text_codepoints`. Used to scrub the
346
+ * `title` attribute on rendered anchors so an LLM-emitted hover tooltip
347
+ * can't visually impersonate the link via bidi-reorder or zero-width
348
+ * tricks.
349
+ */
350
+ export function hasDangerousTextCodepoints(
351
+ s: string | undefined | null
352
+ ): boolean {
353
+ if (typeof s !== 'string') {
354
+ return false;
355
+ }
356
+ for (let i = 0; i < s.length; i++) {
357
+ if (isDisallowedUriCodepoint(s.charCodeAt(i))) {
358
+ return true;
359
+ }
360
+ }
361
+ return false;
362
+ }
363
+
342
364
  /**
343
365
  * Return `uri` if its scheme is in the chat-anchor allowlist, else null.
344
366
  * Mirrors the server-side `safe_anchor_uri` check so that anchor parts
@@ -385,10 +407,11 @@ export function safeAnchorUri(uri: string | undefined | null): string | null {
385
407
  * user happens to be in the JupyterLab working directory.
386
408
  */
387
409
  export function buildResumeCommand(cwd: string, sessionId: string): string {
410
+ const quotedSessionId = shellSingleQuote(sessionId);
388
411
  if (!cwd) {
389
- return `claude --resume ${sessionId}`;
412
+ return `claude --resume ${quotedSessionId}`;
390
413
  }
391
- return `cd ${shellSingleQuote(cwd)} && claude --resume ${sessionId}`;
414
+ return `cd ${shellSingleQuote(cwd)} && claude --resume ${quotedSessionId}`;
392
415
  }
393
416
 
394
417
  /**
package/style/base.css CHANGED
@@ -643,6 +643,7 @@ pre:has(.code-block-header) {
643
643
 
644
644
  .chat-message-feedback:focus-within,
645
645
  .chat-message-feedback:has(.selected),
646
+ .chat-message-feedback.always-visible,
646
647
  .chat-message:hover .chat-message-feedback {
647
648
  opacity: 1;
648
649
  }
@@ -779,6 +780,169 @@ pre:has(.code-block-header) {
779
780
  margin-top: 2px;
780
781
  }
781
782
 
783
+ .nbi-tool-call-wrapper {
784
+ margin: 2px 0;
785
+ }
786
+
787
+ .nbi-tool-call {
788
+ display: flex;
789
+ align-items: center;
790
+ gap: 6px;
791
+ padding: 3px 6px;
792
+ border-radius: 4px;
793
+ background-color: var(--jp-layout-color2);
794
+ font-size: var(--jp-ui-font-size1);
795
+ }
796
+
797
+ .nbi-tool-call-kind-icon {
798
+ flex-shrink: 0;
799
+ color: var(--jp-ui-font-color2);
800
+ }
801
+
802
+ .nbi-tool-call-title {
803
+ flex: 1;
804
+ min-width: 0;
805
+ overflow: hidden;
806
+ white-space: nowrap;
807
+ text-overflow: ellipsis;
808
+ }
809
+
810
+ .nbi-tool-call-status-icon {
811
+ flex-shrink: 0;
812
+ color: var(--jp-ui-font-color2);
813
+ }
814
+
815
+ .nbi-tool-call-completed .nbi-tool-call-status-icon {
816
+ color: var(--jp-success-color1);
817
+ }
818
+
819
+ .nbi-tool-call-failed .nbi-tool-call-status-icon {
820
+ color: var(--jp-error-color1);
821
+ }
822
+
823
+ .nbi-tool-call-in-progress .nbi-tool-call-status-icon {
824
+ animation: nbi-tool-call-spin 1s linear infinite;
825
+ }
826
+
827
+ @keyframes nbi-tool-call-spin {
828
+ from {
829
+ transform: rotate(0deg);
830
+ }
831
+
832
+ to {
833
+ transform: rotate(360deg);
834
+ }
835
+ }
836
+
837
+ @media (prefers-reduced-motion: reduce) {
838
+ .nbi-tool-call-in-progress .nbi-tool-call-status-icon {
839
+ animation: none;
840
+ }
841
+ }
842
+
843
+ .nbi-tool-call-diff-toggle {
844
+ display: flex;
845
+ flex-shrink: 0;
846
+ align-items: center;
847
+ padding: 0;
848
+ border: none;
849
+ background: none;
850
+ color: var(--jp-ui-font-color2);
851
+ cursor: pointer;
852
+ }
853
+
854
+ .nbi-tool-call-diffs {
855
+ margin: 2px 0 2px 18px;
856
+ padding-left: 6px;
857
+ border-left: 2px solid var(--jp-border-color2);
858
+ }
859
+
860
+ .nbi-tool-call-diff + .nbi-tool-call-diff {
861
+ margin-top: 4px;
862
+ }
863
+
864
+ .nbi-tool-call-diff-path {
865
+ overflow: hidden;
866
+ font-size: var(--jp-ui-font-size0);
867
+ color: var(--jp-ui-font-color2);
868
+ white-space: nowrap;
869
+ text-overflow: ellipsis;
870
+ }
871
+
872
+ .nbi-tool-call-diff-body {
873
+ margin: 2px 0;
874
+ overflow-x: auto;
875
+ font-family: var(--jp-code-font-family);
876
+ font-size: var(--jp-code-font-size);
877
+ line-height: 1.4;
878
+ }
879
+
880
+ .nbi-tool-call-diff-line {
881
+ display: flex;
882
+ }
883
+
884
+ .nbi-diff-gutter {
885
+ flex-shrink: 0;
886
+ width: 1ch;
887
+ user-select: none;
888
+ text-align: center;
889
+ }
890
+
891
+ .nbi-diff-text {
892
+ white-space: pre-wrap;
893
+ word-break: break-word;
894
+ }
895
+
896
+ /* Semi-transparent tints rather than the solid --jp-*-color3 fills (which are
897
+ light pastels in BOTH themes): the tint sits over the card background so the
898
+ line stays readable with the theme's own (light-on-dark / dark-on-light)
899
+ text color instead of forcing dark text onto a pale fill. */
900
+ .nbi-diff-add {
901
+ background-color: rgb(64 160 43 / 22%);
902
+ }
903
+
904
+ .nbi-diff-remove {
905
+ background-color: rgb(248 81 73 / 22%);
906
+ }
907
+
908
+ .nbi-diff-context {
909
+ color: var(--jp-ui-font-color2);
910
+ }
911
+
912
+ .nbi-tool-call-diff-truncated {
913
+ font-size: var(--jp-ui-font-size0);
914
+ font-style: italic;
915
+ color: var(--jp-ui-font-color2);
916
+ }
917
+
918
+ .nbi-tool-call-group {
919
+ margin: 2px 0;
920
+ }
921
+
922
+ .nbi-tool-call-group-header {
923
+ display: flex;
924
+ align-items: center;
925
+ gap: 4px;
926
+ width: 100%;
927
+ padding: 2px 4px;
928
+ border: none;
929
+ background: none;
930
+ color: var(--jp-ui-font-color2);
931
+ font-size: var(--jp-ui-font-size0);
932
+ text-align: left;
933
+ cursor: pointer;
934
+ }
935
+
936
+ .nbi-tool-call-group-summary {
937
+ overflow: hidden;
938
+ white-space: nowrap;
939
+ text-overflow: ellipsis;
940
+ }
941
+
942
+ .nbi-tool-call-group-body {
943
+ margin-left: 6px;
944
+ }
945
+
782
946
  .user-input-autocomplete,
783
947
  .mode-tools-popover,
784
948
  .workspace-file-popover {
@@ -1503,12 +1667,17 @@ button.send-button:disabled:active:not(.send-button-stop) {
1503
1667
  .expandable-content-text {
1504
1668
  display: none;
1505
1669
  margin: 5px;
1506
- padding: 0 5px;
1507
- border: 1px solid var(--jp-border-color2);
1508
- border-radius: 5px;
1670
+ padding: 8px 10px;
1671
+ border-radius: 6px;
1509
1672
  max-height: 200px;
1510
1673
  overflow-y: auto;
1511
- box-shadow: inset 0 0 15px 15px rgb(23 23 23 / 50%);
1674
+ background-color: var(--jp-layout-color2);
1675
+ }
1676
+
1677
+ /* Inline code also fills with --jp-layout-color2, which now matches this box's
1678
+ fill; recess nested code to --jp-layout-color1 so it stays distinct. */
1679
+ .expandable-content-text :not(pre) > code {
1680
+ background-color: var(--jp-layout-color1);
1512
1681
  }
1513
1682
 
1514
1683
  .expandable-content .collapsed-icon {