xtralab 0.11.1 → 0.12.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 +37 -231
- package/lib/agentSessions.d.ts +6 -2
- package/lib/askAgent/editorSelection.d.ts +53 -0
- package/lib/askAgent/editorSelection.js +95 -0
- package/lib/askAgent/icons.d.ts +7 -0
- package/lib/askAgent/icons.js +15 -0
- package/lib/askAgent/index.d.ts +32 -0
- package/lib/askAgent/index.js +741 -0
- package/lib/askAgent/popup.d.ts +83 -0
- package/lib/askAgent/popup.js +221 -0
- package/lib/askAgent/prompt.d.ts +24 -0
- package/lib/askAgent/prompt.js +116 -0
- package/lib/askAgent/queue.d.ts +93 -0
- package/lib/askAgent/queue.js +207 -0
- package/lib/askAgent/queuePanel.d.ts +76 -0
- package/lib/askAgent/queuePanel.js +188 -0
- package/lib/askAgent/targetPicker.d.ts +58 -0
- package/lib/askAgent/targetPicker.js +74 -0
- package/lib/askAgent/tokens.d.ts +124 -0
- package/lib/askAgent/tokens.js +13 -0
- package/lib/commandBar/index.d.ts +12 -8
- package/lib/commandBar/index.js +58 -41
- package/lib/editorBreadcrumbs/index.js +9 -4
- package/lib/editorBreadcrumbs/widget.d.ts +7 -7
- package/lib/editorBreadcrumbs/widget.js +6 -8
- package/lib/fileBrowser/commands.d.ts +2 -8
- package/lib/fileBrowser/commands.js +39 -30
- package/lib/fileBrowser/contents.d.ts +8 -3
- package/lib/fileBrowser/fileBrowser.d.ts +2 -1
- package/lib/fileBrowser/widget.d.ts +5 -9
- package/lib/fileBrowser/widget.js +1 -1
- package/lib/fileTypeIcons/index.js +5 -2
- package/lib/git/api.d.ts +0 -26
- package/lib/git/api.js +2 -44
- package/lib/git/askRequest.d.ts +23 -0
- package/lib/git/askRequest.js +90 -0
- package/lib/git/commands.d.ts +14 -2
- package/lib/git/commands.js +16 -13
- package/lib/git/diffModel.d.ts +1 -9
- package/lib/git/diffModel.js +1 -1
- package/lib/git/diffProvider.js +8 -3
- package/lib/git/diffSurface.d.ts +19 -2
- package/lib/git/diffSurface.js +64 -18
- package/lib/git/diffTheme.d.ts +0 -4
- package/lib/git/diffTheme.js +1 -1
- package/lib/git/diffWidget.d.ts +9 -0
- package/lib/git/diffWidget.js +38 -10
- package/lib/git/imageDiff.d.ts +17 -4
- package/lib/git/imageDiff.js +90 -30
- package/lib/git/index.js +7 -2
- package/lib/git/notebookDiff.d.ts +10 -67
- package/lib/git/notebookDiff.js +62 -69
- package/lib/git/tokens.d.ts +5 -2
- package/lib/highlight/index.js +12 -8
- package/lib/index.js +2 -0
- package/lib/launcher/agents.js +3 -4
- package/lib/launcher/commands.d.ts +6 -2
- package/lib/launcher/dashboard.d.ts +8 -1
- package/lib/launcher/dashboard.js +39 -33
- package/lib/launcher/editorRegistry.js +5 -3
- package/lib/launcher/editors.d.ts +15 -5
- package/lib/launcher/icons.d.ts +0 -37
- package/lib/launcher/icons.js +11 -12
- package/lib/launcher/index.js +2 -1
- package/lib/launcher/invocation.d.ts +0 -15
- package/lib/launcher/invocation.js +1 -1
- package/lib/launcher/schemaDefaults.d.ts +3 -1
- package/lib/launcher/schemaDefaults.js +3 -1
- package/lib/mimeData.d.ts +7 -0
- package/lib/mimeData.js +16 -0
- package/lib/omnibox/index.d.ts +4 -0
- package/lib/omnibox/index.js +43 -2
- package/lib/omnibox/model.d.ts +43 -14
- package/lib/omnibox/model.js +117 -14
- package/lib/omnibox/recents.d.ts +65 -0
- package/lib/omnibox/recents.js +109 -0
- package/lib/omnibox/tokens.d.ts +6 -2
- package/lib/omnibox/widget.d.ts +23 -6
- package/lib/omnibox/widget.js +26 -7
- package/lib/showOutput/index.js +15 -18
- package/lib/terminals/agentTerminals.d.ts +63 -0
- package/lib/terminals/agentTerminals.js +157 -0
- package/lib/terminals/index.d.ts +7 -1
- package/lib/terminals/index.js +50 -30
- package/lib/terminals/model.d.ts +11 -2
- package/lib/terminals/model.js +27 -8
- package/lib/terminals/tokens.d.ts +66 -0
- package/lib/terminals/tokens.js +7 -0
- package/lib/terminals/widget.d.ts +3 -6
- package/lib/terminals/widget.js +1 -1
- package/lib/topBar/index.js +3 -3
- package/lib/walkthrough/index.js +14 -3
- package/lib/walkthrough/panel.d.ts +12 -4
- package/lib/walkthrough/panel.js +9 -3
- package/package.json +49 -51
- package/schema/command-bar.json +10 -0
- package/schema/omnibox.json +15 -0
- package/src/agentSessions.ts +6 -2
- package/src/askAgent/editorSelection.ts +138 -0
- package/src/askAgent/icons.ts +16 -0
- package/src/askAgent/index.ts +915 -0
- package/src/askAgent/popup.tsx +407 -0
- package/src/askAgent/prompt.ts +144 -0
- package/src/askAgent/queue.ts +269 -0
- package/src/askAgent/queuePanel.tsx +418 -0
- package/src/askAgent/targetPicker.tsx +209 -0
- package/src/askAgent/tokens.ts +139 -0
- package/src/commandBar/index.ts +75 -49
- package/src/editorBreadcrumbs/index.ts +14 -4
- package/src/editorBreadcrumbs/widget.ts +11 -9
- package/src/fileBrowser/commands.ts +45 -31
- package/src/fileBrowser/contents.ts +7 -3
- package/src/fileBrowser/fileBrowser.tsx +1 -1
- package/src/fileBrowser/widget.tsx +5 -3
- package/src/fileTypeIcons/index.ts +6 -3
- package/src/git/api.ts +2 -61
- package/src/git/askRequest.ts +113 -0
- package/src/git/commands.ts +41 -14
- package/src/git/diffModel.ts +1 -1
- package/src/git/diffProvider.tsx +11 -3
- package/src/git/diffSurface.tsx +114 -22
- package/src/git/diffTheme.ts +1 -1
- package/src/git/diffWidget.tsx +54 -7
- package/src/git/imageDiff.tsx +169 -58
- package/src/git/index.ts +10 -2
- package/src/git/notebookDiff.tsx +91 -57
- package/src/git/tokens.ts +4 -2
- package/src/highlight/index.ts +12 -8
- package/src/index.ts +2 -0
- package/src/launcher/agents.ts +3 -4
- package/src/launcher/commands.ts +6 -2
- package/src/launcher/dashboard.tsx +86 -38
- package/src/launcher/editorRegistry.ts +10 -4
- package/src/launcher/editors.ts +15 -5
- package/src/launcher/icons.ts +11 -12
- package/src/launcher/index.ts +2 -1
- package/src/launcher/invocation.ts +1 -1
- package/src/launcher/schemaDefaults.ts +3 -1
- package/src/mimeData.ts +20 -0
- package/src/omnibox/index.ts +48 -1
- package/src/omnibox/model.ts +173 -33
- package/src/omnibox/recents.ts +155 -0
- package/src/omnibox/tokens.ts +6 -2
- package/src/omnibox/widget.tsx +54 -13
- package/src/showOutput/index.ts +20 -23
- package/src/terminals/agentTerminals.ts +238 -0
- package/src/terminals/index.ts +54 -30
- package/src/terminals/model.ts +28 -7
- package/src/terminals/tokens.ts +77 -0
- package/src/terminals/widget.tsx +4 -2
- package/src/topBar/index.ts +12 -6
- package/src/walkthrough/index.ts +19 -4
- package/src/walkthrough/panel.ts +15 -5
- package/style/askAgent.css +544 -0
- package/style/commandBar.css +12 -33
- package/style/git.css +72 -31
- package/style/index.css +1 -0
- package/style/index.js +2 -0
- package/style/topBar.css +4 -4
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { Terminal } from '@jupyterlab/services';
|
|
2
|
+
import type { ITerminalTracker } from '@jupyterlab/terminal';
|
|
3
|
+
import type { TranslationBundle } from '@jupyterlab/translation';
|
|
4
|
+
import { ISignal, Signal } from '@lumino/signaling';
|
|
5
|
+
|
|
6
|
+
import { fetchRunningAgents } from './detection';
|
|
7
|
+
import type { SessionRegistry, TerminalWidget } from './model';
|
|
8
|
+
import type { IAgentTerminalSession, IAgentTerminals } from './tokens';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pause between pasting the prompt and pressing Enter, so the two reach the
|
|
12
|
+
* agent as separate PTY reads — the same shape as a human pasting and then
|
|
13
|
+
* submitting. Sent back to back, the server can coalesce both websocket
|
|
14
|
+
* messages into one read and the TUI would have to split the paste from the
|
|
15
|
+
* trailing Enter inside a single chunk.
|
|
16
|
+
*/
|
|
17
|
+
const PASTE_SUBMIT_DELAY_MS = 150;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Bracketed-paste markers (`CSI 200~` / `CSI 201~`). The prompt is wrapped
|
|
21
|
+
* in these so the agent's TUI treats it as one pasted block — newlines
|
|
22
|
+
* insert into its input box instead of submitting the message at each line
|
|
23
|
+
* break. Every send target is a terminal where detection just confirmed a
|
|
24
|
+
* running coding agent, and the agent TUIs keep bracketed-paste mode on.
|
|
25
|
+
*/
|
|
26
|
+
const PASTE_OPEN = '\x1b[200~';
|
|
27
|
+
const PASTE_CLOSE = '\x1b[201~';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How long to wait for the session's websocket to reach `connected` before
|
|
31
|
+
* failing the send, so a server hiccup surfaces as an error notification
|
|
32
|
+
* rather than a silent hang.
|
|
33
|
+
*/
|
|
34
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve once `session` reports `connected`; reject with `timeoutMessage`
|
|
38
|
+
* after {@link CONNECT_TIMEOUT_MS}. Matches how the launcher waits before
|
|
39
|
+
* typing into a fresh terminal — writes sent while the websocket is still
|
|
40
|
+
* connecting disappear silently.
|
|
41
|
+
*/
|
|
42
|
+
function waitUntilConnected(
|
|
43
|
+
session: Terminal.ITerminalConnection,
|
|
44
|
+
timeoutMessage: string
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
if (session.connectionStatus === 'connected') {
|
|
47
|
+
return Promise.resolve();
|
|
48
|
+
}
|
|
49
|
+
return new Promise<void>((resolve, reject) => {
|
|
50
|
+
const cleanup = (): void => {
|
|
51
|
+
window.clearTimeout(timer);
|
|
52
|
+
session.connectionStatusChanged.disconnect(onStatus);
|
|
53
|
+
};
|
|
54
|
+
const onStatus = (): void => {
|
|
55
|
+
if (session.connectionStatus === 'connected') {
|
|
56
|
+
cleanup();
|
|
57
|
+
resolve();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const timer = window.setTimeout(() => {
|
|
61
|
+
cleanup();
|
|
62
|
+
reject(new Error(timeoutMessage));
|
|
63
|
+
}, CONNECT_TIMEOUT_MS);
|
|
64
|
+
session.connectionStatusChanged.connect(onStatus);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The {@link IAgentTerminals} implementation: a read-only view over the
|
|
70
|
+
* terminals panel's {@link SessionRegistry} narrowed to sessions with a
|
|
71
|
+
* detection-confirmed coding agent, plus the "send a prompt into one"
|
|
72
|
+
* action. Lives in the `xtralab:terminals` plugin so the ask-agent popup
|
|
73
|
+
* (and any future caller) can target running agents without duplicating the
|
|
74
|
+
* detection plumbing.
|
|
75
|
+
*/
|
|
76
|
+
export class AgentTerminals implements IAgentTerminals {
|
|
77
|
+
constructor(options: AgentTerminals.IOptions) {
|
|
78
|
+
this._registry = options.registry;
|
|
79
|
+
this._tracker = options.tracker;
|
|
80
|
+
this._terminals = options.terminals;
|
|
81
|
+
this._detectCommands = options.detectCommands;
|
|
82
|
+
this._isAgentCommand = options.isAgentCommand;
|
|
83
|
+
this._trans = options.trans;
|
|
84
|
+
this._registry.stateChanged.connect(this._onRegistryStateChanged, this);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
sessions(): IAgentTerminalSession[] {
|
|
88
|
+
const result: IAgentTerminalSession[] = [];
|
|
89
|
+
for (const name of this._registry.sessionNames()) {
|
|
90
|
+
const command = this._registry.detectedCommandFor(name);
|
|
91
|
+
if (command === null || !this._isAgentCommand(command)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
result.push({
|
|
95
|
+
name,
|
|
96
|
+
command,
|
|
97
|
+
label: this._registry.labelFor(name),
|
|
98
|
+
activity: this._registry.activityFor(name)
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get changed(): ISignal<IAgentTerminals, void> {
|
|
105
|
+
return this._changed;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async sendPrompt(name: string, prompt: string): Promise<void> {
|
|
109
|
+
// Re-validate against the server rather than trusting the caller's
|
|
110
|
+
// (possibly seconds-old) snapshot: the prompt is prose, and prose pasted
|
|
111
|
+
// into the shell prompt left behind by an exited agent would be
|
|
112
|
+
// *executed* on the Enter below.
|
|
113
|
+
await this._terminals.refreshRunning();
|
|
114
|
+
const alive = Array.from(this._terminals.running()).some(
|
|
115
|
+
model => model.name === name
|
|
116
|
+
);
|
|
117
|
+
if (!alive) {
|
|
118
|
+
throw new Error(this._trans.__('The terminal is no longer running.'));
|
|
119
|
+
}
|
|
120
|
+
const detected = await fetchRunningAgents(this._detectCommands());
|
|
121
|
+
if (detected === null) {
|
|
122
|
+
// Detection being unavailable (older server, transient failure) is
|
|
123
|
+
// not proof the agent is gone; refuse the write with a message that
|
|
124
|
+
// invites the retry that would succeed.
|
|
125
|
+
throw new Error(
|
|
126
|
+
this._trans.__('Could not verify the terminal — try sending again.')
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const command = detected[name];
|
|
130
|
+
if (typeof command !== 'string' || !this._isAgentCommand(command)) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
this._trans.__('No agent is running in that terminal anymore.')
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Deliver in the background: write straight to the session over its
|
|
137
|
+
// websocket instead of revealing its tab, so focus stays wherever the
|
|
138
|
+
// user is asking from. An open tab's live connection is reused; a
|
|
139
|
+
// closed tab gets a short-lived client connection to the running server
|
|
140
|
+
// session, without creating any widget.
|
|
141
|
+
const widget = this._findWidget(name);
|
|
142
|
+
const session =
|
|
143
|
+
widget !== null
|
|
144
|
+
? widget.content.session
|
|
145
|
+
: this._terminals.connectTo({ model: { name } });
|
|
146
|
+
try {
|
|
147
|
+
await waitUntilConnected(
|
|
148
|
+
session,
|
|
149
|
+
this._trans.__('Timed out connecting to the terminal.')
|
|
150
|
+
);
|
|
151
|
+
// Wrapped and newline-normalised exactly like xterm's own paste
|
|
152
|
+
// handling. Writing the markers directly (rather than through an open
|
|
153
|
+
// tab's `Terminal.paste`) also sidesteps the one case paste() gets
|
|
154
|
+
// wrong: a freshly reconnected xterm whose replayed scrollback no
|
|
155
|
+
// longer holds the agent's original mode-setting escape, which makes
|
|
156
|
+
// paste() skip the wrapping the application still expects.
|
|
157
|
+
session.send({
|
|
158
|
+
type: 'stdin',
|
|
159
|
+
content: [PASTE_OPEN + prompt.replace(/\r?\n/g, '\r') + PASTE_CLOSE]
|
|
160
|
+
});
|
|
161
|
+
await new Promise(resolve =>
|
|
162
|
+
window.setTimeout(resolve, PASTE_SUBMIT_DELAY_MS)
|
|
163
|
+
);
|
|
164
|
+
if (session.isDisposed) {
|
|
165
|
+
// The tab closed during the pause; the pasted text already reached
|
|
166
|
+
// the agent's input box but Enter can no longer be delivered here.
|
|
167
|
+
throw new Error(
|
|
168
|
+
this._trans.__('The terminal closed before the prompt was submitted.')
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
session.send({ type: 'stdin', content: ['\r'] });
|
|
172
|
+
} finally {
|
|
173
|
+
// Close a connection we opened just for this send. Disposing the
|
|
174
|
+
// client connection leaves the server session (and its agent) running.
|
|
175
|
+
if (widget === null && !session.isDisposed) {
|
|
176
|
+
session.dispose();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private _findWidget(name: string): TerminalWidget | null {
|
|
182
|
+
return (
|
|
183
|
+
this._tracker.find(widget => widget.content.session.name === name) ?? null
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private _onRegistryStateChanged(): void {
|
|
188
|
+
this._changed.emit();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private _registry: SessionRegistry;
|
|
192
|
+
private _tracker: ITerminalTracker;
|
|
193
|
+
private _terminals: Terminal.IManager;
|
|
194
|
+
private _detectCommands: () => string[];
|
|
195
|
+
private _isAgentCommand: (command: string) => boolean;
|
|
196
|
+
private _trans: TranslationBundle;
|
|
197
|
+
private _changed = new Signal<IAgentTerminals, void>(this);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A namespace for `AgentTerminals` statics.
|
|
202
|
+
*/
|
|
203
|
+
export namespace AgentTerminals {
|
|
204
|
+
/**
|
|
205
|
+
* Construction options for {@link AgentTerminals}.
|
|
206
|
+
*/
|
|
207
|
+
export interface IOptions {
|
|
208
|
+
/**
|
|
209
|
+
* The panel's session registry — the source of the session snapshot.
|
|
210
|
+
*/
|
|
211
|
+
registry: SessionRegistry;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Tracker of open terminal widgets, to reuse an open tab's connection.
|
|
215
|
+
*/
|
|
216
|
+
tracker: ITerminalTracker;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The terminal session manager, for validation and ad-hoc connections.
|
|
220
|
+
*/
|
|
221
|
+
terminals: Terminal.IManager;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Names to detect — same list the registry polls with.
|
|
225
|
+
*/
|
|
226
|
+
detectCommands: () => string[];
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Whether a detected command belongs to a coding agent (not an editor).
|
|
230
|
+
*/
|
|
231
|
+
isAgentCommand: (command: string) => boolean;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Translation bundle for the error messages thrown to callers.
|
|
235
|
+
*/
|
|
236
|
+
trans: TranslationBundle;
|
|
237
|
+
}
|
|
238
|
+
}
|
package/src/terminals/index.ts
CHANGED
|
@@ -12,7 +12,9 @@ import { LabIcon, MenuSvg, terminalIcon } from '@jupyterlab/ui-components';
|
|
|
12
12
|
import { IAgentSessions } from '../agentSessions';
|
|
13
13
|
import { IEditorRegistry } from '../launcher/editorRegistry';
|
|
14
14
|
import { agentCommandId, IAgentRegistry } from '../launcher/tokens';
|
|
15
|
+
import { AgentTerminals } from './agentTerminals';
|
|
15
16
|
import { SessionRegistry } from './model';
|
|
17
|
+
import { IAgentTerminals } from './tokens';
|
|
16
18
|
import { RunningTerminals } from './widget';
|
|
17
19
|
|
|
18
20
|
const PLUGIN_ID = 'xtralab:terminals';
|
|
@@ -44,12 +46,18 @@ const PLUGIN_ID = 'xtralab:terminals';
|
|
|
44
46
|
* consistent here as in the launcher and reuses the very same registered
|
|
45
47
|
* commands and icons. When the launcher is disabled or no agents are
|
|
46
48
|
* installed, the button falls back to opening a plain terminal directly.
|
|
49
|
+
*
|
|
50
|
+
* The plugin also provides {@link IAgentTerminals} — the sessions with a
|
|
51
|
+
* detection-confirmed running agent, plus a "paste a prompt into one"
|
|
52
|
+
* action — which the ask-agent popup uses to offer running agents as
|
|
53
|
+
* targets next to "new terminal".
|
|
47
54
|
*/
|
|
48
|
-
const plugin: JupyterFrontEndPlugin<
|
|
55
|
+
const plugin: JupyterFrontEndPlugin<IAgentTerminals> = {
|
|
49
56
|
id: PLUGIN_ID,
|
|
50
57
|
description:
|
|
51
58
|
'A left-sidebar panel listing every running terminal session by its real (program-published) title, with activate/reopen, shutdown and new-terminal actions.',
|
|
52
59
|
autoStart: true,
|
|
60
|
+
provides: IAgentTerminals,
|
|
53
61
|
requires: [ITerminalTracker],
|
|
54
62
|
optional: [
|
|
55
63
|
ILayoutRestorer,
|
|
@@ -68,7 +76,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
68
76
|
editorRegistry: IEditorRegistry | null,
|
|
69
77
|
agentSessions: IAgentSessions | null,
|
|
70
78
|
settingRegistry: ISettingRegistry | null
|
|
71
|
-
):
|
|
79
|
+
): IAgentTerminals => {
|
|
72
80
|
const trans = (translator ?? nullTranslator).load('jupyterlab');
|
|
73
81
|
|
|
74
82
|
// Resolve a running agent/editor identifier to an icon: the matching
|
|
@@ -96,6 +104,35 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
96
104
|
return editor?.icon ?? terminalIcon;
|
|
97
105
|
};
|
|
98
106
|
|
|
107
|
+
// The names the server should look for in each terminal's process tree:
|
|
108
|
+
// for every agent and editor, its configured `command` plus its canonical
|
|
109
|
+
// `id`. The id is the real CLI name (e.g. `claude`, `nvim`), so an agent
|
|
110
|
+
// whose command points at an alias — e.g. `ccm` running `claude` — is
|
|
111
|
+
// still recognised by the process it actually spawns. Read live so the
|
|
112
|
+
// list tracks settings changes; deduplicated since command and id usually
|
|
113
|
+
// coincide.
|
|
114
|
+
const detectCommands = (): string[] => {
|
|
115
|
+
const names = new Set<string>();
|
|
116
|
+
for (const agent of agentRegistry?.agents ?? []) {
|
|
117
|
+
names.add(agent.command);
|
|
118
|
+
names.add(agent.id);
|
|
119
|
+
}
|
|
120
|
+
for (const editor of editorRegistry?.editors ?? []) {
|
|
121
|
+
names.add(editor.command);
|
|
122
|
+
names.add(editor.id);
|
|
123
|
+
}
|
|
124
|
+
return Array.from(names);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Whether a detected command/id belongs to a coding agent rather than an
|
|
128
|
+
// editor. Only agent sessions get a latest-activity line (editor
|
|
129
|
+
// terminals stay badge-only), and only they are offered as prompt
|
|
130
|
+
// targets through `IAgentTerminals`.
|
|
131
|
+
const isAgentCommand = (command: string): boolean =>
|
|
132
|
+
agentRegistry?.agents.some(
|
|
133
|
+
a => a.command === command || a.id === command
|
|
134
|
+
) ?? false;
|
|
135
|
+
|
|
99
136
|
const registry = new SessionRegistry({
|
|
100
137
|
serviceManager: app.serviceManager,
|
|
101
138
|
tracker,
|
|
@@ -104,32 +141,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
104
141
|
// nothing while a notebook or other widget is current instead).
|
|
105
142
|
shell: app.shell,
|
|
106
143
|
agentSessions,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// `id`. The id is the real CLI name (e.g. `claude`, `nvim`), so an agent
|
|
110
|
-
// whose command points at an alias — e.g. `ccm` running `claude` — is
|
|
111
|
-
// still recognised by the process it actually spawns. Read live so the
|
|
112
|
-
// list tracks settings changes; deduplicated since command and id usually
|
|
113
|
-
// coincide.
|
|
114
|
-
detectCommands: () => {
|
|
115
|
-
const names = new Set<string>();
|
|
116
|
-
for (const agent of agentRegistry?.agents ?? []) {
|
|
117
|
-
names.add(agent.command);
|
|
118
|
-
names.add(agent.id);
|
|
119
|
-
}
|
|
120
|
-
for (const editor of editorRegistry?.editors ?? []) {
|
|
121
|
-
names.add(editor.command);
|
|
122
|
-
names.add(editor.id);
|
|
123
|
-
}
|
|
124
|
-
return Array.from(names);
|
|
125
|
-
},
|
|
126
|
-
// Only coding agents (not editors) get a latest-activity line, so the
|
|
127
|
-
// registry asks this whether a detected command/id is an agent before
|
|
128
|
-
// reading its output buffer — editor terminals stay badge-only.
|
|
129
|
-
isAgentCommand: (command: string) =>
|
|
130
|
-
agentRegistry?.agents.some(
|
|
131
|
-
a => a.command === command || a.id === command
|
|
132
|
-
) ?? false
|
|
144
|
+
detectCommands,
|
|
145
|
+
isAgentCommand
|
|
133
146
|
});
|
|
134
147
|
|
|
135
148
|
// Mirror the agent/editor detected in each open terminal onto its main-area
|
|
@@ -138,7 +151,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
138
151
|
// the writes neither loop nor churn.
|
|
139
152
|
const syncTabIcons = (): void => {
|
|
140
153
|
tracker.forEach(widget => {
|
|
141
|
-
const session = widget.content
|
|
154
|
+
const session = widget.content.session;
|
|
142
155
|
if (!session) {
|
|
143
156
|
return;
|
|
144
157
|
}
|
|
@@ -274,10 +287,21 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
274
287
|
);
|
|
275
288
|
});
|
|
276
289
|
}
|
|
290
|
+
|
|
291
|
+
return new AgentTerminals({
|
|
292
|
+
registry,
|
|
293
|
+
tracker,
|
|
294
|
+
terminals: app.serviceManager.terminals,
|
|
295
|
+
detectCommands,
|
|
296
|
+
isAgentCommand,
|
|
297
|
+
trans
|
|
298
|
+
});
|
|
277
299
|
}
|
|
278
300
|
};
|
|
279
301
|
|
|
280
|
-
/**
|
|
302
|
+
/**
|
|
303
|
+
* Read a boolean setting value, falling back when it is missing or invalid.
|
|
304
|
+
*/
|
|
281
305
|
function boolOption(value: unknown, fallback: boolean): boolean {
|
|
282
306
|
return typeof value === 'boolean' ? value : fallback;
|
|
283
307
|
}
|
package/src/terminals/model.ts
CHANGED
|
@@ -245,6 +245,20 @@ export class SessionRegistry implements IDisposable {
|
|
|
245
245
|
return null;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The agent command server-side detection currently reports for the
|
|
250
|
+
* session, or `null` when it has not confirmed one (the session is idle,
|
|
251
|
+
* not yet covered by a poll, or detection is unavailable). Unlike
|
|
252
|
+
* {@link agentCommandFor} this never falls back to the optimistic launch
|
|
253
|
+
* tags: callers about to *write into* a session use it, and text must only
|
|
254
|
+
* ever be sent to a confirmed running agent — pasted into a shell prompt it
|
|
255
|
+
* would be executed.
|
|
256
|
+
*/
|
|
257
|
+
detectedCommandFor(name: string): string | null {
|
|
258
|
+
const detected = this._detected.get(name);
|
|
259
|
+
return typeof detected === 'string' ? detected : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
248
262
|
/**
|
|
249
263
|
* The most recent meaningful line of output from the session's terminal, or
|
|
250
264
|
* `null` when there is nothing to surface — no coding agent is running in the
|
|
@@ -542,7 +556,7 @@ export class SessionRegistry implements IDisposable {
|
|
|
542
556
|
|
|
543
557
|
private _onTitleChanged(title: Title<Widget>): void {
|
|
544
558
|
const owner = title.owner as TerminalWidget;
|
|
545
|
-
const session = owner.content
|
|
559
|
+
const session = owner.content.session;
|
|
546
560
|
if (!session) {
|
|
547
561
|
return;
|
|
548
562
|
}
|
|
@@ -558,8 +572,7 @@ export class SessionRegistry implements IDisposable {
|
|
|
558
572
|
* `_initialConnection` fires). Without the filter, briefly
|
|
559
573
|
* reopening a tab while an agent has yet to re-emit its xterm
|
|
560
574
|
* title escape sequence would clobber the cached agent name with
|
|
561
|
-
* `Terminal 1
|
|
562
|
-
* against. Live widget titles still display whatever the widget
|
|
575
|
+
* `Terminal 1`. Live widget titles still display whatever the widget
|
|
563
576
|
* currently holds because `labelFor` consults the widget first;
|
|
564
577
|
* the filter only affects what survives a tab close.
|
|
565
578
|
*/
|
|
@@ -663,10 +676,14 @@ namespace Private {
|
|
|
663
676
|
return true;
|
|
664
677
|
}
|
|
665
678
|
|
|
666
|
-
/**
|
|
679
|
+
/**
|
|
680
|
+
* Rows above the cursor to scan when looking for the latest output.
|
|
681
|
+
*/
|
|
667
682
|
const ACTIVITY_SCAN_ROWS = 64;
|
|
668
683
|
|
|
669
|
-
/**
|
|
684
|
+
/**
|
|
685
|
+
* Clamp for the activity string so one runaway block can't bloat a row.
|
|
686
|
+
*/
|
|
670
687
|
const ACTIVITY_MAX_LENGTH = 160;
|
|
671
688
|
|
|
672
689
|
/**
|
|
@@ -715,7 +732,9 @@ namespace Private {
|
|
|
715
732
|
return null;
|
|
716
733
|
}
|
|
717
734
|
|
|
718
|
-
/**
|
|
735
|
+
/**
|
|
736
|
+
* Sanitized text of one buffer row.
|
|
737
|
+
*/
|
|
719
738
|
export function lineText(buffer: IXtermBuffer, row: number): string {
|
|
720
739
|
return sanitizeActivity(buffer.getLine(row)?.translateToString(true) ?? '');
|
|
721
740
|
}
|
|
@@ -746,7 +765,9 @@ namespace Private {
|
|
|
746
765
|
return result;
|
|
747
766
|
}
|
|
748
767
|
|
|
749
|
-
/**
|
|
768
|
+
/**
|
|
769
|
+
* Truncate by code point (never splitting an emoji) with an ellipsis.
|
|
770
|
+
*/
|
|
750
771
|
export function clampActivity(text: string): string {
|
|
751
772
|
const points = Array.from(text);
|
|
752
773
|
return points.length > ACTIVITY_MAX_LENGTH
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Token } from '@lumino/coreutils';
|
|
2
|
+
import type { ISignal } from '@lumino/signaling';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One running terminal session with a confirmed coding agent inside — an
|
|
6
|
+
* entry of {@link IAgentTerminals.sessions}.
|
|
7
|
+
*/
|
|
8
|
+
export interface IAgentTerminalSession {
|
|
9
|
+
/**
|
|
10
|
+
* The terminal session name (terminado's, e.g. `1`).
|
|
11
|
+
*/
|
|
12
|
+
name: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The agent identifier server-side detection reported for the session:
|
|
16
|
+
* the configured `command`, or the canonical agent `id` when the command
|
|
17
|
+
* is an alias (e.g. `ccm` spawning `claude`). Matches an `IAgent` by
|
|
18
|
+
* either field.
|
|
19
|
+
*/
|
|
20
|
+
command: string;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Display label — the session's real title (the launcher's agent name, or
|
|
24
|
+
* whatever the running program published via an xterm escape sequence).
|
|
25
|
+
*/
|
|
26
|
+
label: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The latest meaningful line of the agent's output, when its tab is open
|
|
30
|
+
* and the activity line is enabled; `null` otherwise.
|
|
31
|
+
*/
|
|
32
|
+
activity: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Running agent terminals, and a way to send a prompt into one.
|
|
37
|
+
*
|
|
38
|
+
* `sessions` only lists sessions where server-side process detection has
|
|
39
|
+
* *confirmed* a running coding agent (editors and plain shells are excluded,
|
|
40
|
+
* as are optimistic just-launched tags): every listed session is one whose
|
|
41
|
+
* TUI can meaningfully receive pasted prompt text. `sendPrompt` re-validates
|
|
42
|
+
* against the server before writing, since pasting prose into a shell prompt
|
|
43
|
+
* would execute it.
|
|
44
|
+
*/
|
|
45
|
+
export interface IAgentTerminals {
|
|
46
|
+
/**
|
|
47
|
+
* Snapshot of the sessions with a detection-confirmed running agent.
|
|
48
|
+
*/
|
|
49
|
+
sessions(): IAgentTerminalSession[];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Emitted whenever the {@link sessions} snapshot may have changed.
|
|
53
|
+
*/
|
|
54
|
+
readonly changed: ISignal<IAgentTerminals, void>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Paste `prompt` into the named session's terminal and press Enter.
|
|
58
|
+
*
|
|
59
|
+
* Delivery is deliberately background: the text is written straight to
|
|
60
|
+
* the session's websocket, bracketed-paste-wrapped so a multi-line prompt
|
|
61
|
+
* arrives as one block in the agent's input box, without revealing or
|
|
62
|
+
* focusing the terminal's tab — the user keeps working where they are,
|
|
63
|
+
* and the agent CLIs queue the prompt themselves when they are busy.
|
|
64
|
+
* Rejects when the session is gone or no agent is running in it anymore.
|
|
65
|
+
*/
|
|
66
|
+
sendPrompt(name: string, prompt: string): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* DI token for {@link IAgentTerminals}. Provided by `xtralab:terminals`;
|
|
71
|
+
* consumed — optionally, so each side works without the other — by
|
|
72
|
+
* `xtralab:ask-agent` to offer running agent terminals as prompt targets.
|
|
73
|
+
*/
|
|
74
|
+
export const IAgentTerminals = new Token<IAgentTerminals>(
|
|
75
|
+
'xtralab:IAgentTerminals',
|
|
76
|
+
'The running terminal sessions with a detected coding agent, and a way to send a prompt into one.'
|
|
77
|
+
);
|
package/src/terminals/widget.tsx
CHANGED
|
@@ -19,7 +19,7 @@ import { SessionRegistry } from './model';
|
|
|
19
19
|
* Id of the panel widget. Used for layout restoration and as the handle
|
|
20
20
|
* the sidebar visibility toggle would target.
|
|
21
21
|
*/
|
|
22
|
-
|
|
22
|
+
const RUNNING_TERMINALS_ID = 'xtralab-running-terminals';
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Left-sidebar panel listing every running terminal session. It is the
|
|
@@ -108,7 +108,9 @@ export namespace RunningTerminals {
|
|
|
108
108
|
* terminal widget if no tab is currently attached.
|
|
109
109
|
*/
|
|
110
110
|
onActivate: (sessionName: string) => void;
|
|
111
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Shut the named session down on the server.
|
|
113
|
+
*/
|
|
112
114
|
onShutdown: (sessionName: string) => void;
|
|
113
115
|
/**
|
|
114
116
|
* Shut down every running terminal at once. The plugin is expected to
|
package/src/topBar/index.ts
CHANGED
|
@@ -21,9 +21,13 @@ const TOGGLE_LEFT_AREA = 'application:toggle-left-area';
|
|
|
21
21
|
const TOGGLE_RIGHT_AREA = 'application:toggle-right-area';
|
|
22
22
|
|
|
23
23
|
interface IButtonSpec {
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Stable widget id (required by `LabShell.add`).
|
|
26
|
+
*/
|
|
25
27
|
id: string;
|
|
26
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Command the button triggers and mirrors the state of.
|
|
30
|
+
*/
|
|
27
31
|
command: string;
|
|
28
32
|
icon: LabIcon;
|
|
29
33
|
caption: (trans: ReturnType<ITranslator['load']>) => string;
|
|
@@ -36,7 +40,9 @@ interface IButtonSpec {
|
|
|
36
40
|
* leading edge is free.
|
|
37
41
|
*/
|
|
38
42
|
rank: number;
|
|
39
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Side-specific class, used by the stylesheet for placement.
|
|
45
|
+
*/
|
|
40
46
|
sideClass: string;
|
|
41
47
|
}
|
|
42
48
|
|
|
@@ -47,7 +53,7 @@ const BUTTONS: IButtonSpec[] = [
|
|
|
47
53
|
icon: leftSidebarIcon,
|
|
48
54
|
caption: trans => trans.__('Toggle left sidebar'),
|
|
49
55
|
rank: -1,
|
|
50
|
-
sideClass: 'xtralab-TopBarButton-left'
|
|
56
|
+
sideClass: 'jp-xtralab-TopBarButton-left'
|
|
51
57
|
},
|
|
52
58
|
{
|
|
53
59
|
id: 'xtralab-toggle-right-sidebar',
|
|
@@ -55,7 +61,7 @@ const BUTTONS: IButtonSpec[] = [
|
|
|
55
61
|
icon: rightSidebarIcon,
|
|
56
62
|
caption: trans => trans.__('Toggle right sidebar'),
|
|
57
63
|
rank: 1000,
|
|
58
|
-
sideClass: 'xtralab-TopBarButton-right'
|
|
64
|
+
sideClass: 'jp-xtralab-TopBarButton-right'
|
|
59
65
|
}
|
|
60
66
|
];
|
|
61
67
|
|
|
@@ -123,7 +129,7 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
123
129
|
noFocusOnClick: true
|
|
124
130
|
});
|
|
125
131
|
button.id = spec.id;
|
|
126
|
-
button.addClass('xtralab-TopBarButton');
|
|
132
|
+
button.addClass('jp-xtralab-TopBarButton');
|
|
127
133
|
button.addClass(spec.sideClass);
|
|
128
134
|
labShell.add(button, 'top', { rank: spec.rank });
|
|
129
135
|
}
|
package/src/walkthrough/index.ts
CHANGED
|
@@ -7,19 +7,25 @@ import { ICommandPalette } from '@jupyterlab/apputils';
|
|
|
7
7
|
import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
|
|
8
8
|
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
9
9
|
import { tocIcon } from '@jupyterlab/ui-components';
|
|
10
|
+
import type { ReadonlyPartialJSONValue } from '@lumino/coreutils';
|
|
10
11
|
|
|
12
|
+
import { coerceData } from '../mimeData';
|
|
11
13
|
import { IWalkthroughMedia, IWalkthroughStep, WalkthroughPanel } from './panel';
|
|
12
14
|
|
|
13
15
|
const PLUGIN_ID = 'xtralab:walkthrough';
|
|
14
16
|
|
|
15
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Append a step to the read-only walkthrough panel.
|
|
19
|
+
*/
|
|
16
20
|
const WALKTHROUGH_COMMAND = 'xtralab:walkthrough';
|
|
17
21
|
|
|
18
22
|
const HIGHLIGHT_LINES_COMMAND = 'xtralab:highlight-lines';
|
|
19
23
|
|
|
20
24
|
const PANEL_ID = 'xtralab-walkthrough';
|
|
21
25
|
|
|
22
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Pull a `{ mimeType, data }` media object out of a JSON command argument.
|
|
28
|
+
*/
|
|
23
29
|
function readMedia(value: unknown): IWalkthroughMedia | undefined {
|
|
24
30
|
if (
|
|
25
31
|
value &&
|
|
@@ -27,8 +33,17 @@ function readMedia(value: unknown): IWalkthroughMedia | undefined {
|
|
|
27
33
|
!Array.isArray(value) &&
|
|
28
34
|
typeof (value as { mimeType?: unknown }).mimeType === 'string'
|
|
29
35
|
) {
|
|
30
|
-
const media = value as {
|
|
31
|
-
|
|
36
|
+
const media = value as {
|
|
37
|
+
mimeType: string;
|
|
38
|
+
data?: ReadonlyPartialJSONValue;
|
|
39
|
+
};
|
|
40
|
+
if (media.data === undefined) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
mimeType: media.mimeType,
|
|
45
|
+
data: coerceData(media.mimeType, media.data)
|
|
46
|
+
};
|
|
32
47
|
}
|
|
33
48
|
return undefined;
|
|
34
49
|
}
|