xtralab 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +40 -9
  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 +15 -1
  8. package/lib/launcher/agents.d.ts +7 -0
  9. package/lib/launcher/agents.js +24 -0
  10. package/lib/launcher/editorRegistry.js +5 -3
  11. package/lib/launcher/editors.d.ts +8 -0
  12. package/lib/launcher/editors.js +17 -0
  13. package/lib/launcher/index.js +4 -0
  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/terminalNotifications/index.d.ts +15 -0
  31. package/lib/terminalNotifications/index.js +213 -0
  32. package/lib/terminals/index.d.ts +4 -0
  33. package/lib/terminals/index.js +65 -16
  34. package/lib/terminals/model.d.ts +56 -3
  35. package/lib/terminals/model.js +261 -5
  36. package/lib/terminals/widget.d.ts +3 -1
  37. package/lib/terminals/widget.js +11 -3
  38. package/lib/walkthrough/index.d.ts +14 -0
  39. package/lib/walkthrough/index.js +162 -0
  40. package/lib/walkthrough/panel.d.ts +54 -0
  41. package/lib/walkthrough/panel.js +98 -0
  42. package/package.json +1 -1
  43. package/schema/launcher.json +3 -2
  44. package/schema/terminal-notifications.json +21 -0
  45. package/schema/terminals.json +15 -0
  46. package/src/commandBar/index.ts +165 -0
  47. package/src/fileBrowser/fileBrowser.tsx +8 -1
  48. package/src/highlight/index.ts +251 -0
  49. package/src/index.ts +15 -1
  50. package/src/launcher/agents.ts +25 -0
  51. package/src/launcher/editorRegistry.ts +8 -4
  52. package/src/launcher/editors.ts +18 -0
  53. package/src/launcher/index.ts +4 -0
  54. package/src/launcher/schemaDefaults.ts +53 -0
  55. package/src/omnibox/files.ts +68 -0
  56. package/src/omnibox/index.ts +105 -0
  57. package/src/omnibox/model.ts +210 -0
  58. package/src/omnibox/tokens.ts +29 -0
  59. package/src/omnibox/widget.tsx +322 -0
  60. package/src/searchReplace/index.ts +86 -0
  61. package/src/showOutput/index.ts +199 -0
  62. package/src/terminalNotifications/index.ts +295 -0
  63. package/src/terminals/index.ts +79 -16
  64. package/src/terminals/model.ts +304 -3
  65. package/src/terminals/widget.tsx +21 -4
  66. package/src/walkthrough/index.ts +202 -0
  67. package/src/walkthrough/panel.ts +152 -0
  68. package/style/commandBar.css +104 -0
  69. package/style/highlight.css +11 -0
  70. package/style/index.css +5 -0
  71. package/style/index.js +5 -0
  72. package/style/omnibox.css +120 -0
  73. package/style/showOutput.css +11 -0
  74. package/style/terminals.css +54 -10
  75. package/style/walkthrough.css +80 -0
@@ -0,0 +1,251 @@
1
+ import {
2
+ JupyterFrontEnd,
3
+ JupyterFrontEndPlugin
4
+ } from '@jupyterlab/application';
5
+ import { ICommandPalette } from '@jupyterlab/apputils';
6
+ import { CodeMirrorEditor } from '@jupyterlab/codemirror';
7
+ import { IDocumentManager } from '@jupyterlab/docmanager';
8
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
9
+ import { StateEffect, StateField } from '@codemirror/state';
10
+ import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
11
+ import type { Widget } from '@lumino/widgets';
12
+
13
+ const PLUGIN_ID = 'xtralab:highlight';
14
+
15
+ /**
16
+ * Highlight a contiguous range of lines, or clear every highlight. These are
17
+ * the only two commands the plugin contributes; both are designed to be driven
18
+ * by a coding agent over the MCP command bridge to walk a user through code.
19
+ */
20
+ const HIGHLIGHT_LINES_COMMAND = 'xtralab:highlight-lines';
21
+ const CLEAR_HIGHLIGHTS_COMMAND = 'xtralab:clear-highlights';
22
+
23
+ /** CodeMirror line-decoration class; styled in style/highlight.css. */
24
+ const HIGHLIGHT_LINE_CLASS = 'jp-xtralab-highlightLine';
25
+
26
+ /**
27
+ * A CodeMirror effect carrying the 1-indexed, inclusive line range to
28
+ * highlight, or `null` to clear the editor's highlights.
29
+ */
30
+ const setHighlight = StateEffect.define<{
31
+ start: number;
32
+ end: number;
33
+ } | null>();
34
+
35
+ /**
36
+ * Holds the highlight decorations for one editor. It is injected lazily (the
37
+ * first time an editor is highlighted) via `StateEffect.appendConfig`, so
38
+ * editors that are never highlighted pay nothing.
39
+ */
40
+ const highlightField = StateField.define<DecorationSet>({
41
+ create: () => Decoration.none,
42
+ update(decorations, transaction) {
43
+ // Keep decorations anchored as the user edits around them.
44
+ decorations = decorations.map(transaction.changes);
45
+ for (const effect of transaction.effects) {
46
+ if (!effect.is(setHighlight)) {
47
+ continue;
48
+ }
49
+ if (effect.value === null) {
50
+ decorations = Decoration.none;
51
+ continue;
52
+ }
53
+ const { doc } = transaction.state;
54
+ const lineDecoration = Decoration.line({ class: HIGHLIGHT_LINE_CLASS });
55
+ const start = Math.max(1, Math.min(effect.value.start, doc.lines));
56
+ const end = Math.max(start, Math.min(effect.value.end, doc.lines));
57
+ const ranges = [];
58
+ for (let line = start; line <= end; line++) {
59
+ ranges.push(lineDecoration.range(doc.line(line).from));
60
+ }
61
+ decorations = Decoration.set(ranges);
62
+ }
63
+ return decorations;
64
+ },
65
+ provide: field => EditorView.decorations.from(field)
66
+ });
67
+
68
+ /**
69
+ * Return the CodeMirror view backing a widget's editor, or `null` when the
70
+ * widget is not a CodeMirror-based text editor (a notebook, a terminal, the
71
+ * settings editor, …). Duck-typed on `content.editor` so the plugin does not
72
+ * depend on `@jupyterlab/fileeditor`.
73
+ */
74
+ function viewForWidget(widget: Widget | null): EditorView | null {
75
+ const editor = (widget as { content?: { editor?: unknown } } | null)?.content
76
+ ?.editor;
77
+ return editor instanceof CodeMirrorEditor ? editor.editor : null;
78
+ }
79
+
80
+ /**
81
+ * Coerce a JSON command argument to a positive integer line number, falling
82
+ * back to `fallback` when it is missing or not a finite number.
83
+ */
84
+ function toLine(value: unknown, fallback: number): number {
85
+ const n = Math.trunc(Number(value));
86
+ return Number.isFinite(n) && n >= 1 ? n : fallback;
87
+ }
88
+
89
+ /**
90
+ * Contribute `xtralab:highlight-lines` and `xtralab:clear-highlights`.
91
+ *
92
+ * The built-in command surface can open a file (`docmanager:open`) and move
93
+ * the cursor to a line (`fileeditor:go-to-line`), but it cannot persistently
94
+ * highlight a span of lines — `documentsearch:start` only marks text matches
95
+ * and hijacks the find box. This plugin fills that gap with a CodeMirror line
96
+ * decoration, so an agent can point at "lines 31–43 of src/index.ts" while it
97
+ * narrates a walkthrough. `xtralab:highlight-lines` opens the file when needed,
98
+ * scrolls the range into view, and replaces any previous highlight in that
99
+ * editor; `xtralab:clear-highlights` removes every highlight.
100
+ */
101
+ const plugin: JupyterFrontEndPlugin<void> = {
102
+ id: PLUGIN_ID,
103
+ description: 'Highlight line ranges in text editors to guide walkthroughs.',
104
+ autoStart: true,
105
+ requires: [IDocumentManager],
106
+ optional: [ICommandPalette, ITranslator],
107
+ activate: (
108
+ app: JupyterFrontEnd,
109
+ docManager: IDocumentManager,
110
+ palette: ICommandPalette | null,
111
+ translator: ITranslator | null
112
+ ): void => {
113
+ const { commands, shell } = app;
114
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
115
+
116
+ // Editors that currently carry a highlight, so a single clear can reach
117
+ // them all. Disposed editors are dropped on the next clear.
118
+ const highlighted = new Set<EditorView>();
119
+
120
+ const ensureField = (view: EditorView): void => {
121
+ if (view.state.field(highlightField, false) === undefined) {
122
+ view.dispatch({ effects: StateEffect.appendConfig.of(highlightField) });
123
+ }
124
+ };
125
+
126
+ commands.addCommand(HIGHLIGHT_LINES_COMMAND, {
127
+ label: trans.__('Highlight Lines'),
128
+ caption: trans.__('Highlight a range of lines in a text editor'),
129
+ // Advertise the argument shape so agents listing commands over the MCP
130
+ // bridge can see how to call it, the same way core commands do.
131
+ describedBy: {
132
+ args: {
133
+ type: 'object',
134
+ properties: {
135
+ path: {
136
+ type: 'string',
137
+ description:
138
+ 'Path of the file to highlight in, relative to the server root. Opened if needed; defaults to the active editor when omitted.'
139
+ },
140
+ line: {
141
+ type: 'number',
142
+ description: 'First line to highlight (1-indexed). Defaults to 1.'
143
+ },
144
+ endLine: {
145
+ type: 'number',
146
+ description:
147
+ 'Last line to highlight (1-indexed, inclusive). Defaults to `line` for a single line.'
148
+ },
149
+ reveal: {
150
+ type: 'boolean',
151
+ description:
152
+ 'Whether to scroll the highlighted range into view. Defaults to true.'
153
+ }
154
+ }
155
+ }
156
+ },
157
+ execute: async args => {
158
+ const line = toLine(args['line'], 1);
159
+ const endLine = toLine(args['endLine'], line);
160
+ const path =
161
+ typeof args['path'] === 'string' ? args['path'] : undefined;
162
+ const reveal = args['reveal'] !== false;
163
+
164
+ // With a path, bind strictly to that document: never fall back to the
165
+ // active widget, or an explicit path could silently highlight an
166
+ // unrelated editor that happens to be focused.
167
+ let widget: Widget | null;
168
+ if (path) {
169
+ // Fail fast on a missing path instead of opening a phantom widget
170
+ // behind a blocking "file not found" dialog.
171
+ try {
172
+ await docManager.services.contents.get(path, { content: false });
173
+ } catch {
174
+ throw new Error(`xtralab: no file at "${path}"`);
175
+ }
176
+ const opened = docManager.openOrReveal(path);
177
+ if (!opened) {
178
+ throw new Error(`xtralab: could not open "${path}"`);
179
+ }
180
+ // Wait for the model and the editor view to be ready.
181
+ await opened.context.ready;
182
+ await opened.revealed;
183
+ widget = opened;
184
+ } else {
185
+ widget = shell.currentWidget;
186
+ }
187
+
188
+ const view = viewForWidget(widget);
189
+ if (!view) {
190
+ throw new Error(
191
+ path
192
+ ? `xtralab: "${path}" is not open in a text editor`
193
+ : 'xtralab: the active widget is not a text editor'
194
+ );
195
+ }
196
+
197
+ const start = Math.min(line, endLine);
198
+ const end = Math.max(line, endLine);
199
+ ensureField(view);
200
+
201
+ const lineCount = view.state.doc.lines;
202
+ const anchor = view.state.doc.line(
203
+ Math.max(1, Math.min(start, lineCount))
204
+ ).from;
205
+ const effects: StateEffect<unknown>[] = [
206
+ setHighlight.of({ start, end })
207
+ ];
208
+ if (reveal) {
209
+ effects.push(EditorView.scrollIntoView(anchor, { y: 'center' }));
210
+ }
211
+ view.dispatch({ effects });
212
+ // Forget editors that have since been closed before tracking this one.
213
+ for (const tracked of highlighted) {
214
+ if (!tracked.dom.isConnected) {
215
+ highlighted.delete(tracked);
216
+ }
217
+ }
218
+ highlighted.add(view);
219
+
220
+ return start === end
221
+ ? trans.__('Highlighted line %1', start)
222
+ : trans.__('Highlighted lines %1-%2', start, end);
223
+ }
224
+ });
225
+
226
+ commands.addCommand(CLEAR_HIGHLIGHTS_COMMAND, {
227
+ label: trans.__('Clear Highlights'),
228
+ caption: trans.__('Remove all xtralab line highlights'),
229
+ execute: () => {
230
+ for (const view of highlighted) {
231
+ try {
232
+ if (view.state.field(highlightField, false) !== undefined) {
233
+ view.dispatch({ effects: setHighlight.of(null) });
234
+ }
235
+ } catch {
236
+ // The editor was disposed; nothing to clear.
237
+ }
238
+ }
239
+ highlighted.clear();
240
+ }
241
+ });
242
+
243
+ if (palette) {
244
+ const category = trans.__('Other');
245
+ palette.addItem({ command: HIGHLIGHT_LINES_COMMAND, category });
246
+ palette.addItem({ command: CLEAR_HIGHLIGHTS_COMMAND, category });
247
+ }
248
+ }
249
+ };
250
+
251
+ export default plugin;
package/src/index.ts CHANGED
@@ -1,16 +1,23 @@
1
1
  import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
2
 
3
3
  import agentSessionsPlugin from './agentSessions';
4
+ import commandBarPlugin from './commandBar';
4
5
  import fileBrowserPlugin from './fileBrowser';
5
6
  import fileTypeIconsPlugin from './fileTypeIcons';
6
7
  import editorBreadcrumbsPlugin from './editorBreadcrumbs';
7
8
  import editorIndentPlugin from './editorIndent';
8
9
  import gitPlugins from './git';
10
+ import highlightPlugin from './highlight';
9
11
  import launcherPlugins from './launcher';
10
12
  import menusPlugin from './menus';
13
+ import omniboxPlugin from './omnibox';
14
+ import searchReplacePlugin from './searchReplace';
15
+ import showOutputPlugin from './showOutput';
11
16
  import sidebarPlugin from './sidebar';
17
+ import terminalNotificationsPlugin from './terminalNotifications';
12
18
  import terminalsPlugin from './terminals';
13
19
  import topBarPlugin from './topBar';
20
+ import walkthroughPlugin from './walkthrough';
14
21
 
15
22
  /**
16
23
  * Every plugin contributed by `xtralab`. The entry point of the
@@ -23,16 +30,23 @@ import topBarPlugin from './topBar';
23
30
  */
24
31
  const plugins: JupyterFrontEndPlugin<unknown>[] = [
25
32
  agentSessionsPlugin,
33
+ commandBarPlugin,
26
34
  editorBreadcrumbsPlugin,
27
35
  editorIndentPlugin,
28
36
  fileBrowserPlugin,
29
37
  fileTypeIconsPlugin,
30
38
  ...gitPlugins,
39
+ highlightPlugin,
31
40
  ...launcherPlugins,
32
41
  menusPlugin,
42
+ omniboxPlugin,
43
+ searchReplacePlugin,
44
+ showOutputPlugin,
33
45
  sidebarPlugin,
46
+ terminalNotificationsPlugin,
34
47
  terminalsPlugin,
35
- topBarPlugin
48
+ topBarPlugin,
49
+ walkthroughPlugin
36
50
  ];
37
51
 
38
52
  export default plugins;
@@ -178,6 +178,31 @@ const DEFAULTS: IAgent[] = [
178
178
  }
179
179
  ];
180
180
 
181
+ /**
182
+ * The built-in agents projected into the JSON settings shape
183
+ * ({@link IAgentSettings}), dropping the runtime-only {@link LabIcon}.
184
+ * {@link registerLauncherSchemaDefaults} injects this as the `agents` setting's
185
+ * schema default so the Settings Editor shows the shipped list.
186
+ */
187
+ export function defaultAgentSettings(): IAgentSettings[] {
188
+ return DEFAULTS.map(agent => {
189
+ const entry: IAgentSettings = {
190
+ id: agent.id,
191
+ label: agent.label,
192
+ caption: agent.caption,
193
+ command: agent.command,
194
+ rank: agent.rank,
195
+ requireAvailable: agent.requireAvailable
196
+ };
197
+ // Omit `promptArgs` for agents that don't take an inline prompt, so the
198
+ // default doesn't show a prompt form they lack.
199
+ if (agent.promptArgs !== undefined) {
200
+ entry.promptArgs = agent.promptArgs;
201
+ }
202
+ return entry;
203
+ });
204
+ }
205
+
181
206
  function resolveIcon(id: string, iconSvg: string | undefined): LabIcon {
182
207
  if (iconSvg) {
183
208
  return new LabIcon({ name: `xtralab:agent-custom-${id}`, svgstr: iconSvg });
@@ -13,6 +13,10 @@ import {
13
13
  type IEditor,
14
14
  type IEditorSettings
15
15
  } from './editors';
16
+ import {
17
+ LAUNCHER_PLUGIN_ID,
18
+ registerLauncherSchemaDefaults
19
+ } from './schemaDefaults';
16
20
 
17
21
  /**
18
22
  * A read-only, observable view of the launcher's terminal editors: xtralab's
@@ -80,9 +84,6 @@ class EditorRegistry implements IEditorRegistry {
80
84
  private _changed = new Signal<IEditorRegistry, void>(this);
81
85
  }
82
86
 
83
- /** Schema id the `editors` setting lives under (shared with the launcher). */
84
- const LAUNCHER_SETTINGS_ID = 'xtralab:launcher';
85
-
86
87
  /**
87
88
  * Provides {@link IEditorRegistry}: merges xtralab's built-in editors with the
88
89
  * user's `editors` setting, probes the server's `$PATH` to resolve the launcher
@@ -124,8 +125,11 @@ export const editorRegistryPlugin: JupyterFrontEndPlugin<IEditorRegistry> = {
124
125
  };
125
126
 
126
127
  if (settingRegistry) {
128
+ // Must precede the first `load`: the schema defers loading until a
129
+ // transform is registered.
130
+ registerLauncherSchemaDefaults(settingRegistry);
127
131
  try {
128
- const settings = await settingRegistry.load(LAUNCHER_SETTINGS_ID);
132
+ const settings = await settingRegistry.load(LAUNCHER_PLUGIN_ID);
129
133
  await apply(readOverrides(settings));
130
134
  settings.changed.connect(async () => {
131
135
  try {
@@ -88,6 +88,24 @@ const DEFAULTS: IEditor[] = [
88
88
  }
89
89
  ];
90
90
 
91
+ /**
92
+ * The built-in editors projected into the JSON settings shape
93
+ * ({@link IEditorSettings}), dropping the runtime-only {@link LabIcon}. Mirrors
94
+ * {@link defaultAgentSettings}: {@link registerLauncherSchemaDefaults} injects
95
+ * this as the `editors` setting's schema default so the Settings Editor shows
96
+ * the shipped list.
97
+ */
98
+ export function defaultEditorSettings(): IEditorSettings[] {
99
+ return DEFAULTS.map(editor => ({
100
+ id: editor.id,
101
+ label: editor.label,
102
+ caption: editor.caption,
103
+ command: editor.command,
104
+ rank: editor.rank,
105
+ requireAvailable: editor.requireAvailable
106
+ }));
107
+ }
108
+
91
109
  function resolveEditorIcon(id: string, iconSvg: string | undefined): LabIcon {
92
110
  if (iconSvg) {
93
111
  return new LabIcon({
@@ -19,6 +19,7 @@ import { CREATE_LAUNCHER_COMMAND, registerAgentCommands } from './commands';
19
19
  import { LauncherDashboard } from './dashboard';
20
20
  import { editorRegistryPlugin, IEditorRegistry } from './editorRegistry';
21
21
  import { AgentRegistry } from './registry';
22
+ import { registerLauncherSchemaDefaults } from './schemaDefaults';
22
23
  import { IAgentRegistry } from './tokens';
23
24
 
24
25
  const PLUGIN_ID = 'xtralab:launcher';
@@ -110,6 +111,9 @@ const plugin: JupyterFrontEndPlugin<IAgentRegistry> = {
110
111
  };
111
112
 
112
113
  if (settingRegistry) {
114
+ // Must precede the first `load`: the schema defers loading until a
115
+ // transform is registered.
116
+ registerLauncherSchemaDefaults(settingRegistry);
113
117
  try {
114
118
  const settings = await settingRegistry.load(PLUGIN_ID);
115
119
  await applyAgents(readOverrides(settings));
@@ -0,0 +1,53 @@
1
+ import type { PartialJSONValue } from '@lumino/coreutils';
2
+ import type { ISettingRegistry } from '@jupyterlab/settingregistry';
3
+
4
+ import { defaultAgentSettings } from './agents';
5
+ import { defaultEditorSettings } from './editors';
6
+
7
+ /** Schema id the launcher's `agents`/`editors` settings live under. */
8
+ export const LAUNCHER_PLUGIN_ID = 'xtralab:launcher';
9
+
10
+ /**
11
+ * Setting registries that already have the transform, so both callers skip
12
+ * re-registering (the registry rejects a duplicate transformer).
13
+ */
14
+ const registered = new WeakSet<ISettingRegistry>();
15
+
16
+ /**
17
+ * Register a `fetch` transform that injects xtralab's built-in agent and
18
+ * editor lists as the schema defaults for the `xtralab:launcher` settings, so
19
+ * the Settings Editor shows the shipped list.
20
+ *
21
+ * The schema declares `jupyter.lab.transform: true`, so the registry defers
22
+ * loading the plugin until a transform is registered: callers must invoke this
23
+ * before their first `settingRegistry.load(LAUNCHER_PLUGIN_ID)`. Idempotent, so
24
+ * the two plugins that load these settings can both call it regardless of
25
+ * activation order.
26
+ */
27
+ export function registerLauncherSchemaDefaults(
28
+ settingRegistry: ISettingRegistry
29
+ ): void {
30
+ if (registered.has(settingRegistry)) {
31
+ return;
32
+ }
33
+ registered.add(settingRegistry);
34
+
35
+ settingRegistry.transform(LAUNCHER_PLUGIN_ID, {
36
+ fetch: plugin => {
37
+ const properties = plugin.schema.properties;
38
+ if (properties) {
39
+ // The settings shapes are plain JSON by construction, but their named
40
+ // interfaces lack an index signature, so cast at this TS→JSON boundary.
41
+ if (properties.agents) {
42
+ properties.agents.default =
43
+ defaultAgentSettings() as unknown as PartialJSONValue;
44
+ }
45
+ if (properties.editors) {
46
+ properties.editors.default =
47
+ defaultEditorSettings() as unknown as PartialJSONValue;
48
+ }
49
+ }
50
+ return plugin;
51
+ }
52
+ });
53
+ }
@@ -0,0 +1,68 @@
1
+ import { URLExt } from '@jupyterlab/coreutils';
2
+ import { ServerConnection } from '@jupyterlab/services';
3
+
4
+ /**
5
+ * Shape of the `jupyterlab-quickopen` `api/files` response: a map from each
6
+ * directory (relative to the server root, `""` for the root itself) to the
7
+ * bare filenames it contains.
8
+ */
9
+ interface IQuickOpenContents {
10
+ contents: { [dir: string]: string[] };
11
+ }
12
+
13
+ /**
14
+ * Fetch and flatten the workspace file list from `jupyterlab-quickopen`'s
15
+ * server endpoint (`GET {base}/jupyterlab-quickopen/api/files`).
16
+ *
17
+ * We reuse quickopen's endpoint rather than walking the contents API so the
18
+ * listing honors the same server-side `.gitignore` filtering quickopen does
19
+ * (xtralab ships `respectGitignore: true`) in a single request. The endpoint
20
+ * only exists when quickopen's server extension is installed and the server
21
+ * reads a local filesystem; callers treat a rejection as "no file results".
22
+ */
23
+ async function fetchWorkspaceFiles(respectGitignore = true): Promise<string[]> {
24
+ const settings = ServerConnection.makeSettings();
25
+ const params = new URLSearchParams();
26
+ if (respectGitignore) {
27
+ params.append('respect_gitignore', '1');
28
+ }
29
+ // The handler requires a `path` argument; `""` scans from the root.
30
+ params.append('path', '');
31
+ const url =
32
+ URLExt.join(settings.baseUrl, 'jupyterlab-quickopen', 'api', 'files') +
33
+ `?${params.toString()}`;
34
+
35
+ const response = await ServerConnection.makeRequest(
36
+ url,
37
+ { method: 'GET' },
38
+ settings
39
+ );
40
+ if (response.status !== 200) {
41
+ throw new ServerConnection.ResponseError(response);
42
+ }
43
+ const data = (await response.json()) as IQuickOpenContents;
44
+ const paths: string[] = [];
45
+ for (const dir in data.contents) {
46
+ for (const name of data.contents[dir]) {
47
+ paths.push(dir ? `${dir}/${name}` : name);
48
+ }
49
+ }
50
+ return paths;
51
+ }
52
+
53
+ const CACHE_TTL_MS = 15_000;
54
+ let cache: { at: number; files: Promise<string[]> } | null = null;
55
+
56
+ /**
57
+ * The workspace file list, cached briefly so reopening the omnibox in quick
58
+ * succession reuses one scan. A failed fetch (e.g. quickopen's server
59
+ * extension is absent) resolves to an empty list so file search degrades to
60
+ * "no results" instead of surfacing an error.
61
+ */
62
+ export function loadWorkspaceFiles(): Promise<string[]> {
63
+ const now = Date.now();
64
+ if (!cache || now - cache.at > CACHE_TTL_MS) {
65
+ cache = { at: now, files: fetchWorkspaceFiles().catch(() => []) };
66
+ }
67
+ return cache.files;
68
+ }
@@ -0,0 +1,105 @@
1
+ import {
2
+ JupyterFrontEnd,
3
+ JupyterFrontEndPlugin
4
+ } from '@jupyterlab/application';
5
+ import { ICommandPalette } from '@jupyterlab/apputils';
6
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
7
+ import { Widget } from '@lumino/widgets';
8
+
9
+ import { IAgentRegistry } from '../launcher/tokens';
10
+
11
+ import { IOmnibox, OMNIBOX_OPEN_COMMAND } from './tokens';
12
+ import { OmniboxWidget } from './widget';
13
+
14
+ const PLUGIN_ID = 'xtralab:omnibox';
15
+
16
+ /**
17
+ * The omnibox: a single launcher overlay that fuzzy-searches workspace files
18
+ * (via jupyterlab-quickopen's gitignore-aware endpoint) and JupyterLab
19
+ * commands, and routes a typed prompt to one of the configured agents (running
20
+ * it in a fresh terminal through the launcher's `xtralab:start-agent:<id>`
21
+ * commands). It is opened by the top-bar command bar.
22
+ *
23
+ * The agent rows come from the launcher's `IAgentRegistry`; when the launcher
24
+ * is disabled the omnibox still searches files and commands.
25
+ */
26
+ const plugin: JupyterFrontEndPlugin<IOmnibox> = {
27
+ id: PLUGIN_ID,
28
+ description:
29
+ 'A launcher overlay that searches files and commands and prompts agents.',
30
+ autoStart: true,
31
+ provides: IOmnibox,
32
+ optional: [IAgentRegistry, ICommandPalette, ITranslator],
33
+ activate: (
34
+ app: JupyterFrontEnd,
35
+ agentRegistry: IAgentRegistry | null,
36
+ palette: ICommandPalette | null,
37
+ translator: ITranslator | null
38
+ ): IOmnibox => {
39
+ const { commands, docRegistry } = app;
40
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
41
+ const placeholder = trans.__('Search files and commands, or ask an agent…');
42
+
43
+ let current: OmniboxWidget | null = null;
44
+
45
+ const close = (): void => {
46
+ const widget = current;
47
+ current = null;
48
+ widget?.dispose();
49
+ };
50
+
51
+ const open = (query?: string): void => {
52
+ // Reopen fresh each time so the input resets and the agent snapshot is
53
+ // current; the file list is cached separately (see files.ts).
54
+ close();
55
+ const widget = new OmniboxWidget({
56
+ commands,
57
+ docRegistry,
58
+ agents: agentRegistry ? agentRegistry.agents : [],
59
+ placeholder,
60
+ initialQuery: query ?? '',
61
+ trans,
62
+ onClose: close
63
+ });
64
+ current = widget;
65
+ widget.disposed.connect(() => {
66
+ if (current === widget) {
67
+ current = null;
68
+ }
69
+ });
70
+ Widget.attach(widget, document.body);
71
+ };
72
+
73
+ commands.addCommand(OMNIBOX_OPEN_COMMAND, {
74
+ label: trans.__('Search…'),
75
+ caption: trans.__('Search files and commands, or ask an agent'),
76
+ execute: args => {
77
+ // Toggle: a second press (e.g. of the keyboard shortcut) closes it.
78
+ if (current && !current.isDisposed) {
79
+ close();
80
+ return;
81
+ }
82
+ const query = args['query'];
83
+ open(typeof query === 'string' ? query : undefined);
84
+ }
85
+ });
86
+
87
+ // Open the omnibox with Cmd/Ctrl+K, the common "command center" chord.
88
+ commands.addKeyBinding({
89
+ command: OMNIBOX_OPEN_COMMAND,
90
+ keys: ['Accel K'],
91
+ selector: 'body'
92
+ });
93
+
94
+ if (palette) {
95
+ palette.addItem({
96
+ command: OMNIBOX_OPEN_COMMAND,
97
+ category: trans.__('Other')
98
+ });
99
+ }
100
+
101
+ return { open, close };
102
+ }
103
+ };
104
+
105
+ export default plugin;