@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,33 @@
1
+ // Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
2
+ import React from 'react';
3
+ import { hasDangerousTextCodepoints, safeAnchorUri } from '../utils';
4
+ /**
5
+ * The single render path for anchor elements driven by LLM / tool output.
6
+ *
7
+ * Runs `href` through `safeAnchorUri`, which mirrors the server-side
8
+ * `safe_anchor_uri` allowlist (`http` / `https` / `mailto`) and rejects
9
+ * dangerous codepoints. On accept it renders a `_blank` anchor with
10
+ * `rel="noopener noreferrer"` and an SR-only "(opens in new tab)" suffix;
11
+ * on reject it falls through to plain text plus an SR-only "(link
12
+ * blocked)" note so screen readers can tell why the link disappeared.
13
+ *
14
+ * The `title` attribute is scrubbed for the same dangerous codepoints
15
+ * the URI check rejects, since react-markdown forwards CommonMark
16
+ * `[text](url "title")` titles to the rendered anchor and an LLM can
17
+ * smuggle bidi-override or zero-width characters there to visually
18
+ * impersonate the link target on hover.
19
+ */
20
+ export function SafeAnchor({ href, children, title, className }) {
21
+ const safeUri = safeAnchorUri(href !== null && href !== void 0 ? href : '');
22
+ if (!safeUri) {
23
+ return (React.createElement("span", { className: className },
24
+ children,
25
+ React.createElement("span", { className: "nbi-sr-only" }, " (link blocked)")));
26
+ }
27
+ const safeTitle = typeof title === 'string' && !hasDangerousTextCodepoints(title)
28
+ ? title
29
+ : undefined;
30
+ return (React.createElement("a", { href: safeUri, target: "_blank", rel: "noopener noreferrer", title: safeTitle, className: className },
31
+ children,
32
+ React.createElement("span", { className: "nbi-sr-only" }, " (opens in new tab)")));
33
+ }
@@ -0,0 +1,26 @@
1
+ /// <reference types="react" />
2
+ export interface IToolCallDiffLine {
3
+ type: 'add' | 'remove' | 'context' | string;
4
+ content: string;
5
+ }
6
+ export interface IToolCallDiff {
7
+ path: string;
8
+ lines: IToolCallDiffLine[];
9
+ truncated?: boolean;
10
+ }
11
+ /**
12
+ * A single agent tool call surfaced as a persistent chat card. Mirrors the
13
+ * `ToolCallData` payload emitted by the server: it stays in the transcript
14
+ * after the turn ends and carries its final status, unlike the transient
15
+ * single progress line it replaces. File-edit tools also carry inline diffs.
16
+ */
17
+ export interface IToolCall {
18
+ id: string;
19
+ title: string;
20
+ kind: 'read' | 'edit' | 'execute' | 'other' | string;
21
+ status: 'in_progress' | 'completed' | 'failed' | 'cancelled' | string;
22
+ diffs?: IToolCallDiff[];
23
+ }
24
+ export declare function ToolCallCard(props: {
25
+ toolCall: IToolCall;
26
+ }): JSX.Element;
@@ -0,0 +1,54 @@
1
+ import React, { useState } from 'react';
2
+ import { VscChevronDown, VscChevronRight, VscClose, VscEdit, VscError, VscEye, VscPassFilled, VscSync, VscTerminal, VscTools } from '../icons';
3
+ const KIND_ICONS = {
4
+ read: VscEye,
5
+ edit: VscEdit,
6
+ execute: VscTerminal,
7
+ other: VscTools
8
+ };
9
+ const STATUS_LABELS = {
10
+ in_progress: 'in progress',
11
+ completed: 'completed',
12
+ failed: 'failed',
13
+ cancelled: 'cancelled'
14
+ };
15
+ const GUTTER = { add: '+', remove: '-' };
16
+ function ToolCallDiffView(props) {
17
+ return (React.createElement("div", { className: "nbi-tool-call-diffs" }, props.diffs.map((diff, i) => (React.createElement("div", { className: "nbi-tool-call-diff", key: i },
18
+ diff.path ? (React.createElement("div", { className: "nbi-tool-call-diff-path", title: diff.path }, diff.path)) : null,
19
+ React.createElement("div", { className: "nbi-tool-call-diff-body" }, diff.lines.map((line, j) => {
20
+ var _a;
21
+ return (React.createElement("div", { className: `nbi-tool-call-diff-line nbi-diff-${line.type}`, key: j },
22
+ React.createElement("span", { className: "nbi-diff-gutter", "aria-hidden": "true" }, (_a = GUTTER[line.type]) !== null && _a !== void 0 ? _a : ' '),
23
+ line.type === 'add' || line.type === 'remove' ? (React.createElement("span", { className: "nbi-sr-only" }, line.type === 'add' ? 'added ' : 'removed ')) : null,
24
+ React.createElement("span", { className: "nbi-diff-text" }, line.content)));
25
+ })),
26
+ diff.truncated ? (React.createElement("div", { className: "nbi-tool-call-diff-truncated" }, "diff truncated")) : null)))));
27
+ }
28
+ export function ToolCallCard(props) {
29
+ var _a, _b;
30
+ const { title, kind, status, diffs } = props.toolCall;
31
+ const [expanded, setExpanded] = useState(true);
32
+ const KindIcon = (_a = KIND_ICONS[kind]) !== null && _a !== void 0 ? _a : VscTools;
33
+ let StatusIcon = VscSync;
34
+ if (status === 'completed') {
35
+ StatusIcon = VscPassFilled;
36
+ }
37
+ else if (status === 'failed') {
38
+ StatusIcon = VscError;
39
+ }
40
+ else if (status === 'cancelled') {
41
+ StatusIcon = VscClose;
42
+ }
43
+ const statusLabel = (_b = STATUS_LABELS[status]) !== null && _b !== void 0 ? _b : status;
44
+ const statusModifier = status.replace(/_/g, '-');
45
+ const hasDiffs = Array.isArray(diffs) && diffs.length > 0;
46
+ return (React.createElement("div", { className: "nbi-tool-call-wrapper" },
47
+ React.createElement("div", { className: `nbi-tool-call nbi-tool-call-${statusModifier}` },
48
+ React.createElement(KindIcon, { className: "nbi-tool-call-kind-icon", "aria-hidden": "true" }),
49
+ React.createElement("span", { className: "nbi-tool-call-title", title: title }, title),
50
+ hasDiffs ? (React.createElement("button", { type: "button", className: "nbi-tool-call-diff-toggle", onClick: () => setExpanded(e => !e), "aria-expanded": expanded, "aria-label": expanded ? 'Hide diff' : 'Show diff' }, expanded ? (React.createElement(VscChevronDown, { "aria-hidden": "true" })) : (React.createElement(VscChevronRight, { "aria-hidden": "true" })))) : null,
51
+ React.createElement(StatusIcon, { className: "nbi-tool-call-status-icon", "aria-hidden": "true" }),
52
+ React.createElement("span", { className: "nbi-sr-only" }, statusLabel)),
53
+ hasDiffs && expanded ? React.createElement(ToolCallDiffView, { diffs: diffs }) : null));
54
+ }
@@ -0,0 +1,11 @@
1
+ /// <reference types="react" />
2
+ import { IToolCall } from './tool-call-card';
3
+ /**
4
+ * Renders a run of consecutive tool calls. A single call is shown as a bare
5
+ * card; multiple calls are wrapped in one collapsible group with a summary
6
+ * header, so consecutive agent activity reads as one unit instead of a wall
7
+ * of rows.
8
+ */
9
+ export declare function ToolCallGroup(props: {
10
+ toolCalls: IToolCall[];
11
+ }): JSX.Element;
@@ -0,0 +1,33 @@
1
+ import React, { useState } from 'react';
2
+ import { VscChevronDown, VscChevronRight } from '../icons';
3
+ import { ToolCallCard } from './tool-call-card';
4
+ // Groups with more calls than this start collapsed so a tool-heavy turn (or a
5
+ // reloaded transcript) doesn't flood the chat. A live turn mounts the group at
6
+ // length 1, so it starts expanded and stays visible as calls stream in.
7
+ const GROUP_COLLAPSE_THRESHOLD = 3;
8
+ /**
9
+ * Renders a run of consecutive tool calls. A single call is shown as a bare
10
+ * card; multiple calls are wrapped in one collapsible group with a summary
11
+ * header, so consecutive agent activity reads as one unit instead of a wall
12
+ * of rows.
13
+ */
14
+ export function ToolCallGroup(props) {
15
+ const { toolCalls } = props;
16
+ // Hooks must run unconditionally and in a stable order: the group's length
17
+ // grows as calls stream in, so call useState before any length branch.
18
+ // Start collapsed only for a large group whose calls are all settled --
19
+ // never hide a still-running or failed call (matters on transcript reload,
20
+ // where a group can mount already large).
21
+ const [expanded, setExpanded] = useState(() => toolCalls.length <= GROUP_COLLAPSE_THRESHOLD ||
22
+ toolCalls.some(t => t.status === 'in_progress' || t.status === 'failed'));
23
+ if (toolCalls.length <= 1) {
24
+ return toolCalls.length === 1 ? (React.createElement(ToolCallCard, { toolCall: toolCalls[0] })) : (React.createElement(React.Fragment, null));
25
+ }
26
+ const failed = toolCalls.filter(t => t.status === 'failed').length;
27
+ const summary = `${toolCalls.length} tool calls` + (failed ? ` (${failed} failed)` : '');
28
+ return (React.createElement("div", { className: "nbi-tool-call-group" },
29
+ React.createElement("button", { type: "button", className: "nbi-tool-call-group-header", onClick: () => setExpanded(e => !e), "aria-expanded": expanded },
30
+ expanded ? (React.createElement(VscChevronDown, { "aria-hidden": "true" })) : (React.createElement(VscChevronRight, { "aria-hidden": "true" })),
31
+ React.createElement("span", { className: "nbi-tool-call-group-summary" }, summary)),
32
+ expanded ? (React.createElement("div", { className: "nbi-tool-call-group-body" }, toolCalls.map(toolCall => (React.createElement(ToolCallCard, { key: toolCall.id, toolCall: toolCall }))))) : null));
33
+ }
package/lib/icons.d.ts CHANGED
@@ -17,6 +17,7 @@ export declare const VscClose: IconLike;
17
17
  export declare const VscCloudUpload: IconLike;
18
18
  export declare const VscCopy: IconLike;
19
19
  export declare const VscEdit: IconLike;
20
+ export declare const VscError: IconLike;
20
21
  export declare const VscEye: IconLike;
21
22
  export declare const VscEyeClosed: IconLike;
22
23
  export declare const VscFile: IconLike;
@@ -31,6 +32,8 @@ export declare const VscSend: IconLike;
31
32
  export declare const VscSettingsGear: IconLike;
32
33
  export declare const VscSparkle: IconLike;
33
34
  export declare const VscStopCircle: IconLike;
35
+ export declare const VscSync: IconLike;
36
+ export declare const VscTerminal: IconLike;
34
37
  export declare const VscThumbsdown: IconLike;
35
38
  export declare const VscThumbsdownFilled: IconLike;
36
39
  export declare const VscThumbsup: IconLike;
package/lib/icons.js CHANGED
@@ -27,6 +27,7 @@ export const VscClose = asIcon(Vsc.VscClose);
27
27
  export const VscCloudUpload = asIcon(Vsc.VscCloudUpload);
28
28
  export const VscCopy = asIcon(Vsc.VscCopy);
29
29
  export const VscEdit = asIcon(Vsc.VscEdit);
30
+ export const VscError = asIcon(Vsc.VscError);
30
31
  export const VscEye = asIcon(Vsc.VscEye);
31
32
  export const VscEyeClosed = asIcon(Vsc.VscEyeClosed);
32
33
  export const VscFile = asIcon(Vsc.VscFile);
@@ -41,6 +42,8 @@ export const VscSend = asIcon(Vsc.VscSend);
41
42
  export const VscSettingsGear = asIcon(Vsc.VscSettingsGear);
42
43
  export const VscSparkle = asIcon(Vsc.VscSparkle);
43
44
  export const VscStopCircle = asIcon(Vsc.VscStopCircle);
45
+ export const VscSync = asIcon(Vsc.VscSync);
46
+ export const VscTerminal = asIcon(Vsc.VscTerminal);
44
47
  export const VscThumbsdown = asIcon(Vsc.VscThumbsdown);
45
48
  export const VscThumbsdownFilled = asIcon(Vsc.VscThumbsdownFilled);
46
49
  export const VscThumbsup = asIcon(Vsc.VscThumbsup);
package/lib/index.js CHANGED
@@ -36,7 +36,7 @@ import sparklesWarningSvgstr from '../style/icons/sparkles-warning.svg';
36
36
  import claudeSvgstr from '../style/icons/claude.svg';
37
37
  import openaiSvgstr from '../style/icons/openai.svg';
38
38
  import opencodeSvgstr from '../style/icons/opencode.svg';
39
- import { applyCodeToSelectionInEditor, cellOutputAsText, cellOutputHasError, chooseWorkspaceDirectory, compareSelections, extractLLMGeneratedCode, getSelectionInEditor, getTokenCount, getWholeNotebookContent, isSelectionEmpty, markdownToComment, waitForDuration } from './utils';
39
+ import { applyCodeToSelectionInEditor, cellOutputAsText, cellOutputHasError, chooseWorkspaceDirectory, compareSelections, buildResumeCommand, extractLLMGeneratedCode, getSelectionInEditor, getTokenCount, getWholeNotebookContent, isSelectionEmpty, markdownToComment, waitForDuration } from './utils';
40
40
  import { cellOutputAsContextBundle } from './cell-output-bundle';
41
41
  import { UUID } from '@lumino/coreutils';
42
42
  import * as path from 'path';
@@ -1108,11 +1108,9 @@ const plugin = {
1108
1108
  render() {
1109
1109
  return React.createElement(LauncherPicker, {
1110
1110
  onSessionSelected: (session) => {
1111
+ var _a;
1111
1112
  dialog.close();
1112
- const cmd = session.cwd
1113
- ? `cd ${session.cwd} && claude --resume ${session.session_id}`
1114
- : `claude --resume ${session.session_id}`;
1115
- launchCliInTerminal(cmd);
1113
+ launchCliInTerminal(buildResumeCommand((_a = session.cwd) !== null && _a !== void 0 ? _a : '', session.session_id));
1116
1114
  }
1117
1115
  });
1118
1116
  }
@@ -1360,7 +1358,7 @@ const plugin = {
1360
1358
  metadata: { trusted: true },
1361
1359
  source: args.source
1362
1360
  });
1363
- return true;
1361
+ return { cellIndex: newCellIndex };
1364
1362
  }
1365
1363
  });
1366
1364
  app.commands.addCommand(CommandIDs.addCodeCellToActiveNotebook, {
@@ -1378,7 +1376,7 @@ const plugin = {
1378
1376
  metadata: { trusted: true },
1379
1377
  source: args.source
1380
1378
  });
1381
- return true;
1379
+ return { cellIndex: newCellIndex };
1382
1380
  }
1383
1381
  });
1384
1382
  app.commands.addCommand(CommandIDs.getCellTypeAndSource, {
@@ -6,12 +6,34 @@ import { Prism as SyntaxHighlighterBase } from 'react-syntax-highlighter';
6
6
  const SyntaxHighlighter = SyntaxHighlighterBase;
7
7
  import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
8
8
  import { VscNewFile, VscInsert, VscCopy, VscNotebook, VscAdd } from './icons';
9
+ import { PathExt } from '@jupyterlab/coreutils';
10
+ import { MarkdownLink } from './components/markdown-link';
9
11
  import { isDarkTheme, writeTextToClipboard } from './utils';
10
12
  export function MarkdownRenderer({ children: markdown, getApp, getActiveDocumentInfo }) {
11
13
  const app = getApp();
12
14
  const activeDocumentInfo = getActiveDocumentInfo();
13
15
  const isNotebook = activeDocumentInfo.filename.endsWith('.ipynb');
14
- return (React.createElement(Markdown, { remarkPlugins: [remarkGfm], components: {
16
+ // Resolve workspace-relative LLM links against the active document's
17
+ // directory so `[file](README.md)` from a chat scoped to
18
+ // `notebooks/proj/work.ipynb` lands at `notebooks/proj/README.md` (the
19
+ // user's mental model) rather than the server-root README.
20
+ const linkBaseDir = activeDocumentInfo.filePath
21
+ ? PathExt.dirname(activeDocumentInfo.filePath)
22
+ : '';
23
+ return (
24
+ // No `rehype-raw` plugin: raw HTML in chat markdown (e.g. an LLM
25
+ // emitting `<a href="javascript:...">`) renders as literal text, not
26
+ // a DOM anchor, so the only anchor sink is the CommonMark/GFM `a`
27
+ // node handled by `SafeAnchor` below. Any future change that enables
28
+ // raw HTML needs to add a rehype-sanitize pass alongside.
29
+ React.createElement(Markdown, { remarkPlugins: [remarkGfm], components: {
30
+ // CommonMark `<https://...>` autolinks, `[text](url)`, and
31
+ // reference-style links all normalize to the same `a` node.
32
+ // `MarkdownLink` routes fragment-only and workspace-relative
33
+ // hrefs through Lab's docmanager so an LLM-emitted link can't
34
+ // replace the JupyterLab shell, and hands everything else to
35
+ // SafeAnchor for the `_blank` + scheme-allowlist treatment.
36
+ a: ({ href, title, children }) => (React.createElement(MarkdownLink, { app: app, baseDir: linkBaseDir, href: href, title: title }, children)),
15
37
  code({ node, inline, className, children, getApp, ...props }) {
16
38
  const match = /language-(\w+)/.exec(className || '');
17
39
  const codeString = String(children).replace(/\n$/, '');
package/lib/tokens.d.ts CHANGED
@@ -41,6 +41,7 @@ export declare enum ResponseStreamDataType {
41
41
  Button = "button",
42
42
  Anchor = "anchor",
43
43
  Progress = "progress",
44
+ ToolCall = "tool-call",
44
45
  Confirmation = "confirmation",
45
46
  AskUserQuestion = "ask-user-question"
46
47
  }
package/lib/tokens.js CHANGED
@@ -32,6 +32,7 @@ export var ResponseStreamDataType;
32
32
  ResponseStreamDataType["Button"] = "button";
33
33
  ResponseStreamDataType["Anchor"] = "anchor";
34
34
  ResponseStreamDataType["Progress"] = "progress";
35
+ ResponseStreamDataType["ToolCall"] = "tool-call";
35
36
  ResponseStreamDataType["Confirmation"] = "confirmation";
36
37
  ResponseStreamDataType["AskUserQuestion"] = "ask-user-question";
37
38
  })(ResponseStreamDataType || (ResponseStreamDataType = {}));
@@ -0,0 +1,24 @@
1
+ import { ResponseStreamDataType } from './tokens';
2
+ /**
3
+ * The subset of a chat message's stream-content item this helper needs. A
4
+ * structural subset of `IChatMessageContent` (defined in chat-sidebar) so it
5
+ * can be unit-tested without importing the sidebar (and creating a cycle).
6
+ */
7
+ export interface IToolCallStreamItem {
8
+ id: string;
9
+ type: ResponseStreamDataType;
10
+ content: any;
11
+ created: Date;
12
+ }
13
+ /**
14
+ * Merge a streamed tool-call payload into `contents` by its tool-call id.
15
+ *
16
+ * A tool call streams twice under one id (once when it starts, once when it
17
+ * finishes). The first emission pushes a new card; the second updates that
18
+ * card's content (its status) in place, so the call stays a single persistent
19
+ * row rather than appending a duplicate. Mutates `contents`.
20
+ */
21
+ export declare function upsertToolCallContent(contents: IToolCallStreamItem[], content: {
22
+ id: string;
23
+ [key: string]: any;
24
+ }, created: Date): void;
@@ -0,0 +1,28 @@
1
+ import { UUID } from '@lumino/coreutils';
2
+ import { ResponseStreamDataType } from './tokens';
3
+ /**
4
+ * Merge a streamed tool-call payload into `contents` by its tool-call id.
5
+ *
6
+ * A tool call streams twice under one id (once when it starts, once when it
7
+ * finishes). The first emission pushes a new card; the second updates that
8
+ * card's content (its status) in place, so the call stays a single persistent
9
+ * row rather than appending a duplicate. Mutates `contents`.
10
+ */
11
+ export function upsertToolCallContent(contents, content, created) {
12
+ const existing = contents.find(c => {
13
+ var _a;
14
+ return c.type === ResponseStreamDataType.ToolCall &&
15
+ ((_a = c.content) === null || _a === void 0 ? void 0 : _a.id) === (content === null || content === void 0 ? void 0 : content.id);
16
+ });
17
+ if (existing) {
18
+ existing.content = content;
19
+ }
20
+ else {
21
+ contents.push({
22
+ id: UUID.uuid4(),
23
+ type: ResponseStreamDataType.ToolCall,
24
+ content,
25
+ created
26
+ });
27
+ }
28
+ }
package/lib/utils.d.ts CHANGED
@@ -26,6 +26,15 @@ export declare function getSelectionInEditor(editor: CodeEditor.IEditor): string
26
26
  export declare function getWholeNotebookContent(np: NotebookPanel): string;
27
27
  export declare function applyCodeToSelectionInEditor(editor: CodeEditor.IEditor, code: string): void;
28
28
  export { shellSingleQuote };
29
+ /**
30
+ * True when `s` contains any codepoint in the same set `safeAnchorUri`
31
+ * rejects (C0/DEL/C1, NEL/NBSP/LS/PS/BOM, ZWSP, bidi-override controls).
32
+ * Mirrors the Python `has_dangerous_text_codepoints`. Used to scrub the
33
+ * `title` attribute on rendered anchors so an LLM-emitted hover tooltip
34
+ * can't visually impersonate the link via bidi-reorder or zero-width
35
+ * tricks.
36
+ */
37
+ export declare function hasDangerousTextCodepoints(s: string | undefined | null): boolean;
29
38
  /**
30
39
  * Return `uri` if its scheme is in the chat-anchor allowlist, else null.
31
40
  * Mirrors the server-side `safe_anchor_uri` check so that anchor parts
package/lib/utils.js CHANGED
@@ -277,6 +277,25 @@ function isDisallowedUriCodepoint(code) {
277
277
  }
278
278
  return false;
279
279
  }
280
+ /**
281
+ * True when `s` contains any codepoint in the same set `safeAnchorUri`
282
+ * rejects (C0/DEL/C1, NEL/NBSP/LS/PS/BOM, ZWSP, bidi-override controls).
283
+ * Mirrors the Python `has_dangerous_text_codepoints`. Used to scrub the
284
+ * `title` attribute on rendered anchors so an LLM-emitted hover tooltip
285
+ * can't visually impersonate the link via bidi-reorder or zero-width
286
+ * tricks.
287
+ */
288
+ export function hasDangerousTextCodepoints(s) {
289
+ if (typeof s !== 'string') {
290
+ return false;
291
+ }
292
+ for (let i = 0; i < s.length; i++) {
293
+ if (isDisallowedUriCodepoint(s.charCodeAt(i))) {
294
+ return true;
295
+ }
296
+ }
297
+ return false;
298
+ }
280
299
  /**
281
300
  * Return `uri` if its scheme is in the chat-anchor allowlist, else null.
282
301
  * Mirrors the server-side `safe_anchor_uri` check so that anchor parts
@@ -322,10 +341,11 @@ export function safeAnchorUri(uri) {
322
341
  * user happens to be in the JupyterLab working directory.
323
342
  */
324
343
  export function buildResumeCommand(cwd, sessionId) {
344
+ const quotedSessionId = shellSingleQuote(sessionId);
325
345
  if (!cwd) {
326
- return `claude --resume ${sessionId}`;
346
+ return `claude --resume ${quotedSessionId}`;
327
347
  }
328
- return `cd ${shellSingleQuote(cwd)} && claude --resume ${sessionId}`;
348
+ return `cd ${shellSingleQuote(cwd)} && claude --resume ${quotedSessionId}`;
329
349
  }
330
350
  /**
331
351
  * Write `text` to the system clipboard. Falls back to a hidden textarea +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plmbr/notebook-intelligence",
3
- "version": "5.0.0",
3
+ "version": "5.1.0-a.1",
4
4
  "description": "AI coding assistant for JupyterLab",
5
5
  "keywords": [
6
6
  "AI",
@@ -49,6 +49,7 @@
49
49
  "install:extension": "jlpm build",
50
50
  "lint": "jlpm stylelint && jlpm prettier && jlpm eslint",
51
51
  "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check",
52
+ "postinstall": "husky || exit 0",
52
53
  "prettier": "jlpm prettier:base --write --list-different",
53
54
  "prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"",
54
55
  "prettier:check": "jlpm prettier:base --check",
@@ -98,8 +99,10 @@
98
99
  "eslint": "^8.36.0",
99
100
  "eslint-config-prettier": "^8.8.0",
100
101
  "eslint-plugin-prettier": "^5.0.0",
102
+ "husky": "^9.1.7",
101
103
  "jest": "^29.7.0",
102
104
  "jest-environment-jsdom": "^29.7.0",
105
+ "lint-staged": "^15.2.10",
103
106
  "mkdirp": "^1.0.3",
104
107
  "monaco-editor-webpack-plugin": "^2.0.0",
105
108
  "npm-run-all": "^4.1.5",
@@ -238,6 +241,9 @@
238
241
  }
239
242
  ]
240
243
  },
244
+ "lint-staged": {
245
+ "*": "prettier --write --ignore-unknown"
246
+ },
241
247
  "stylelint": {
242
248
  "extends": [
243
249
  "stylelint-config-recommended",
package/src/api.ts CHANGED
@@ -388,6 +388,10 @@ export class NBIConfig {
388
388
  return this.capabilities.claude_settings;
389
389
  }
390
390
 
391
+ get spinnerVerbs(): { mode: string; verbs: string[] } | null {
392
+ return this.capabilities.spinner_verbs ?? null;
393
+ }
394
+
391
395
  get claudeModels(): IClaudeModelInfo[] {
392
396
  return (this.capabilities.claude_models ?? []).map(claudeModelFromWire);
393
397
  }
@@ -435,6 +439,10 @@ export class NBIConfig {
435
439
  return this.capabilities.chat_feedback_enabled === true;
436
440
  }
437
441
 
442
+ get chatFeedbackAlwaysVisible(): boolean {
443
+ return this.capabilities.chat_feedback_always_visible === true;
444
+ }
445
+
438
446
  // Admin-supplied tour-copy overrides, served from the capabilities
439
447
  // response after server-side validation. Returns the raw dict; the
440
448
  // tour module decides how to apply it. Defaults to a shared frozen