xtralab 0.10.0 → 0.11.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.
Files changed (71) hide show
  1. package/README.md +27 -11
  2. package/lib/commandBar/index.d.ts +14 -0
  3. package/lib/commandBar/index.js +114 -0
  4. package/lib/fileBrowser/fileBrowser.js +6 -1
  5. package/lib/highlight/index.d.ts +15 -0
  6. package/lib/highlight/index.js +214 -0
  7. package/lib/index.js +13 -1
  8. package/lib/launcher/agents.d.ts +18 -1
  9. package/lib/launcher/agents.js +47 -4
  10. package/lib/launcher/editorRegistry.js +5 -3
  11. package/lib/launcher/editors.d.ts +17 -2
  12. package/lib/launcher/editors.js +31 -5
  13. package/lib/launcher/index.js +7 -1
  14. package/lib/launcher/schemaDefaults.d.ts +15 -0
  15. package/lib/launcher/schemaDefaults.js +44 -0
  16. package/lib/omnibox/files.d.ts +7 -0
  17. package/lib/omnibox/files.js +50 -0
  18. package/lib/omnibox/index.d.ts +14 -0
  19. package/lib/omnibox/index.js +83 -0
  20. package/lib/omnibox/model.d.ts +45 -0
  21. package/lib/omnibox/model.js +142 -0
  22. package/lib/omnibox/tokens.d.ts +23 -0
  23. package/lib/omnibox/tokens.js +11 -0
  24. package/lib/omnibox/widget.d.ts +36 -0
  25. package/lib/omnibox/widget.js +161 -0
  26. package/lib/searchReplace/index.d.ts +25 -0
  27. package/lib/searchReplace/index.js +68 -0
  28. package/lib/showOutput/index.d.ts +17 -0
  29. package/lib/showOutput/index.js +154 -0
  30. package/lib/terminals/index.d.ts +4 -0
  31. package/lib/terminals/index.js +65 -16
  32. package/lib/terminals/model.d.ts +56 -3
  33. package/lib/terminals/model.js +261 -5
  34. package/lib/terminals/widget.d.ts +3 -1
  35. package/lib/terminals/widget.js +11 -3
  36. package/lib/walkthrough/index.d.ts +14 -0
  37. package/lib/walkthrough/index.js +162 -0
  38. package/lib/walkthrough/panel.d.ts +54 -0
  39. package/lib/walkthrough/panel.js +98 -0
  40. package/package.json +1 -1
  41. package/schema/launcher.json +5 -4
  42. package/schema/terminals.json +15 -0
  43. package/src/commandBar/index.ts +165 -0
  44. package/src/fileBrowser/fileBrowser.tsx +8 -1
  45. package/src/highlight/index.ts +251 -0
  46. package/src/index.ts +13 -1
  47. package/src/launcher/agents.ts +53 -3
  48. package/src/launcher/editorRegistry.ts +8 -4
  49. package/src/launcher/editors.ts +37 -4
  50. package/src/launcher/index.ts +7 -1
  51. package/src/launcher/schemaDefaults.ts +53 -0
  52. package/src/omnibox/files.ts +68 -0
  53. package/src/omnibox/index.ts +105 -0
  54. package/src/omnibox/model.ts +210 -0
  55. package/src/omnibox/tokens.ts +29 -0
  56. package/src/omnibox/widget.tsx +322 -0
  57. package/src/searchReplace/index.ts +86 -0
  58. package/src/showOutput/index.ts +199 -0
  59. package/src/terminals/index.ts +79 -16
  60. package/src/terminals/model.ts +304 -3
  61. package/src/terminals/widget.tsx +21 -4
  62. package/src/walkthrough/index.ts +202 -0
  63. package/src/walkthrough/panel.ts +152 -0
  64. package/style/commandBar.css +104 -0
  65. package/style/highlight.css +11 -0
  66. package/style/index.css +5 -0
  67. package/style/index.js +5 -0
  68. package/style/omnibox.css +120 -0
  69. package/style/showOutput.css +11 -0
  70. package/style/terminals.css +54 -10
  71. package/style/walkthrough.css +80 -0
@@ -0,0 +1,86 @@
1
+ import {
2
+ ILabShell,
3
+ JupyterFrontEnd,
4
+ JupyterFrontEndPlugin
5
+ } from '@jupyterlab/application';
6
+ import { ICommandPalette } from '@jupyterlab/apputils';
7
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
8
+
9
+ const PLUGIN_ID = 'xtralab:search-replace';
10
+
11
+ const COMMAND_ID = 'xtralab:activate-search-replace';
12
+
13
+ /**
14
+ * Widget id of the panel contributed by `jupyterlab-search-replace`. The
15
+ * extension adds it to the left sidebar but registers no command of its own,
16
+ * so there is nothing to bind a shortcut to out of the box — this plugin adds
17
+ * the missing command.
18
+ */
19
+ const SEARCH_REPLACE_WIDGET_ID = 'jp-search-replace';
20
+
21
+ /**
22
+ * Bind Accel+Shift+F (Cmd+Shift+F on macOS, Ctrl+Shift+F elsewhere) to the
23
+ * project-wide Search and Replace panel, matching the equivalent shortcut in
24
+ * editors like VS Code.
25
+ *
26
+ * Two pieces are needed because that chord is already taken: JupyterLab core
27
+ * binds Accel+Shift+F to `filebrowser:toggle-main`.
28
+ *
29
+ * 1. The chord is freed declaratively. `SettingRegistry.reconcileShortcuts`
30
+ * keys collisions and disables on (keys, selector) — not on command — so the
31
+ * `@jupyterlab/shortcuts-extension:shortcuts` override in
32
+ * `default_setting_overrides.d/00-xtralab.json` disables the file-browser
33
+ * binding. The file browser stays reachable from its sidebar tab and the
34
+ * View menu; only the keyboard shortcut moves.
35
+ *
36
+ * 2. The new binding is added imperatively below rather than as another shipped
37
+ * default. A `disabled` default and a re-binding default land on the same
38
+ * (keys, selector) slot, and the disable suppresses every default on that
39
+ * slot — including the re-binding. `commands.addKeyBinding` runs outside the
40
+ * reconcile pass, so it is unaffected and wins cleanly once the file-browser
41
+ * binding is gone.
42
+ */
43
+ const plugin: JupyterFrontEndPlugin<void> = {
44
+ id: PLUGIN_ID,
45
+ description:
46
+ 'Activate the Search and Replace panel with Accel+Shift+F (Cmd/Ctrl+Shift+F).',
47
+ autoStart: true,
48
+ requires: [ILabShell],
49
+ optional: [ICommandPalette, ITranslator],
50
+ activate: (
51
+ app: JupyterFrontEnd,
52
+ labShell: ILabShell,
53
+ palette: ICommandPalette | null,
54
+ translator: ITranslator | null
55
+ ): void => {
56
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
57
+ const { commands } = app;
58
+
59
+ commands.addCommand(COMMAND_ID, {
60
+ label: trans.__('Search and Replace'),
61
+ caption: trans.__('Show the Search and Replace panel'),
62
+ execute: () => {
63
+ // `activateById` expands the left sidebar when collapsed, selects the
64
+ // tab, and the widget focuses its search input from `onAfterShow`, so
65
+ // the user can start typing a query immediately. It no-ops if
66
+ // `jupyterlab-search-replace` is unavailable.
67
+ labShell.activateById(SEARCH_REPLACE_WIDGET_ID);
68
+ }
69
+ });
70
+
71
+ commands.addKeyBinding({
72
+ command: COMMAND_ID,
73
+ keys: ['Accel Shift F'],
74
+ selector: 'body'
75
+ });
76
+
77
+ if (palette) {
78
+ palette.addItem({
79
+ command: COMMAND_ID,
80
+ category: trans.__('File Operations')
81
+ });
82
+ }
83
+ }
84
+ };
85
+
86
+ export default plugin;
@@ -0,0 +1,199 @@
1
+ import {
2
+ ILabShell,
3
+ JupyterFrontEnd,
4
+ JupyterFrontEndPlugin
5
+ } from '@jupyterlab/application';
6
+ import { ICommandPalette } from '@jupyterlab/apputils';
7
+ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
8
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
9
+ import type { ReadonlyPartialJSONValue } from '@lumino/coreutils';
10
+ import { Panel } from '@lumino/widgets';
11
+
12
+ const PLUGIN_ID = 'xtralab:show-output';
13
+
14
+ /** Render arbitrary rich content into a panel, with no notebook and no kernel. */
15
+ const SHOW_COMMAND = 'xtralab:show';
16
+
17
+ /**
18
+ * Parse a JSON-typed value that may have arrived as a string. Agents and the
19
+ * MCP bridge sometimes serialize a spec (e.g. a Vega-Lite object) to a string;
20
+ * for `application/...json` MIME types we accept either and parse the string.
21
+ */
22
+ function coerceData(
23
+ mimeType: string,
24
+ data: ReadonlyPartialJSONValue
25
+ ): ReadonlyPartialJSONValue {
26
+ if (typeof data === 'string' && /\+?json$/.test(mimeType)) {
27
+ try {
28
+ return JSON.parse(data) as ReadonlyPartialJSONValue;
29
+ } catch {
30
+ return data;
31
+ }
32
+ }
33
+ return data;
34
+ }
35
+
36
+ /**
37
+ * Contribute `xtralab:show`: render a single MIME bundle into a main-area
38
+ * panel using the application's render-mime registry.
39
+ *
40
+ * This is the kernel-free counterpart to running a notebook. A coding agent
41
+ * generates the content itself (a Vega-Lite spec, a Markdown explainer, an
42
+ * HTML fragment, an SVG, a base64 image) and hands it to this command, which
43
+ * renders it with the same renderers JupyterLab uses for cell output and docks
44
+ * the result beside the code. Nothing is written to disk and no kernel is
45
+ * required, so it works even where the kernel has no plotting libraries.
46
+ *
47
+ * Repeated calls with the same `id` reuse one panel, so a walkthrough can
48
+ * refresh the view in place instead of piling up tabs.
49
+ */
50
+ const plugin: JupyterFrontEndPlugin<void> = {
51
+ id: PLUGIN_ID,
52
+ description:
53
+ 'Render rich content (Markdown, Vega-Lite, HTML, images) into a panel.',
54
+ autoStart: true,
55
+ requires: [IRenderMimeRegistry, ILabShell],
56
+ optional: [ICommandPalette, ITranslator],
57
+ activate: (
58
+ app: JupyterFrontEnd,
59
+ rendermime: IRenderMimeRegistry,
60
+ labShell: ILabShell,
61
+ palette: ICommandPalette | null,
62
+ translator: ITranslator | null
63
+ ): void => {
64
+ const { commands } = app;
65
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
66
+
67
+ // One reusable panel per `id`, so repeated shows refresh in place.
68
+ const panels = new Map<string, Panel>();
69
+
70
+ commands.addCommand(SHOW_COMMAND, {
71
+ label: trans.__('Show Rich Output'),
72
+ caption: trans.__(
73
+ 'Render Markdown, a Vega-Lite chart, HTML, or an image in a panel'
74
+ ),
75
+ describedBy: {
76
+ args: {
77
+ type: 'object',
78
+ required: ['mimeType', 'data'],
79
+ properties: {
80
+ mimeType: {
81
+ type: 'string',
82
+ description:
83
+ 'MIME type to render, e.g. "text/markdown", "application/vnd.vegalite.v5+json", "text/html", "image/svg+xml", "image/png".'
84
+ },
85
+ data: {
86
+ description:
87
+ 'The content. A string for text/image types (base64 for "image/png"); an object for JSON types such as a Vega-Lite spec.'
88
+ },
89
+ label: {
90
+ type: 'string',
91
+ description: 'Title for the panel tab. Defaults to "Output".'
92
+ },
93
+ id: {
94
+ type: 'string',
95
+ description:
96
+ 'Panel identifier. Calls sharing an `id` reuse one panel; defaults to "default".'
97
+ },
98
+ area: {
99
+ type: 'string',
100
+ description:
101
+ 'Where the panel docks: "right" (default) puts it in the side area so the editor stays full width; "main" puts it in the document area (split with `mode`).'
102
+ },
103
+ mode: {
104
+ type: 'string',
105
+ description:
106
+ 'Only when `area` is "main": placement relative to the active tab, e.g. "split-right" (default), "split-bottom", "tab-after".'
107
+ }
108
+ }
109
+ }
110
+ },
111
+ execute: async args => {
112
+ const mimeType =
113
+ typeof args['mimeType'] === 'string' ? args['mimeType'] : undefined;
114
+ if (!mimeType || args['data'] === undefined) {
115
+ throw new Error('xtralab:show requires "mimeType" and "data"');
116
+ }
117
+ const data = coerceData(mimeType, args['data']);
118
+ const label =
119
+ typeof args['label'] === 'string'
120
+ ? args['label']
121
+ : trans.__('Output');
122
+ const id = typeof args['id'] === 'string' ? args['id'] : 'default';
123
+ const area = args['area'] === 'main' ? 'main' : 'right';
124
+ const mode =
125
+ typeof args['mode'] === 'string' ? args['mode'] : 'split-right';
126
+
127
+ const bundle = { [mimeType]: data };
128
+ const chosen = rendermime.preferredMimeType(bundle, 'any');
129
+ if (!chosen) {
130
+ throw new Error(
131
+ `xtralab:show: no renderer for MIME type "${mimeType}"`
132
+ );
133
+ }
134
+
135
+ const model = rendermime.createModel({ data: bundle, trusted: true });
136
+ const renderer = rendermime.createRenderer(chosen);
137
+ // A scrollable Panel, not a MainAreaWidget: MainAreaWidget does not lay
138
+ // out its content in the side area, and the panel needs to scroll when
139
+ // the content is taller than it (style/showOutput.css).
140
+ const widget = new Panel();
141
+ widget.addClass('jp-xtralab-ShowPanel');
142
+ widget.id = `xtralab-show-${id}`;
143
+ widget.title.label = label;
144
+ widget.title.closable = true;
145
+ widget.addWidget(renderer);
146
+
147
+ // Replace any existing panel with this id so shows refresh in place.
148
+ const existing = panels.get(id);
149
+ if (existing && !existing.isDisposed) {
150
+ existing.dispose();
151
+ }
152
+ panels.set(id, widget);
153
+ widget.disposed.connect(() => {
154
+ if (panels.get(id) === widget) {
155
+ panels.delete(id);
156
+ }
157
+ });
158
+
159
+ // Attach before rendering so renderers that need layout (Vega) size
160
+ // correctly. Default to the side area so the editor keeps full width;
161
+ // only split the document area when `area` is "main".
162
+ if (area === 'main') {
163
+ labShell.add(widget, 'main', {
164
+ mode: mode as DocumentMode,
165
+ activate: true
166
+ });
167
+ } else {
168
+ labShell.add(widget, 'right', { rank: 900 });
169
+ }
170
+ // Reveal the panel (the side area does not open from `add` alone).
171
+ labShell.activateById(widget.id);
172
+
173
+ try {
174
+ await renderer.renderModel(model);
175
+ } catch (error) {
176
+ widget.dispose();
177
+ throw error instanceof Error
178
+ ? error
179
+ : new Error(`xtralab:show: failed to render "${mimeType}"`);
180
+ }
181
+ return trans.__('Rendered %1 in "%2"', mimeType, label);
182
+ }
183
+ });
184
+
185
+ if (palette) {
186
+ palette.addItem({ command: SHOW_COMMAND, category: trans.__('Other') });
187
+ }
188
+ }
189
+ };
190
+
191
+ type DocumentMode =
192
+ | 'split-top'
193
+ | 'split-left'
194
+ | 'split-right'
195
+ | 'split-bottom'
196
+ | 'tab-before'
197
+ | 'tab-after';
198
+
199
+ export default plugin;
@@ -4,6 +4,7 @@ import {
4
4
  JupyterFrontEndPlugin
5
5
  } from '@jupyterlab/application';
6
6
  import { Dialog, showDialog } from '@jupyterlab/apputils';
7
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
7
8
  import { ITerminalTracker } from '@jupyterlab/terminal';
8
9
  import { ITranslator, nullTranslator } from '@jupyterlab/translation';
9
10
  import { LabIcon, MenuSvg, terminalIcon } from '@jupyterlab/ui-components';
@@ -26,6 +27,10 @@ const PLUGIN_ID = 'xtralab:terminals';
26
27
  * JupyterLab's built-in "Running Terminals and Kernels" panel lists the
27
28
  * same sessions but only by their `terminals/<n>` name, so this panel
28
29
  * resolves and caches the published title to show something meaningful.
30
+ * Rows running a coding agent carry a smaller second line below the
31
+ * title with the agent's latest line of output, so the panel shows what
32
+ * each agent is doing at a glance — on by default, and switchable off
33
+ * through the `showAgentActivity` setting.
29
34
  *
30
35
  * Clicking a row activates the existing tab if one is open, or reopens
31
36
  * the session in a fresh terminal widget (`terminal:open`). The inline
@@ -51,7 +56,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
51
56
  ITranslator,
52
57
  IAgentRegistry,
53
58
  IEditorRegistry,
54
- IAgentSessions
59
+ IAgentSessions,
60
+ ISettingRegistry
55
61
  ],
56
62
  activate: (
57
63
  app: JupyterFrontEnd,
@@ -60,25 +66,33 @@ const plugin: JupyterFrontEndPlugin<void> = {
60
66
  translator: ITranslator | null,
61
67
  agentRegistry: IAgentRegistry | null,
62
68
  editorRegistry: IEditorRegistry | null,
63
- agentSessions: IAgentSessions | null
69
+ agentSessions: IAgentSessions | null,
70
+ settingRegistry: ISettingRegistry | null
64
71
  ): void => {
65
72
  const trans = (translator ?? nullTranslator).load('jupyterlab');
66
73
 
67
- // Resolve a session's running command to an icon: the matching agent's
68
- // logo, the matching editor's logo (Neovim/Vim), or the plain terminal
69
- // icon when nothing recognised is running. Reads `agentRegistry.agents`
70
- // live so it tracks settings changes. Used by the panel to badge each row.
74
+ // Resolve a running agent/editor identifier to an icon: the matching
75
+ // agent's logo, the matching editor's logo (Neovim/Vim), or the plain
76
+ // terminal icon when nothing recognised is running. Detection reports
77
+ // either the configured `command` or the canonical `id` (see
78
+ // `detectCommands`), so a row whose command is an alias — e.g. `ccm` for
79
+ // `claude` — still resolves to the right logo via its id. Reads the
80
+ // registries live so it tracks settings changes. Used to badge each row.
71
81
  const iconForCommand = (command: string | null): LabIcon => {
72
82
  if (!command) {
73
83
  return terminalIcon;
74
84
  }
75
- const agent = agentRegistry?.agents.find(a => a.command === command);
85
+ const agent = agentRegistry?.agents.find(
86
+ a => a.command === command || a.id === command
87
+ );
76
88
  if (agent) {
77
89
  return agent.icon;
78
90
  }
79
91
  // A terminal running an editor (Neovim/Vim, or a user-configured one) is
80
92
  // badged with its logo, the same as an agent.
81
- const editor = editorRegistry?.editors.find(e => e.command === command);
93
+ const editor = editorRegistry?.editors.find(
94
+ e => e.command === command || e.id === command
95
+ );
82
96
  return editor?.icon ?? terminalIcon;
83
97
  };
84
98
 
@@ -90,14 +104,32 @@ const plugin: JupyterFrontEndPlugin<void> = {
90
104
  // nothing while a notebook or other widget is current instead).
91
105
  shell: app.shell,
92
106
  agentSessions,
93
- // The commands the server should look for: the current agent and editor
94
- // lists, each read live from its registry so they track settings changes
95
- // — a terminal running an agent or an editor (Neovim/Vim, or a configured
96
- // one) is badged.
97
- detectCommands: () => [
98
- ...(agentRegistry?.agents.map(a => a.command) ?? []),
99
- ...(editorRegistry?.editors.map(e => e.command) ?? [])
100
- ]
107
+ // The names the server should look for in each terminal's process tree:
108
+ // for every agent and editor, its configured `command` plus its canonical
109
+ // `id`. The id is the real CLI name (e.g. `claude`, `nvim`), so an agent
110
+ // whose command points at an alias — e.g. `ccm` running `claude` — is
111
+ // still recognised by the process it actually spawns. Read live so the
112
+ // list tracks settings changes; deduplicated since command and id usually
113
+ // coincide.
114
+ detectCommands: () => {
115
+ const names = new Set<string>();
116
+ for (const agent of agentRegistry?.agents ?? []) {
117
+ names.add(agent.command);
118
+ names.add(agent.id);
119
+ }
120
+ for (const editor of editorRegistry?.editors ?? []) {
121
+ names.add(editor.command);
122
+ names.add(editor.id);
123
+ }
124
+ return Array.from(names);
125
+ },
126
+ // Only coding agents (not editors) get a latest-activity line, so the
127
+ // registry asks this whether a detected command/id is an agent before
128
+ // reading its output buffer — editor terminals stay badge-only.
129
+ isAgentCommand: (command: string) =>
130
+ agentRegistry?.agents.some(
131
+ a => a.command === command || a.id === command
132
+ ) ?? false
101
133
  });
102
134
 
103
135
  // Mirror the agent/editor detected in each open terminal onto its main-area
@@ -216,7 +248,38 @@ const plugin: JupyterFrontEndPlugin<void> = {
216
248
  if (restorer) {
217
249
  restorer.add(panel, panel.id);
218
250
  }
251
+
252
+ // The latest-activity line under each agent row is on by default; honour
253
+ // the `showAgentActivity` setting so it can be turned off. Read on load and
254
+ // re-applied on every change.
255
+ if (settingRegistry) {
256
+ settingRegistry
257
+ .load(PLUGIN_ID)
258
+ .then(settings => {
259
+ const applyActivitySetting = (): void => {
260
+ registry.setActivityEnabled(
261
+ boolOption(settings.composite.showAgentActivity, true)
262
+ );
263
+ };
264
+ applyActivitySetting();
265
+ // Bind the slot to the panel so disposing the panel (which clears
266
+ // its signal data) drops the connection — the disposed registry is
267
+ // then never reached on a later settings change.
268
+ settings.changed.connect(applyActivitySetting, panel);
269
+ })
270
+ .catch(reason => {
271
+ console.error(
272
+ `xtralab: failed to load ${PLUGIN_ID} settings`,
273
+ reason
274
+ );
275
+ });
276
+ }
219
277
  }
220
278
  };
221
279
 
280
+ /** Read a boolean setting value, falling back when it is missing or invalid. */
281
+ function boolOption(value: unknown, fallback: boolean): boolean {
282
+ return typeof value === 'boolean' ? value : fallback;
283
+ }
284
+
222
285
  export default plugin;