xtralab 0.5.0 → 0.6.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.
- package/README.md +110 -78
- package/lib/agentSessions.d.ts +50 -0
- package/lib/agentSessions.js +45 -0
- package/lib/fileBrowser/index.js +5 -1
- package/lib/git/imageDiff.js +80 -8
- package/lib/index.d.ts +3 -2
- package/lib/index.js +11 -6
- package/lib/launcher/agents.d.ts +1 -1
- package/lib/launcher/agents.js +22 -13
- package/lib/launcher/commands.d.ts +32 -6
- package/lib/launcher/commands.js +81 -57
- package/lib/launcher/dashboard.d.ts +15 -0
- package/lib/launcher/dashboard.js +119 -5
- package/lib/launcher/editorRegistry.d.ts +43 -0
- package/lib/launcher/editorRegistry.js +96 -0
- package/lib/launcher/editors.d.ts +78 -0
- package/lib/launcher/editors.js +110 -0
- package/lib/launcher/icons.d.ts +25 -6
- package/lib/launcher/icons.js +116 -19
- package/lib/launcher/index.d.ts +2 -22
- package/lib/launcher/index.js +48 -25
- package/lib/launcher/registry.d.ts +21 -0
- package/lib/launcher/registry.js +28 -0
- package/lib/launcher/tokens.d.ts +41 -0
- package/lib/launcher/tokens.js +17 -0
- package/lib/terminals/detection.d.ts +11 -0
- package/lib/terminals/detection.js +50 -0
- package/lib/terminals/index.d.ts +27 -0
- package/lib/terminals/index.js +194 -0
- package/lib/terminals/model.d.ts +191 -0
- package/lib/terminals/model.js +418 -0
- package/lib/terminals/widget.d.ts +72 -0
- package/lib/terminals/widget.js +93 -0
- package/lib/topBar/icons.d.ts +13 -0
- package/lib/topBar/icons.js +27 -0
- package/lib/topBar/index.d.ts +16 -0
- package/lib/topBar/index.js +86 -0
- package/package.json +36 -33
- package/schema/launcher.json +56 -1
- package/src/agentSessions.ts +94 -0
- package/src/fileBrowser/index.ts +5 -1
- package/src/git/imageDiff.tsx +127 -26
- package/src/index.ts +11 -6
- package/src/launcher/agents.ts +23 -14
- package/src/launcher/commands.ts +98 -60
- package/src/launcher/dashboard.tsx +205 -3
- package/src/launcher/editorRegistry.ts +149 -0
- package/src/launcher/editors.ts +183 -0
- package/src/launcher/icons.ts +120 -20
- package/src/launcher/index.ts +62 -27
- package/src/launcher/registry.ts +33 -0
- package/src/launcher/tokens.ts +51 -0
- package/src/terminals/detection.ts +65 -0
- package/src/terminals/index.ts +222 -0
- package/src/terminals/model.ts +497 -0
- package/src/terminals/widget.tsx +240 -0
- package/src/topBar/icons.ts +30 -0
- package/src/topBar/index.ts +121 -0
- package/style/git.css +109 -0
- package/style/index.css +2 -1
- package/style/index.js +2 -1
- package/style/launcher.css +124 -2
- package/style/terminals.css +235 -0
- package/style/topBar.css +38 -0
- package/lib/statusBar/index.d.ts +0 -14
- package/lib/statusBar/index.js +0 -89
- package/lib/statusBar/widget.d.ts +0 -121
- package/lib/statusBar/widget.js +0 -237
- package/src/statusBar/index.ts +0 -115
- package/src/statusBar/widget.tsx +0 -318
- package/style/statusBar.css +0 -95
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { Poll } from '@lumino/polling';
|
|
2
|
+
import { Signal } from '@lumino/signaling';
|
|
3
|
+
import { fetchRunningAgents } from './detection';
|
|
4
|
+
/**
|
|
5
|
+
* How often to ask the server which agent (if any) is running in each
|
|
6
|
+
* terminal. Snappy enough that a manually-started agent's logo shows up
|
|
7
|
+
* within a few seconds, light enough that walking the shells' process trees
|
|
8
|
+
* stays negligible.
|
|
9
|
+
*/
|
|
10
|
+
const DETECT_POLL_INTERVAL_MS = 3000;
|
|
11
|
+
/**
|
|
12
|
+
* Upper bound on the exponential backoff when detection fails repeatedly
|
|
13
|
+
* (e.g. the endpoint is missing on an older server). Matches the rest of the
|
|
14
|
+
* plugin's polls.
|
|
15
|
+
*/
|
|
16
|
+
const DETECT_POLL_MAX_MS = 300000;
|
|
17
|
+
/**
|
|
18
|
+
* Grace window after a session first appears during which an optimistic
|
|
19
|
+
* launch tag outranks a `null` detection result. It covers the gap between
|
|
20
|
+
* issuing an agent's command and its process actually being spawnable, so the
|
|
21
|
+
* freshly-launched logo doesn't blink off if a detection poll lands in that
|
|
22
|
+
* sliver. Comfortably longer than a cold agent start; short enough that a tag
|
|
23
|
+
* for an agent that never really started clears quickly.
|
|
24
|
+
*/
|
|
25
|
+
const LAUNCH_GRACE_MS = 4000;
|
|
26
|
+
/**
|
|
27
|
+
* Source-of-truth model for the running-terminals panel. Each running
|
|
28
|
+
* terminal session known to the server is one entry; the registry caches
|
|
29
|
+
* the last `widget.title.label` we observed so the agent name (or any
|
|
30
|
+
* xterm-published title) survives the user closing the tab while the
|
|
31
|
+
* session continues running on the backend.
|
|
32
|
+
*
|
|
33
|
+
* It also resolves *which agent is running* in each session, so the panel can
|
|
34
|
+
* badge rows with the agent's logo. Two inputs feed that, reconciled by
|
|
35
|
+
* {@link agentCommandFor}:
|
|
36
|
+
* - an optimistic launch tag ({@link IAgentSessions}) written when we start
|
|
37
|
+
* an agent ourselves — instant, but blind to the agent later exiting; and
|
|
38
|
+
* - authoritative server-side process detection, polled here, which works
|
|
39
|
+
* for hand-started agents too and clears once an agent exits.
|
|
40
|
+
*
|
|
41
|
+
* Upstream session signals:
|
|
42
|
+
* - `serviceManager.terminals.runningChanged` is the authoritative
|
|
43
|
+
* list of live sessions; everything not in it has been shut down
|
|
44
|
+
* server-side and we must drop it.
|
|
45
|
+
* - `tracker.widgetAdded` plus the per-widget `title.changed` /
|
|
46
|
+
* `disposed` signals keep our label cache in sync with whatever
|
|
47
|
+
* xterm/the launcher has set on the open tabs.
|
|
48
|
+
*
|
|
49
|
+
* `stateChanged` is emitted for every kind of update; the panel hooks it
|
|
50
|
+
* through a `UseSignal` so a single subscription re-renders the list on
|
|
51
|
+
* any change.
|
|
52
|
+
*/
|
|
53
|
+
export class SessionRegistry {
|
|
54
|
+
constructor(options) {
|
|
55
|
+
var _a, _b, _c, _d, _e, _f;
|
|
56
|
+
this._labels = new Map();
|
|
57
|
+
this._ranks = new Map();
|
|
58
|
+
this._firstSeen = new Map();
|
|
59
|
+
this._detected = new Map();
|
|
60
|
+
this._live = new Set();
|
|
61
|
+
this._currentName = null;
|
|
62
|
+
this._nextRank = 100;
|
|
63
|
+
this._isDisposed = false;
|
|
64
|
+
this._stateChanged = new Signal(this);
|
|
65
|
+
this._terminals = options.serviceManager.terminals;
|
|
66
|
+
this._tracker = options.tracker;
|
|
67
|
+
this._agentSessions = (_a = options.agentSessions) !== null && _a !== void 0 ? _a : null;
|
|
68
|
+
this._detectCommands = (_b = options.detectCommands) !== null && _b !== void 0 ? _b : (() => []);
|
|
69
|
+
this._shell = (_c = options.shell) !== null && _c !== void 0 ? _c : null;
|
|
70
|
+
this._terminals.runningChanged.connect(this._onRunningChanged, this);
|
|
71
|
+
this._tracker.widgetAdded.connect(this._onWidgetAdded, this);
|
|
72
|
+
this._tracker.forEach(widget => this._trackWidget(widget));
|
|
73
|
+
this._refreshLive();
|
|
74
|
+
// Track which terminal is the current widget in the main area so the panel
|
|
75
|
+
// can highlight its row — and highlight nothing while a notebook or any
|
|
76
|
+
// other non-terminal tab is current instead. `currentChanged` is optional
|
|
77
|
+
// on the shell interface (not every shell can switch focus), so guard it;
|
|
78
|
+
// when it is absent the highlight stays off.
|
|
79
|
+
(_e = (_d = this._shell) === null || _d === void 0 ? void 0 : _d.currentChanged) === null || _e === void 0 ? void 0 : _e.connect(this._onShellCurrentChanged, this);
|
|
80
|
+
this._updateCurrent();
|
|
81
|
+
// A freshly written launch tag should re-render the panel immediately
|
|
82
|
+
// (show the logo) and again once the grace window closes (so a tag for
|
|
83
|
+
// an agent that never started, or that detection later contradicts,
|
|
84
|
+
// doesn't linger).
|
|
85
|
+
(_f = this._agentSessions) === null || _f === void 0 ? void 0 : _f.changed.connect(this._onTagChanged, this);
|
|
86
|
+
this._poll = new Poll({
|
|
87
|
+
name: '@xtralab/terminals:runningAgents',
|
|
88
|
+
factory: () => this._refreshDetection(),
|
|
89
|
+
frequency: {
|
|
90
|
+
interval: DETECT_POLL_INTERVAL_MS,
|
|
91
|
+
backoff: true,
|
|
92
|
+
max: DETECT_POLL_MAX_MS
|
|
93
|
+
},
|
|
94
|
+
standby: 'when-hidden'
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Emitted whenever the live session list, a cached label, or the detected
|
|
99
|
+
* running agent changes.
|
|
100
|
+
*/
|
|
101
|
+
get stateChanged() {
|
|
102
|
+
return this._stateChanged;
|
|
103
|
+
}
|
|
104
|
+
get isDisposed() {
|
|
105
|
+
return this._isDisposed;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Names of all live sessions, ordered by their stable rank so the
|
|
109
|
+
* rendered list keeps a consistent order (roughly creation order) as
|
|
110
|
+
* new sessions are added and old ones shut down.
|
|
111
|
+
*/
|
|
112
|
+
sessionNames() {
|
|
113
|
+
const names = Array.from(this._live);
|
|
114
|
+
names.sort((a, b) => this.rankFor(a) - this.rankFor(b));
|
|
115
|
+
return names;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* True iff the named session is still on the server. Used by the panel
|
|
119
|
+
* to skip a row during the brief window between the session's shutdown
|
|
120
|
+
* and the next `runningChanged` arriving.
|
|
121
|
+
*/
|
|
122
|
+
has(name) {
|
|
123
|
+
return this._live.has(name);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Display name for the session. The cache wins because it only
|
|
127
|
+
* holds "real" labels (the launcher's agent name or an xterm escape
|
|
128
|
+
* sequence — see `_cacheLabel` for the filter); reading it first
|
|
129
|
+
* prevents the transient `Terminal {name}` an XTerm widget shows
|
|
130
|
+
* during reconnect from flickering into the panel. The live widget
|
|
131
|
+
* title is used when no cache exists, and the `Terminal {name}`
|
|
132
|
+
* fallback covers sessions we have never seen a widget for (e.g.
|
|
133
|
+
* surviving a lab reload).
|
|
134
|
+
*/
|
|
135
|
+
labelFor(name) {
|
|
136
|
+
const cached = this._labels.get(name);
|
|
137
|
+
if (cached) {
|
|
138
|
+
return cached;
|
|
139
|
+
}
|
|
140
|
+
const widget = this.widgetFor(name);
|
|
141
|
+
if (widget && widget.title.label) {
|
|
142
|
+
return widget.title.label;
|
|
143
|
+
}
|
|
144
|
+
return `Terminal ${name}`;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The command of the agent running in the session, or `null` if none.
|
|
148
|
+
*
|
|
149
|
+
* Server-side detection is authoritative whenever it reports a running
|
|
150
|
+
* agent. A launch tag fills two gaps: the startup grace window right after
|
|
151
|
+
* we launch an agent (so its logo shows before its process is detectable),
|
|
152
|
+
* and any time detection is unavailable (older server, transient error).
|
|
153
|
+
* Once the grace window has passed and detection has reported the session
|
|
154
|
+
* as idle, the tag is ignored — that is how the badge clears when an agent
|
|
155
|
+
* exits.
|
|
156
|
+
*/
|
|
157
|
+
agentCommandFor(name) {
|
|
158
|
+
var _a, _b, _c;
|
|
159
|
+
// `string` = detected running agent, `null` = polled and idle,
|
|
160
|
+
// `undefined` = not covered by a successful poll yet.
|
|
161
|
+
const detected = this._detected.has(name)
|
|
162
|
+
? this._detected.get(name)
|
|
163
|
+
: undefined;
|
|
164
|
+
if (typeof detected === 'string') {
|
|
165
|
+
return detected;
|
|
166
|
+
}
|
|
167
|
+
const tagged = (_b = (_a = this._agentSessions) === null || _a === void 0 ? void 0 : _a.get(name)) !== null && _b !== void 0 ? _b : null;
|
|
168
|
+
if (tagged !== null) {
|
|
169
|
+
const seenAt = (_c = this._firstSeen.get(name)) !== null && _c !== void 0 ? _c : 0;
|
|
170
|
+
const inGrace = Date.now() - seenAt < LAUNCH_GRACE_MS;
|
|
171
|
+
if (inGrace || detected === undefined) {
|
|
172
|
+
return tagged;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Return the open widget for a session, if any. The panel uses this to
|
|
179
|
+
* switch behaviour between "activate existing tab" and "open a new tab
|
|
180
|
+
* connected to the live session".
|
|
181
|
+
*/
|
|
182
|
+
widgetFor(name) {
|
|
183
|
+
var _a;
|
|
184
|
+
return ((_a = this._tracker.find(widget => widget.content.session.name === name)) !== null && _a !== void 0 ? _a : null);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Session name of the terminal that is currently the active widget in the
|
|
188
|
+
* shell's main area, or `null` when that widget is not a terminal (for
|
|
189
|
+
* example a notebook or text editor is current). The panel uses this to
|
|
190
|
+
* highlight the current terminal's row — mirroring how the file browser
|
|
191
|
+
* surfaces the open document — and to leave every row unhighlighted while a
|
|
192
|
+
* non-terminal tab is current.
|
|
193
|
+
*/
|
|
194
|
+
currentSessionName() {
|
|
195
|
+
return this._currentName;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Stable per-session rank assigned in observation order. Used to keep
|
|
199
|
+
* the rendered list in a steady order so rows don't reshuffle as
|
|
200
|
+
* sessions come and go. Cleaned up when the session is shut down so the
|
|
201
|
+
* counter does not grow without bound across long sessions.
|
|
202
|
+
*/
|
|
203
|
+
rankFor(name) {
|
|
204
|
+
let rank = this._ranks.get(name);
|
|
205
|
+
if (rank === undefined) {
|
|
206
|
+
rank = this._nextRank++;
|
|
207
|
+
this._ranks.set(name, rank);
|
|
208
|
+
}
|
|
209
|
+
return rank;
|
|
210
|
+
}
|
|
211
|
+
dispose() {
|
|
212
|
+
var _a, _b, _c;
|
|
213
|
+
if (this._isDisposed) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
this._isDisposed = true;
|
|
217
|
+
this._poll.dispose();
|
|
218
|
+
this._terminals.runningChanged.disconnect(this._onRunningChanged, this);
|
|
219
|
+
this._tracker.widgetAdded.disconnect(this._onWidgetAdded, this);
|
|
220
|
+
(_a = this._agentSessions) === null || _a === void 0 ? void 0 : _a.changed.disconnect(this._onTagChanged, this);
|
|
221
|
+
(_c = (_b = this._shell) === null || _b === void 0 ? void 0 : _b.currentChanged) === null || _c === void 0 ? void 0 : _c.disconnect(this._onShellCurrentChanged, this);
|
|
222
|
+
this._tracker.forEach(widget => this._untrackWidget(widget));
|
|
223
|
+
Signal.clearData(this);
|
|
224
|
+
}
|
|
225
|
+
_refreshLive() {
|
|
226
|
+
const next = new Set();
|
|
227
|
+
for (const model of this._terminals.running()) {
|
|
228
|
+
next.add(model.name);
|
|
229
|
+
}
|
|
230
|
+
this._reconcileLive(next);
|
|
231
|
+
}
|
|
232
|
+
_onRunningChanged(_, sessions) {
|
|
233
|
+
this._reconcileLive(new Set(sessions.map(model => model.name)));
|
|
234
|
+
this._stateChanged.emit();
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Adopt `next` as the live set and prune every per-session map for
|
|
238
|
+
* sessions that have gone away — labels, ranks, first-seen timestamps,
|
|
239
|
+
* detection results, and the shared launch tag. Without this the maps (and
|
|
240
|
+
* the rank counter) would grow without bound as terminals come and go.
|
|
241
|
+
*/
|
|
242
|
+
_reconcileLive(next) {
|
|
243
|
+
var _a;
|
|
244
|
+
// Names we had already seen, captured before we prune below — the set of
|
|
245
|
+
// candidates whose launch tag may now need forgetting. (The tag map is
|
|
246
|
+
// not enumerable, so we drive the prune from the sessions we know about;
|
|
247
|
+
// `_firstSeen` has an entry for every session that has ever been live.)
|
|
248
|
+
const known = Array.from(this._firstSeen.keys());
|
|
249
|
+
for (const name of next) {
|
|
250
|
+
if (!this._firstSeen.has(name)) {
|
|
251
|
+
this._firstSeen.set(name, Date.now());
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
this._live = next;
|
|
255
|
+
for (const name of Array.from(this._labels.keys())) {
|
|
256
|
+
if (!next.has(name)) {
|
|
257
|
+
this._labels.delete(name);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const name of Array.from(this._ranks.keys())) {
|
|
261
|
+
if (!next.has(name)) {
|
|
262
|
+
this._ranks.delete(name);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (const name of Array.from(this._firstSeen.keys())) {
|
|
266
|
+
if (!next.has(name)) {
|
|
267
|
+
this._firstSeen.delete(name);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const name of Array.from(this._detected.keys())) {
|
|
271
|
+
if (!next.has(name)) {
|
|
272
|
+
this._detected.delete(name);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Forget launch tags for sessions that have gone away. This matters for
|
|
276
|
+
// correctness as well as bookkeeping: terminado reuses session names, so
|
|
277
|
+
// a stale tag could otherwise mislabel a brand-new terminal that happens
|
|
278
|
+
// to reuse a closed session's name.
|
|
279
|
+
for (const name of known) {
|
|
280
|
+
if (!next.has(name)) {
|
|
281
|
+
(_a = this._agentSessions) === null || _a === void 0 ? void 0 : _a.delete(name);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Poll body: ask the server which agent runs in each terminal and update
|
|
287
|
+
* the detection map. On failure we keep the previous results rather than
|
|
288
|
+
* clearing them, so a transient error doesn't strip every badge.
|
|
289
|
+
*/
|
|
290
|
+
async _refreshDetection() {
|
|
291
|
+
const commands = this._detectCommands();
|
|
292
|
+
if (commands.length === 0) {
|
|
293
|
+
if (this._detected.size > 0) {
|
|
294
|
+
this._detected = new Map();
|
|
295
|
+
this._stateChanged.emit();
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const result = await fetchRunningAgents(commands);
|
|
300
|
+
if (result === null) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const next = new Map();
|
|
304
|
+
for (const [name, command] of Object.entries(result)) {
|
|
305
|
+
if (this._live.has(name)) {
|
|
306
|
+
next.set(name, command);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (!Private.detectedEqual(this._detected, next)) {
|
|
310
|
+
this._detected = next;
|
|
311
|
+
this._stateChanged.emit();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
_onTagChanged() {
|
|
315
|
+
this._stateChanged.emit();
|
|
316
|
+
// Re-render once the grace window closes so a tag that detection never
|
|
317
|
+
// confirmed (or has since contradicted) stops being shown.
|
|
318
|
+
setTimeout(() => {
|
|
319
|
+
if (!this._isDisposed) {
|
|
320
|
+
this._stateChanged.emit();
|
|
321
|
+
}
|
|
322
|
+
}, LAUNCH_GRACE_MS + 100);
|
|
323
|
+
}
|
|
324
|
+
_onWidgetAdded(_, widget) {
|
|
325
|
+
this._trackWidget(widget);
|
|
326
|
+
this._stateChanged.emit();
|
|
327
|
+
}
|
|
328
|
+
_onShellCurrentChanged() {
|
|
329
|
+
this._updateCurrent();
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Recompute which terminal (if any) is the active main-area widget and emit
|
|
333
|
+
* only when it changes. The terminal tracker tells us whether the shell's
|
|
334
|
+
* current widget is one of our terminals; anything else — a notebook, an
|
|
335
|
+
* editor, or nothing — clears the highlight.
|
|
336
|
+
*/
|
|
337
|
+
_updateCurrent() {
|
|
338
|
+
var _a, _b;
|
|
339
|
+
const current = (_b = (_a = this._shell) === null || _a === void 0 ? void 0 : _a.currentWidget) !== null && _b !== void 0 ? _b : null;
|
|
340
|
+
const name = current && this._tracker.has(current)
|
|
341
|
+
? current.content.session.name
|
|
342
|
+
: null;
|
|
343
|
+
if (name !== this._currentName) {
|
|
344
|
+
this._currentName = name;
|
|
345
|
+
this._stateChanged.emit();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
_trackWidget(widget) {
|
|
349
|
+
const name = widget.content.session.name;
|
|
350
|
+
this._cacheLabel(name, widget.title.label);
|
|
351
|
+
widget.title.changed.connect(this._onTitleChanged, this);
|
|
352
|
+
widget.disposed.connect(this._onWidgetDisposed, this);
|
|
353
|
+
}
|
|
354
|
+
_untrackWidget(widget) {
|
|
355
|
+
widget.title.changed.disconnect(this._onTitleChanged, this);
|
|
356
|
+
widget.disposed.disconnect(this._onWidgetDisposed, this);
|
|
357
|
+
}
|
|
358
|
+
_onTitleChanged(title) {
|
|
359
|
+
var _a;
|
|
360
|
+
const owner = title.owner;
|
|
361
|
+
const session = (_a = owner.content) === null || _a === void 0 ? void 0 : _a.session;
|
|
362
|
+
if (!session) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this._cacheLabel(session.name, title.label);
|
|
366
|
+
this._stateChanged.emit();
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Update the cached label for a session, but only when the new
|
|
370
|
+
* label is "real" — non-empty and not one of the transient defaults
|
|
371
|
+
* the XTerm widget cycles through during reconnect (`'...'` while
|
|
372
|
+
* the websocket is opening, `'Terminal {name}'` once
|
|
373
|
+
* `_initialConnection` fires). Without the filter, briefly
|
|
374
|
+
* reopening a tab while an agent has yet to re-emit its xterm
|
|
375
|
+
* title escape sequence would clobber the cached agent name with
|
|
376
|
+
* `Terminal 1`, which is exactly the regression we are guarding
|
|
377
|
+
* against. Live widget titles still display whatever the widget
|
|
378
|
+
* currently holds because `labelFor` consults the widget first;
|
|
379
|
+
* the filter only affects what survives a tab close.
|
|
380
|
+
*/
|
|
381
|
+
_cacheLabel(name, label) {
|
|
382
|
+
if (!label) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (label === '...' || label === `Terminal ${name}`) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this._labels.set(name, label);
|
|
389
|
+
}
|
|
390
|
+
_onWidgetDisposed(widget) {
|
|
391
|
+
// The widget is gone but the session may still be running on the
|
|
392
|
+
// server — keep the cached label so the panel keeps the agent's name
|
|
393
|
+
// when the user reopens the tab. We only clean up our subscriptions
|
|
394
|
+
// here; the cache is purged by `runningChanged` once the session
|
|
395
|
+
// itself goes away.
|
|
396
|
+
this._untrackWidget(widget);
|
|
397
|
+
this._stateChanged.emit();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
var Private;
|
|
401
|
+
(function (Private) {
|
|
402
|
+
/**
|
|
403
|
+
* Value-equality for two detection maps, so a poll that changes nothing
|
|
404
|
+
* doesn't trigger a re-render.
|
|
405
|
+
*/
|
|
406
|
+
function detectedEqual(a, b) {
|
|
407
|
+
if (a.size !== b.size) {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
for (const [name, command] of a) {
|
|
411
|
+
if (!b.has(name) || b.get(name) !== command) {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
Private.detectedEqual = detectedEqual;
|
|
418
|
+
})(Private || (Private = {}));
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type TranslationBundle } from '@jupyterlab/translation';
|
|
2
|
+
import { LabIcon, ReactWidget } from '@jupyterlab/ui-components';
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { SessionRegistry } from './model';
|
|
5
|
+
/**
|
|
6
|
+
* Id of the panel widget. Used for layout restoration and as the handle
|
|
7
|
+
* the sidebar visibility toggle would target.
|
|
8
|
+
*/
|
|
9
|
+
export declare const RUNNING_TERMINALS_ID = "xtralab-running-terminals";
|
|
10
|
+
/**
|
|
11
|
+
* Left-sidebar panel listing every running terminal session. It is the
|
|
12
|
+
* sidebar counterpart of the launcher's terminal tiles: where the
|
|
13
|
+
* launcher *starts* sessions, this panel surfaces the ones already
|
|
14
|
+
* running so the user can jump back to a backgrounded agent — including
|
|
15
|
+
* sessions whose tab has been closed but which are still alive on the
|
|
16
|
+
* server.
|
|
17
|
+
*
|
|
18
|
+
* Backed by {@link SessionRegistry}: a `UseSignal` re-renders the list
|
|
19
|
+
* whenever the registry emits `stateChanged`, so it tracks
|
|
20
|
+
* `runningChanged` and the agent-title cache without any local state.
|
|
21
|
+
* Click behaviour (activate vs. reopen, shutdown, new terminal) is
|
|
22
|
+
* delegated to plugin-supplied callbacks so the widget never imports
|
|
23
|
+
* `app`.
|
|
24
|
+
*/
|
|
25
|
+
export declare class RunningTerminals extends ReactWidget {
|
|
26
|
+
constructor(options: RunningTerminals.IOptions);
|
|
27
|
+
dispose(): void;
|
|
28
|
+
protected render(): React.ReactElement;
|
|
29
|
+
private _registry;
|
|
30
|
+
private _trans;
|
|
31
|
+
private _iconForCommand;
|
|
32
|
+
private _onActivate;
|
|
33
|
+
private _onShutdown;
|
|
34
|
+
private _onShutdownAll;
|
|
35
|
+
private _onCreate;
|
|
36
|
+
}
|
|
37
|
+
export declare namespace RunningTerminals {
|
|
38
|
+
interface IOptions {
|
|
39
|
+
registry: SessionRegistry;
|
|
40
|
+
trans?: TranslationBundle;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the running-agent command for a row (from
|
|
43
|
+
* `registry.agentCommandFor`) to the icon to show before its label —
|
|
44
|
+
* the agent's logo, or the plain terminal icon. Supplied by the plugin
|
|
45
|
+
* so the widget never imports the agent list.
|
|
46
|
+
*/
|
|
47
|
+
iconForCommand: (command: string | null) => LabIcon;
|
|
48
|
+
/**
|
|
49
|
+
* Activate the named session's open tab, or reopen it in a fresh
|
|
50
|
+
* terminal widget if no tab is currently attached.
|
|
51
|
+
*/
|
|
52
|
+
onActivate: (sessionName: string) => void;
|
|
53
|
+
/** Shut the named session down on the server. */
|
|
54
|
+
onShutdown: (sessionName: string) => void;
|
|
55
|
+
/**
|
|
56
|
+
* Shut down every running terminal at once. The plugin is expected to
|
|
57
|
+
* confirm with the user first, since it tears down all live sessions.
|
|
58
|
+
*/
|
|
59
|
+
onShutdownAll: () => void;
|
|
60
|
+
/**
|
|
61
|
+
* Activate the "+" button, anchored at the given viewport coordinates
|
|
62
|
+
* (the bottom-left of the button). The plugin decides what to show
|
|
63
|
+
* there — a menu of agents plus a plain terminal, or just a new
|
|
64
|
+
* terminal when no agents are available — so the panel only reports
|
|
65
|
+
* where the button is and never imports the command/menu machinery.
|
|
66
|
+
*/
|
|
67
|
+
onCreate: (anchor: {
|
|
68
|
+
x: number;
|
|
69
|
+
y: number;
|
|
70
|
+
}) => void;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { nullTranslator } from '@jupyterlab/translation';
|
|
2
|
+
import { addIcon, closeIcon, ReactWidget, stopIcon, terminalIcon, UseSignal } from '@jupyterlab/ui-components';
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
/**
|
|
5
|
+
* Id of the panel widget. Used for layout restoration and as the handle
|
|
6
|
+
* the sidebar visibility toggle would target.
|
|
7
|
+
*/
|
|
8
|
+
export const RUNNING_TERMINALS_ID = 'xtralab-running-terminals';
|
|
9
|
+
/**
|
|
10
|
+
* Left-sidebar panel listing every running terminal session. It is the
|
|
11
|
+
* sidebar counterpart of the launcher's terminal tiles: where the
|
|
12
|
+
* launcher *starts* sessions, this panel surfaces the ones already
|
|
13
|
+
* running so the user can jump back to a backgrounded agent — including
|
|
14
|
+
* sessions whose tab has been closed but which are still alive on the
|
|
15
|
+
* server.
|
|
16
|
+
*
|
|
17
|
+
* Backed by {@link SessionRegistry}: a `UseSignal` re-renders the list
|
|
18
|
+
* whenever the registry emits `stateChanged`, so it tracks
|
|
19
|
+
* `runningChanged` and the agent-title cache without any local state.
|
|
20
|
+
* Click behaviour (activate vs. reopen, shutdown, new terminal) is
|
|
21
|
+
* delegated to plugin-supplied callbacks so the widget never imports
|
|
22
|
+
* `app`.
|
|
23
|
+
*/
|
|
24
|
+
export class RunningTerminals extends ReactWidget {
|
|
25
|
+
constructor(options) {
|
|
26
|
+
var _a;
|
|
27
|
+
super();
|
|
28
|
+
this._registry = options.registry;
|
|
29
|
+
this._trans = (_a = options.trans) !== null && _a !== void 0 ? _a : nullTranslator.load('jupyterlab');
|
|
30
|
+
this._iconForCommand = options.iconForCommand;
|
|
31
|
+
this._onActivate = options.onActivate;
|
|
32
|
+
this._onShutdown = options.onShutdown;
|
|
33
|
+
this._onShutdownAll = options.onShutdownAll;
|
|
34
|
+
this._onCreate = options.onCreate;
|
|
35
|
+
this.id = RUNNING_TERMINALS_ID;
|
|
36
|
+
this.title.icon = terminalIcon;
|
|
37
|
+
this.title.caption = this._trans.__('Running Terminals');
|
|
38
|
+
this.addClass('jp-xtralab-Terminals');
|
|
39
|
+
}
|
|
40
|
+
dispose() {
|
|
41
|
+
if (this.isDisposed) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// The panel owns the registry, so tear down its upstream
|
|
45
|
+
// subscriptions before the React tree goes away.
|
|
46
|
+
this._registry.dispose();
|
|
47
|
+
super.dispose();
|
|
48
|
+
}
|
|
49
|
+
render() {
|
|
50
|
+
return (React.createElement(UseSignal, { signal: this._registry.stateChanged }, () => (React.createElement(RunningTerminalsComponent, { registry: this._registry, trans: this._trans, iconForCommand: this._iconForCommand, onActivate: this._onActivate, onShutdown: this._onShutdown, onShutdownAll: this._onShutdownAll, onCreate: this._onCreate }))));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function RunningTerminalsComponent(props) {
|
|
54
|
+
const { registry, trans, iconForCommand, onActivate, onShutdown, onShutdownAll, onCreate } = props;
|
|
55
|
+
const names = registry.sessionNames();
|
|
56
|
+
// The session whose terminal is the current widget in the main area, so its
|
|
57
|
+
// row can be highlighted. `null` when the current tab is a notebook or any
|
|
58
|
+
// other non-terminal widget.
|
|
59
|
+
const currentName = registry.currentSessionName();
|
|
60
|
+
return (React.createElement("div", { className: "jp-xtralab-Terminals-body" },
|
|
61
|
+
React.createElement("div", { className: "jp-xtralab-Terminals-header" },
|
|
62
|
+
React.createElement("h2", { className: "jp-xtralab-Terminals-title" }, trans.__('Terminals')),
|
|
63
|
+
React.createElement("div", { className: "jp-xtralab-Terminals-actions" },
|
|
64
|
+
React.createElement("button", { type: "button", className: "jp-xtralab-Terminals-action jp-xtralab-Terminals-shutdown-all", onClick: onShutdownAll, disabled: names.length === 0, title: trans.__('Shut Down All Terminals'), "aria-label": trans.__('Shut Down All Terminals') },
|
|
65
|
+
React.createElement(stopIcon.react, { tag: "span", verticalAlign: "middle" })),
|
|
66
|
+
React.createElement("button", { type: "button", className: "jp-xtralab-Terminals-action jp-xtralab-Terminals-new", onClick: event => {
|
|
67
|
+
// Anchor whatever the plugin shows (usually an agent menu) to
|
|
68
|
+
// the button's bottom-left, in viewport coordinates — what
|
|
69
|
+
// `Menu.open(x, y)` expects.
|
|
70
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
71
|
+
onCreate({ x: rect.left, y: rect.bottom });
|
|
72
|
+
}, title: trans.__('New Terminal'), "aria-label": trans.__('New Terminal'), "aria-haspopup": "menu" },
|
|
73
|
+
React.createElement(addIcon.react, { tag: "span", verticalAlign: "middle" })))),
|
|
74
|
+
names.length === 0 ? (React.createElement("p", { className: "jp-xtralab-Terminals-empty" }, trans.__('No running terminals.'))) : (React.createElement("ul", { className: "jp-xtralab-Terminals-list" }, names.map(name => {
|
|
75
|
+
const label = registry.labelFor(name);
|
|
76
|
+
const hasWidget = registry.widgetFor(name) !== null;
|
|
77
|
+
const tooltip = hasWidget
|
|
78
|
+
? trans.__('Activate %1', label)
|
|
79
|
+
: trans.__('Reopen %1', label);
|
|
80
|
+
// The running agent's logo (e.g. Claude), or the plain terminal
|
|
81
|
+
// icon when nothing recognised is running in the session.
|
|
82
|
+
const RowIcon = iconForCommand(registry.agentCommandFor(name)).react;
|
|
83
|
+
const isCurrent = name === currentName;
|
|
84
|
+
return (React.createElement("li", { key: name, className: isCurrent
|
|
85
|
+
? 'jp-xtralab-Terminals-item jp-mod-current'
|
|
86
|
+
: 'jp-xtralab-Terminals-item' },
|
|
87
|
+
React.createElement("button", { type: "button", className: "jp-xtralab-Terminals-item-activate", onClick: () => onActivate(name), title: tooltip, "aria-label": tooltip, "aria-current": isCurrent || undefined },
|
|
88
|
+
React.createElement(RowIcon, { tag: "span", verticalAlign: "middle" }),
|
|
89
|
+
React.createElement("span", { className: "jp-xtralab-Terminals-item-label" }, label)),
|
|
90
|
+
React.createElement("button", { type: "button", className: "jp-xtralab-Terminals-item-close", onClick: () => onShutdown(name), title: trans.__('Shut down %1', label), "aria-label": trans.__('Shut down %1', label) },
|
|
91
|
+
React.createElement(closeIcon.react, { tag: "span", verticalAlign: "middle" }))));
|
|
92
|
+
})))));
|
|
93
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LabIcon } from '@jupyterlab/ui-components';
|
|
2
|
+
/**
|
|
3
|
+
* Mirror-symmetric "toggle sidebar" marks for the top-bar buttons, in the
|
|
4
|
+
* same visual language as the macOS / VS Code / svgrepo sidebar icons
|
|
5
|
+
* (https://www.svgrepo.com/vectors/sidebar/): a rounded window outline split
|
|
6
|
+
* by a divider, with the controlled side shaded so left vs. right reads at a
|
|
7
|
+
* glance. The artwork is hand-authored from basic geometry (rounded rect +
|
|
8
|
+
* divider + a shaded panel) so there is no third-party licensing to track,
|
|
9
|
+
* and every stroke/fill uses `currentColor` so the icons inherit the top
|
|
10
|
+
* bar's text color across the light and dark themes.
|
|
11
|
+
*/
|
|
12
|
+
export declare const leftSidebarIcon: LabIcon;
|
|
13
|
+
export declare const rightSidebarIcon: LabIcon;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { LabIcon } from '@jupyterlab/ui-components';
|
|
2
|
+
/**
|
|
3
|
+
* Mirror-symmetric "toggle sidebar" marks for the top-bar buttons, in the
|
|
4
|
+
* same visual language as the macOS / VS Code / svgrepo sidebar icons
|
|
5
|
+
* (https://www.svgrepo.com/vectors/sidebar/): a rounded window outline split
|
|
6
|
+
* by a divider, with the controlled side shaded so left vs. right reads at a
|
|
7
|
+
* glance. The artwork is hand-authored from basic geometry (rounded rect +
|
|
8
|
+
* divider + a shaded panel) so there is no third-party licensing to track,
|
|
9
|
+
* and every stroke/fill uses `currentColor` so the icons inherit the top
|
|
10
|
+
* bar's text color across the light and dark themes.
|
|
11
|
+
*/
|
|
12
|
+
export const leftSidebarIcon = new LabIcon({
|
|
13
|
+
name: 'xtralab:sidebar-left',
|
|
14
|
+
svgstr: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
15
|
+
<path d="M9 3H5.5A2.5 2.5 0 0 0 3 5.5V18.5A2.5 2.5 0 0 0 5.5 21H9Z" fill="currentColor" fill-opacity="0.3"/>
|
|
16
|
+
<rect x="3" y="3" width="18" height="18" rx="2.5" stroke="currentColor" stroke-width="1.7"/>
|
|
17
|
+
<line x1="9" y1="3" x2="9" y2="21" stroke="currentColor" stroke-width="1.7"/>
|
|
18
|
+
</svg>`
|
|
19
|
+
});
|
|
20
|
+
export const rightSidebarIcon = new LabIcon({
|
|
21
|
+
name: 'xtralab:sidebar-right',
|
|
22
|
+
svgstr: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
|
23
|
+
<path d="M15 3H18.5A2.5 2.5 0 0 1 21 5.5V18.5A2.5 2.5 0 0 1 18.5 21H15Z" fill="currentColor" fill-opacity="0.3"/>
|
|
24
|
+
<rect x="3" y="3" width="18" height="18" rx="2.5" stroke="currentColor" stroke-width="1.7"/>
|
|
25
|
+
<line x1="15" y1="3" x2="15" y2="21" stroke="currentColor" stroke-width="1.7"/>
|
|
26
|
+
</svg>`
|
|
27
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { JupyterFrontEndPlugin } from '@jupyterlab/application';
|
|
2
|
+
/**
|
|
3
|
+
* Add two icon buttons to the top bar that toggle the left and right side
|
|
4
|
+
* areas, mirroring the macOS-style sidebar buttons: a left-sidebar button at
|
|
5
|
+
* the leading edge and a right-sidebar button at the far edge.
|
|
6
|
+
*
|
|
7
|
+
* Each is a `CommandToolbarButton` wrapping an existing
|
|
8
|
+
* `application:toggle-{left,right}-area` command, so clicking it runs the
|
|
9
|
+
* same toggle as the View menu and the button reflects the command's state —
|
|
10
|
+
* pressed while the matching sidebar is open, and disabled when the area is
|
|
11
|
+
* empty. Note xtralab ships nothing in the right area by default (the
|
|
12
|
+
* property inspector, table of contents and debugger are all disabled), so
|
|
13
|
+
* the right button stays disabled until a widget is moved or added there.
|
|
14
|
+
*/
|
|
15
|
+
declare const plugin: JupyterFrontEndPlugin<void>;
|
|
16
|
+
export default plugin;
|