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.
- package/README.md +27 -11
- package/lib/commandBar/index.d.ts +14 -0
- package/lib/commandBar/index.js +114 -0
- package/lib/fileBrowser/fileBrowser.js +6 -1
- package/lib/highlight/index.d.ts +15 -0
- package/lib/highlight/index.js +214 -0
- package/lib/index.js +13 -1
- package/lib/launcher/agents.d.ts +18 -1
- package/lib/launcher/agents.js +47 -4
- package/lib/launcher/editorRegistry.js +5 -3
- package/lib/launcher/editors.d.ts +17 -2
- package/lib/launcher/editors.js +31 -5
- package/lib/launcher/index.js +7 -1
- package/lib/launcher/schemaDefaults.d.ts +15 -0
- package/lib/launcher/schemaDefaults.js +44 -0
- package/lib/omnibox/files.d.ts +7 -0
- package/lib/omnibox/files.js +50 -0
- package/lib/omnibox/index.d.ts +14 -0
- package/lib/omnibox/index.js +83 -0
- package/lib/omnibox/model.d.ts +45 -0
- package/lib/omnibox/model.js +142 -0
- package/lib/omnibox/tokens.d.ts +23 -0
- package/lib/omnibox/tokens.js +11 -0
- package/lib/omnibox/widget.d.ts +36 -0
- package/lib/omnibox/widget.js +161 -0
- package/lib/searchReplace/index.d.ts +25 -0
- package/lib/searchReplace/index.js +68 -0
- package/lib/showOutput/index.d.ts +17 -0
- package/lib/showOutput/index.js +154 -0
- package/lib/terminals/index.d.ts +4 -0
- package/lib/terminals/index.js +65 -16
- package/lib/terminals/model.d.ts +56 -3
- package/lib/terminals/model.js +261 -5
- package/lib/terminals/widget.d.ts +3 -1
- package/lib/terminals/widget.js +11 -3
- package/lib/walkthrough/index.d.ts +14 -0
- package/lib/walkthrough/index.js +162 -0
- package/lib/walkthrough/panel.d.ts +54 -0
- package/lib/walkthrough/panel.js +98 -0
- package/package.json +1 -1
- package/schema/launcher.json +5 -4
- package/schema/terminals.json +15 -0
- package/src/commandBar/index.ts +165 -0
- package/src/fileBrowser/fileBrowser.tsx +8 -1
- package/src/highlight/index.ts +251 -0
- package/src/index.ts +13 -1
- package/src/launcher/agents.ts +53 -3
- package/src/launcher/editorRegistry.ts +8 -4
- package/src/launcher/editors.ts +37 -4
- package/src/launcher/index.ts +7 -1
- package/src/launcher/schemaDefaults.ts +53 -0
- package/src/omnibox/files.ts +68 -0
- package/src/omnibox/index.ts +105 -0
- package/src/omnibox/model.ts +210 -0
- package/src/omnibox/tokens.ts +29 -0
- package/src/omnibox/widget.tsx +322 -0
- package/src/searchReplace/index.ts +86 -0
- package/src/showOutput/index.ts +199 -0
- package/src/terminals/index.ts +79 -16
- package/src/terminals/model.ts +304 -3
- package/src/terminals/widget.tsx +21 -4
- package/src/walkthrough/index.ts +202 -0
- package/src/walkthrough/panel.ts +152 -0
- package/style/commandBar.css +104 -0
- package/style/highlight.css +11 -0
- package/style/index.css +5 -0
- package/style/index.js +5 -0
- package/style/omnibox.css +120 -0
- package/style/showOutput.css +11 -0
- package/style/terminals.css +54 -10
- 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,17 +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';
|
|
12
17
|
import terminalNotificationsPlugin from './terminalNotifications';
|
|
13
18
|
import terminalsPlugin from './terminals';
|
|
14
19
|
import topBarPlugin from './topBar';
|
|
20
|
+
import walkthroughPlugin from './walkthrough';
|
|
15
21
|
|
|
16
22
|
/**
|
|
17
23
|
* Every plugin contributed by `xtralab`. The entry point of the
|
|
@@ -24,17 +30,23 @@ import topBarPlugin from './topBar';
|
|
|
24
30
|
*/
|
|
25
31
|
const plugins: JupyterFrontEndPlugin<unknown>[] = [
|
|
26
32
|
agentSessionsPlugin,
|
|
33
|
+
commandBarPlugin,
|
|
27
34
|
editorBreadcrumbsPlugin,
|
|
28
35
|
editorIndentPlugin,
|
|
29
36
|
fileBrowserPlugin,
|
|
30
37
|
fileTypeIconsPlugin,
|
|
31
38
|
...gitPlugins,
|
|
39
|
+
highlightPlugin,
|
|
32
40
|
...launcherPlugins,
|
|
33
41
|
menusPlugin,
|
|
42
|
+
omniboxPlugin,
|
|
43
|
+
searchReplacePlugin,
|
|
44
|
+
showOutputPlugin,
|
|
34
45
|
sidebarPlugin,
|
|
35
46
|
terminalNotificationsPlugin,
|
|
36
47
|
terminalsPlugin,
|
|
37
|
-
topBarPlugin
|
|
48
|
+
topBarPlugin,
|
|
49
|
+
walkthroughPlugin
|
|
38
50
|
];
|
|
39
51
|
|
|
40
52
|
export default plugins;
|
package/src/launcher/agents.ts
CHANGED
|
@@ -63,7 +63,12 @@ export interface IAgentSettings {
|
|
|
63
63
|
* palette. Defaults to true.
|
|
64
64
|
*/
|
|
65
65
|
enabled?: boolean;
|
|
66
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* See `IAgent.requireAvailable`. Defaults to true for a built-in agent that
|
|
68
|
+
* still uses its shipped command, and to false once you override the
|
|
69
|
+
* `command` (a user-chosen command — often a shell alias — is trusted and
|
|
70
|
+
* always shown). Set it explicitly to force the check on or off.
|
|
71
|
+
*/
|
|
67
72
|
requireAvailable?: boolean;
|
|
68
73
|
/**
|
|
69
74
|
* See `IAgent.promptArgs`. Pass an empty array to mark the agent as
|
|
@@ -178,6 +183,31 @@ const DEFAULTS: IAgent[] = [
|
|
|
178
183
|
}
|
|
179
184
|
];
|
|
180
185
|
|
|
186
|
+
/**
|
|
187
|
+
* The built-in agents projected into the JSON settings shape
|
|
188
|
+
* ({@link IAgentSettings}), dropping the runtime-only {@link LabIcon}.
|
|
189
|
+
* {@link registerLauncherSchemaDefaults} injects this as the `agents` setting's
|
|
190
|
+
* schema default so the Settings Editor shows the shipped list.
|
|
191
|
+
*/
|
|
192
|
+
export function defaultAgentSettings(): IAgentSettings[] {
|
|
193
|
+
return DEFAULTS.map(agent => {
|
|
194
|
+
const entry: IAgentSettings = {
|
|
195
|
+
id: agent.id,
|
|
196
|
+
label: agent.label,
|
|
197
|
+
caption: agent.caption,
|
|
198
|
+
command: agent.command,
|
|
199
|
+
rank: agent.rank,
|
|
200
|
+
requireAvailable: agent.requireAvailable
|
|
201
|
+
};
|
|
202
|
+
// Omit `promptArgs` for agents that don't take an inline prompt, so the
|
|
203
|
+
// default doesn't show a prompt form they lack.
|
|
204
|
+
if (agent.promptArgs !== undefined) {
|
|
205
|
+
entry.promptArgs = agent.promptArgs;
|
|
206
|
+
}
|
|
207
|
+
return entry;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
181
211
|
function resolveIcon(id: string, iconSvg: string | undefined): LabIcon {
|
|
182
212
|
if (iconSvg) {
|
|
183
213
|
return new LabIcon({ name: `xtralab:agent-custom-${id}`, svgstr: iconSvg });
|
|
@@ -190,6 +220,11 @@ function resolveIcon(id: string, iconSvg: string | undefined): LabIcon {
|
|
|
190
220
|
* their built-in fields unless explicitly overridden; user-only entries are
|
|
191
221
|
* appended. `enabled: false` filters an entry out of the result entirely
|
|
192
222
|
* (so callers don't need to check the flag again).
|
|
223
|
+
*
|
|
224
|
+
* Overriding a built-in agent's `command` also turns its `requireAvailable`
|
|
225
|
+
* off, so the card survives the launcher's `which`-based availability filter
|
|
226
|
+
* even when the new command is a shell alias the server can't resolve. See the
|
|
227
|
+
* built-in branch below for the rationale.
|
|
193
228
|
*/
|
|
194
229
|
export function mergeAgents(overrides: IAgentSettings[]): IAgent[] {
|
|
195
230
|
const overrideById = new Map(overrides.map(entry => [entry.id, entry]));
|
|
@@ -205,16 +240,31 @@ export function mergeAgents(overrides: IAgentSettings[]): IAgent[] {
|
|
|
205
240
|
if (override.enabled === false) {
|
|
206
241
|
continue;
|
|
207
242
|
}
|
|
243
|
+
const command = override.command ?? base.command;
|
|
208
244
|
merged.push({
|
|
209
245
|
id: base.id,
|
|
210
246
|
label: override.label ?? base.label,
|
|
211
247
|
caption: override.caption ?? base.caption,
|
|
212
|
-
command
|
|
248
|
+
command,
|
|
213
249
|
icon: override.iconSvg
|
|
214
250
|
? resolveIcon(base.id, override.iconSvg)
|
|
215
251
|
: base.icon,
|
|
216
252
|
rank: override.rank ?? base.rank,
|
|
217
|
-
|
|
253
|
+
// The availability filter exists to prune xtralab's *built-in* command
|
|
254
|
+
// from the launcher when it isn't installed. Once the user points the
|
|
255
|
+
// agent at their own command — e.g. the `ccm` alias wrapping `claude
|
|
256
|
+
// --effort=max …` — the server's `shutil.which` probe can't see it
|
|
257
|
+
// (aliases and shell functions only exist inside an interactive shell),
|
|
258
|
+
// so keeping the check on would wrongly hide the card. We therefore only
|
|
259
|
+
// require availability while the command is still xtralab's default; a
|
|
260
|
+
// user-chosen command is trusted and always shown. An explicit
|
|
261
|
+
// `requireAvailable` still applies to the unchanged default command, so a
|
|
262
|
+
// user can alias `claude` itself and set `requireAvailable: false` (or
|
|
263
|
+
// force the check back on).
|
|
264
|
+
requireAvailable:
|
|
265
|
+
command === base.command
|
|
266
|
+
? (override.requireAvailable ?? base.requireAvailable)
|
|
267
|
+
: false,
|
|
218
268
|
// `null` is the explicit way to opt out of an agent's default prompt
|
|
219
269
|
// support; an absent key keeps the default. `undefined` from `??` is
|
|
220
270
|
// pruned below.
|
|
@@ -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(
|
|
132
|
+
const settings = await settingRegistry.load(LAUNCHER_PLUGIN_ID);
|
|
129
133
|
await apply(readOverrides(settings));
|
|
130
134
|
settings.changed.connect(async () => {
|
|
131
135
|
try {
|
package/src/launcher/editors.ts
CHANGED
|
@@ -57,7 +57,12 @@ export interface IEditorSettings {
|
|
|
57
57
|
* the editor tile entirely. Defaults to true.
|
|
58
58
|
*/
|
|
59
59
|
enabled?: boolean;
|
|
60
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* See {@link IEditor.requireAvailable}. Defaults to true for a built-in
|
|
62
|
+
* editor that still uses its shipped command, and to false once you override
|
|
63
|
+
* the `command` (a user-chosen command — often a shell alias — is trusted and
|
|
64
|
+
* always shown). Set it explicitly to force the check on or off.
|
|
65
|
+
*/
|
|
61
66
|
requireAvailable?: boolean;
|
|
62
67
|
}
|
|
63
68
|
|
|
@@ -88,6 +93,24 @@ const DEFAULTS: IEditor[] = [
|
|
|
88
93
|
}
|
|
89
94
|
];
|
|
90
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The built-in editors projected into the JSON settings shape
|
|
98
|
+
* ({@link IEditorSettings}), dropping the runtime-only {@link LabIcon}. Mirrors
|
|
99
|
+
* {@link defaultAgentSettings}: {@link registerLauncherSchemaDefaults} injects
|
|
100
|
+
* this as the `editors` setting's schema default so the Settings Editor shows
|
|
101
|
+
* the shipped list.
|
|
102
|
+
*/
|
|
103
|
+
export function defaultEditorSettings(): IEditorSettings[] {
|
|
104
|
+
return DEFAULTS.map(editor => ({
|
|
105
|
+
id: editor.id,
|
|
106
|
+
label: editor.label,
|
|
107
|
+
caption: editor.caption,
|
|
108
|
+
command: editor.command,
|
|
109
|
+
rank: editor.rank,
|
|
110
|
+
requireAvailable: editor.requireAvailable
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
91
114
|
function resolveEditorIcon(id: string, iconSvg: string | undefined): LabIcon {
|
|
92
115
|
if (iconSvg) {
|
|
93
116
|
return new LabIcon({
|
|
@@ -103,7 +126,9 @@ function resolveEditorIcon(id: string, iconSvg: string | undefined): LabIcon {
|
|
|
103
126
|
* entries keep their fields unless explicitly overridden; user-only entries are
|
|
104
127
|
* appended. `enabled: false` filters an entry out of the result entirely (so
|
|
105
128
|
* callers don't need to re-check the flag). The result is sorted by rank, which
|
|
106
|
-
* is also the launcher's tile-preference order. Mirrors `mergeAgents
|
|
129
|
+
* is also the launcher's tile-preference order. Mirrors `mergeAgents`, including
|
|
130
|
+
* turning `requireAvailable` off when the user overrides a built-in's `command`
|
|
131
|
+
* so an aliased editor still shows.
|
|
107
132
|
*/
|
|
108
133
|
export function mergeEditors(overrides: IEditorSettings[]): IEditor[] {
|
|
109
134
|
const overrideById = new Map(overrides.map(entry => [entry.id, entry]));
|
|
@@ -119,16 +144,24 @@ export function mergeEditors(overrides: IEditorSettings[]): IEditor[] {
|
|
|
119
144
|
if (override.enabled === false) {
|
|
120
145
|
continue;
|
|
121
146
|
}
|
|
147
|
+
const command = override.command ?? base.command;
|
|
122
148
|
merged.push({
|
|
123
149
|
id: base.id,
|
|
124
150
|
label: override.label ?? base.label,
|
|
125
151
|
caption: override.caption ?? base.caption,
|
|
126
|
-
command
|
|
152
|
+
command,
|
|
127
153
|
icon: override.iconSvg
|
|
128
154
|
? resolveEditorIcon(base.id, override.iconSvg)
|
|
129
155
|
: base.icon,
|
|
130
156
|
rank: override.rank ?? base.rank,
|
|
131
|
-
|
|
157
|
+
// See `mergeAgents`: once the user points a built-in editor at their own
|
|
158
|
+
// command (e.g. a shell alias `which` can't resolve), stop requiring
|
|
159
|
+
// availability so the tile still shows. The check stays on only while the
|
|
160
|
+
// command is xtralab's default.
|
|
161
|
+
requireAvailable:
|
|
162
|
+
command === base.command
|
|
163
|
+
? (override.requireAvailable ?? base.requireAvailable)
|
|
164
|
+
: false
|
|
132
165
|
});
|
|
133
166
|
}
|
|
134
167
|
|
package/src/launcher/index.ts
CHANGED
|
@@ -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';
|
|
@@ -36,7 +37,9 @@ const PLUGIN_ID = 'xtralab:launcher';
|
|
|
36
37
|
* The agent list is the merge of xtralab's defaults with the user's
|
|
37
38
|
* `xtralab:launcher` settings, then filtered by a server-side `which`
|
|
38
39
|
* check so users only see agents that are actually installed. Agents with
|
|
39
|
-
* `requireAvailable: false`
|
|
40
|
+
* `requireAvailable: false` skip the filter, as does any built-in whose
|
|
41
|
+
* `command` the user has overridden (a user-chosen command — often a shell
|
|
42
|
+
* alias the server can't resolve — is trusted and always shown).
|
|
40
43
|
*
|
|
41
44
|
* The plugin deliberately does NOT provide the `ILauncher` token: other
|
|
42
45
|
* extensions register notebook/console/terminal cards on it as a side
|
|
@@ -110,6 +113,9 @@ const plugin: JupyterFrontEndPlugin<IAgentRegistry> = {
|
|
|
110
113
|
};
|
|
111
114
|
|
|
112
115
|
if (settingRegistry) {
|
|
116
|
+
// Must precede the first `load`: the schema defers loading until a
|
|
117
|
+
// transform is registered.
|
|
118
|
+
registerLauncherSchemaDefaults(settingRegistry);
|
|
113
119
|
try {
|
|
114
120
|
const settings = await settingRegistry.load(PLUGIN_ID);
|
|
115
121
|
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
|
+
}
|