xtralab 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -9
- 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 +7 -0
- package/lib/launcher/agents.js +24 -0
- package/lib/launcher/editorRegistry.js +5 -3
- package/lib/launcher/editors.d.ts +8 -0
- package/lib/launcher/editors.js +17 -0
- package/lib/launcher/index.js +4 -0
- 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 +3 -2
- 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 +25 -0
- package/src/launcher/editorRegistry.ts +8 -4
- package/src/launcher/editors.ts +18 -0
- package/src/launcher/index.ts +4 -0
- 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
package/src/terminals/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
JupyterFrontEndPlugin
|
|
5
5
|
} from '@jupyterlab/application';
|
|
6
6
|
import { Dialog, showDialog } from '@jupyterlab/apputils';
|
|
7
|
+
import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
7
8
|
import { ITerminalTracker } from '@jupyterlab/terminal';
|
|
8
9
|
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
|
|
9
10
|
import { LabIcon, MenuSvg, terminalIcon } from '@jupyterlab/ui-components';
|
|
@@ -26,6 +27,10 @@ const PLUGIN_ID = 'xtralab:terminals';
|
|
|
26
27
|
* JupyterLab's built-in "Running Terminals and Kernels" panel lists the
|
|
27
28
|
* same sessions but only by their `terminals/<n>` name, so this panel
|
|
28
29
|
* resolves and caches the published title to show something meaningful.
|
|
30
|
+
* Rows running a coding agent carry a smaller second line below the
|
|
31
|
+
* title with the agent's latest line of output, so the panel shows what
|
|
32
|
+
* each agent is doing at a glance — on by default, and switchable off
|
|
33
|
+
* through the `showAgentActivity` setting.
|
|
29
34
|
*
|
|
30
35
|
* Clicking a row activates the existing tab if one is open, or reopens
|
|
31
36
|
* the session in a fresh terminal widget (`terminal:open`). The inline
|
|
@@ -51,7 +56,8 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
51
56
|
ITranslator,
|
|
52
57
|
IAgentRegistry,
|
|
53
58
|
IEditorRegistry,
|
|
54
|
-
IAgentSessions
|
|
59
|
+
IAgentSessions,
|
|
60
|
+
ISettingRegistry
|
|
55
61
|
],
|
|
56
62
|
activate: (
|
|
57
63
|
app: JupyterFrontEnd,
|
|
@@ -60,25 +66,33 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
60
66
|
translator: ITranslator | null,
|
|
61
67
|
agentRegistry: IAgentRegistry | null,
|
|
62
68
|
editorRegistry: IEditorRegistry | null,
|
|
63
|
-
agentSessions: IAgentSessions | null
|
|
69
|
+
agentSessions: IAgentSessions | null,
|
|
70
|
+
settingRegistry: ISettingRegistry | null
|
|
64
71
|
): void => {
|
|
65
72
|
const trans = (translator ?? nullTranslator).load('jupyterlab');
|
|
66
73
|
|
|
67
|
-
// Resolve a
|
|
68
|
-
// logo, the matching editor's logo (Neovim/Vim), or the plain
|
|
69
|
-
// icon when nothing recognised is running.
|
|
70
|
-
//
|
|
74
|
+
// Resolve a running agent/editor identifier to an icon: the matching
|
|
75
|
+
// agent's logo, the matching editor's logo (Neovim/Vim), or the plain
|
|
76
|
+
// terminal icon when nothing recognised is running. Detection reports
|
|
77
|
+
// either the configured `command` or the canonical `id` (see
|
|
78
|
+
// `detectCommands`), so a row whose command is an alias — e.g. `ccm` for
|
|
79
|
+
// `claude` — still resolves to the right logo via its id. Reads the
|
|
80
|
+
// registries live so it tracks settings changes. Used to badge each row.
|
|
71
81
|
const iconForCommand = (command: string | null): LabIcon => {
|
|
72
82
|
if (!command) {
|
|
73
83
|
return terminalIcon;
|
|
74
84
|
}
|
|
75
|
-
const agent = agentRegistry?.agents.find(
|
|
85
|
+
const agent = agentRegistry?.agents.find(
|
|
86
|
+
a => a.command === command || a.id === command
|
|
87
|
+
);
|
|
76
88
|
if (agent) {
|
|
77
89
|
return agent.icon;
|
|
78
90
|
}
|
|
79
91
|
// A terminal running an editor (Neovim/Vim, or a user-configured one) is
|
|
80
92
|
// badged with its logo, the same as an agent.
|
|
81
|
-
const editor = editorRegistry?.editors.find(
|
|
93
|
+
const editor = editorRegistry?.editors.find(
|
|
94
|
+
e => e.command === command || e.id === command
|
|
95
|
+
);
|
|
82
96
|
return editor?.icon ?? terminalIcon;
|
|
83
97
|
};
|
|
84
98
|
|
|
@@ -90,14 +104,32 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
90
104
|
// nothing while a notebook or other widget is current instead).
|
|
91
105
|
shell: app.shell,
|
|
92
106
|
agentSessions,
|
|
93
|
-
// The
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
// The names the server should look for in each terminal's process tree:
|
|
108
|
+
// for every agent and editor, its configured `command` plus its canonical
|
|
109
|
+
// `id`. The id is the real CLI name (e.g. `claude`, `nvim`), so an agent
|
|
110
|
+
// whose command points at an alias — e.g. `ccm` running `claude` — is
|
|
111
|
+
// still recognised by the process it actually spawns. Read live so the
|
|
112
|
+
// list tracks settings changes; deduplicated since command and id usually
|
|
113
|
+
// coincide.
|
|
114
|
+
detectCommands: () => {
|
|
115
|
+
const names = new Set<string>();
|
|
116
|
+
for (const agent of agentRegistry?.agents ?? []) {
|
|
117
|
+
names.add(agent.command);
|
|
118
|
+
names.add(agent.id);
|
|
119
|
+
}
|
|
120
|
+
for (const editor of editorRegistry?.editors ?? []) {
|
|
121
|
+
names.add(editor.command);
|
|
122
|
+
names.add(editor.id);
|
|
123
|
+
}
|
|
124
|
+
return Array.from(names);
|
|
125
|
+
},
|
|
126
|
+
// Only coding agents (not editors) get a latest-activity line, so the
|
|
127
|
+
// registry asks this whether a detected command/id is an agent before
|
|
128
|
+
// reading its output buffer — editor terminals stay badge-only.
|
|
129
|
+
isAgentCommand: (command: string) =>
|
|
130
|
+
agentRegistry?.agents.some(
|
|
131
|
+
a => a.command === command || a.id === command
|
|
132
|
+
) ?? false
|
|
101
133
|
});
|
|
102
134
|
|
|
103
135
|
// Mirror the agent/editor detected in each open terminal onto its main-area
|
|
@@ -216,7 +248,38 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
216
248
|
if (restorer) {
|
|
217
249
|
restorer.add(panel, panel.id);
|
|
218
250
|
}
|
|
251
|
+
|
|
252
|
+
// The latest-activity line under each agent row is on by default; honour
|
|
253
|
+
// the `showAgentActivity` setting so it can be turned off. Read on load and
|
|
254
|
+
// re-applied on every change.
|
|
255
|
+
if (settingRegistry) {
|
|
256
|
+
settingRegistry
|
|
257
|
+
.load(PLUGIN_ID)
|
|
258
|
+
.then(settings => {
|
|
259
|
+
const applyActivitySetting = (): void => {
|
|
260
|
+
registry.setActivityEnabled(
|
|
261
|
+
boolOption(settings.composite.showAgentActivity, true)
|
|
262
|
+
);
|
|
263
|
+
};
|
|
264
|
+
applyActivitySetting();
|
|
265
|
+
// Bind the slot to the panel so disposing the panel (which clears
|
|
266
|
+
// its signal data) drops the connection — the disposed registry is
|
|
267
|
+
// then never reached on a later settings change.
|
|
268
|
+
settings.changed.connect(applyActivitySetting, panel);
|
|
269
|
+
})
|
|
270
|
+
.catch(reason => {
|
|
271
|
+
console.error(
|
|
272
|
+
`xtralab: failed to load ${PLUGIN_ID} settings`,
|
|
273
|
+
reason
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
219
277
|
}
|
|
220
278
|
};
|
|
221
279
|
|
|
280
|
+
/** Read a boolean setting value, falling back when it is missing or invalid. */
|
|
281
|
+
function boolOption(value: unknown, fallback: boolean): boolean {
|
|
282
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
283
|
+
}
|
|
284
|
+
|
|
222
285
|
export default plugin;
|
package/src/terminals/model.ts
CHANGED
|
@@ -16,6 +16,31 @@ import { fetchRunningAgents } from './detection';
|
|
|
16
16
|
*/
|
|
17
17
|
export type TerminalWidget = MainAreaWidget<ITerminal.ITerminal>;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The slice of an xterm.js `Terminal` the registry reads to surface a
|
|
21
|
+
* session's most recent line of output. JupyterLab keeps its xterm in the
|
|
22
|
+
* terminal widget's private `_term` field, so a structural type reaches the
|
|
23
|
+
* buffer without taking a dependency on `@xterm/xterm` — the same access the
|
|
24
|
+
* terminal-notifications plugin already relies on. If the field is ever
|
|
25
|
+
* renamed, `_term` reads back `undefined` and the activity line is simply
|
|
26
|
+
* omitted: the row falls back to its title alone.
|
|
27
|
+
*/
|
|
28
|
+
interface IXtermBufferLine {
|
|
29
|
+
readonly isWrapped: boolean;
|
|
30
|
+
translateToString(trimRight?: boolean): string;
|
|
31
|
+
}
|
|
32
|
+
interface IXtermBuffer {
|
|
33
|
+
readonly baseY: number;
|
|
34
|
+
readonly cursorY: number;
|
|
35
|
+
getLine(index: number): IXtermBufferLine | undefined;
|
|
36
|
+
}
|
|
37
|
+
interface IXtermTerminal {
|
|
38
|
+
readonly buffer: { readonly active: IXtermBuffer };
|
|
39
|
+
}
|
|
40
|
+
interface ITerminalContentInternals extends ITerminal.ITerminal {
|
|
41
|
+
_term?: IXtermTerminal;
|
|
42
|
+
}
|
|
43
|
+
|
|
19
44
|
/**
|
|
20
45
|
* How often to ask the server which agent (if any) is running in each
|
|
21
46
|
* terminal. Snappy enough that a manually-started agent's logo shows up
|
|
@@ -41,6 +66,14 @@ const DETECT_POLL_MAX_MS = 300_000;
|
|
|
41
66
|
*/
|
|
42
67
|
const LAUNCH_GRACE_MS = 4000;
|
|
43
68
|
|
|
69
|
+
/**
|
|
70
|
+
* How often to re-read each open coding-agent terminal's output buffer to
|
|
71
|
+
* refresh the "latest activity" line shown under its row. Brisk enough to feel
|
|
72
|
+
* live while an agent works, cheap because it only walks the last screenful of
|
|
73
|
+
* a handful of terminals and emits only when a line actually changes.
|
|
74
|
+
*/
|
|
75
|
+
const ACTIVITY_POLL_INTERVAL_MS = 1500;
|
|
76
|
+
|
|
44
77
|
/**
|
|
45
78
|
* Source-of-truth model for the running-terminals panel. Each running
|
|
46
79
|
* terminal session known to the server is one entry; the registry caches
|
|
@@ -48,6 +81,11 @@ const LAUNCH_GRACE_MS = 4000;
|
|
|
48
81
|
* xterm-published title) survives the user closing the tab while the
|
|
49
82
|
* session continues running on the backend.
|
|
50
83
|
*
|
|
84
|
+
* For sessions running a coding agent it also surfaces a *latest activity*
|
|
85
|
+
* line — the freshest meaningful line of the terminal's output, read from the
|
|
86
|
+
* open tab's xterm buffer on a poll (see {@link activityFor}) — so each row
|
|
87
|
+
* shows what its agent is doing, not just its name.
|
|
88
|
+
*
|
|
51
89
|
* It also resolves *which agent is running* in each session, so the panel can
|
|
52
90
|
* badge rows with the agent's logo. Two inputs feed that, reconciled by
|
|
53
91
|
* {@link agentCommandFor}:
|
|
@@ -74,6 +112,7 @@ export class SessionRegistry implements IDisposable {
|
|
|
74
112
|
this._tracker = options.tracker;
|
|
75
113
|
this._agentSessions = options.agentSessions ?? null;
|
|
76
114
|
this._detectCommands = options.detectCommands ?? (() => []);
|
|
115
|
+
this._isAgentCommand = options.isAgentCommand ?? (() => true);
|
|
77
116
|
this._shell = options.shell ?? null;
|
|
78
117
|
|
|
79
118
|
this._terminals.runningChanged.connect(this._onRunningChanged, this);
|
|
@@ -105,6 +144,17 @@ export class SessionRegistry implements IDisposable {
|
|
|
105
144
|
},
|
|
106
145
|
standby: 'when-hidden'
|
|
107
146
|
});
|
|
147
|
+
|
|
148
|
+
// A second, faster poll reads the live output buffer of each open
|
|
149
|
+
// coding-agent terminal to keep its "latest activity" line current. Kept
|
|
150
|
+
// separate from the detection poll so reading xterm buffers neither delays
|
|
151
|
+
// nor is delayed by the server round-trip that drives the agent badges.
|
|
152
|
+
this._activityPoll = new Poll({
|
|
153
|
+
name: '@xtralab/terminals:activity',
|
|
154
|
+
factory: () => this._refreshActivity(),
|
|
155
|
+
frequency: { interval: ACTIVITY_POLL_INTERVAL_MS, backoff: false },
|
|
156
|
+
standby: 'when-hidden'
|
|
157
|
+
});
|
|
108
158
|
}
|
|
109
159
|
|
|
110
160
|
/**
|
|
@@ -162,7 +212,10 @@ export class SessionRegistry implements IDisposable {
|
|
|
162
212
|
}
|
|
163
213
|
|
|
164
214
|
/**
|
|
165
|
-
*
|
|
215
|
+
* An identifier for the agent running in the session — its configured
|
|
216
|
+
* command, or its canonical id when detection matched the spawned process by
|
|
217
|
+
* id rather than by an aliased command — or `null` if none. Callers resolve
|
|
218
|
+
* it to a logo through the plugin's `iconForCommand`, which accepts either.
|
|
166
219
|
*
|
|
167
220
|
* Server-side detection is authoritative whenever it reports a running
|
|
168
221
|
* agent. A launch tag fills two gaps: the startup grace window right after
|
|
@@ -192,6 +245,44 @@ export class SessionRegistry implements IDisposable {
|
|
|
192
245
|
return null;
|
|
193
246
|
}
|
|
194
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The most recent meaningful line of output from the session's terminal, or
|
|
250
|
+
* `null` when there is nothing to surface — no coding agent is running in the
|
|
251
|
+
* session, its tab is closed (so there is no live buffer to read), or the
|
|
252
|
+
* buffer holds only the agent's input box and chrome. Refreshed on a poll by
|
|
253
|
+
* {@link _refreshActivity}; shown as a smaller line under the row's title.
|
|
254
|
+
* Always `null` while the activity line is disabled (see
|
|
255
|
+
* {@link setActivityEnabled}).
|
|
256
|
+
*/
|
|
257
|
+
activityFor(name: string): string | null {
|
|
258
|
+
if (!this._activityEnabled) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
return this._activity.get(name) ?? null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Turn the per-row latest-activity line on or off, driven by the
|
|
266
|
+
* `xtralab:terminals` `showAgentActivity` setting. When turned off the
|
|
267
|
+
* activity poll stops reading terminal buffers and any cached lines are
|
|
268
|
+
* dropped, so rows fall back to their title alone; when turned back on the
|
|
269
|
+
* lines are repopulated on the spot. A no-op when the value is unchanged.
|
|
270
|
+
*/
|
|
271
|
+
setActivityEnabled(enabled: boolean): void {
|
|
272
|
+
if (enabled === this._activityEnabled) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this._activityEnabled = enabled;
|
|
276
|
+
if (enabled) {
|
|
277
|
+
// Repopulate immediately rather than waiting for the next poll tick;
|
|
278
|
+
// `_refreshActivity` emits `stateChanged` if it finds anything to show.
|
|
279
|
+
void this._refreshActivity();
|
|
280
|
+
} else {
|
|
281
|
+
this._activity.clear();
|
|
282
|
+
this._stateChanged.emit();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
195
286
|
/**
|
|
196
287
|
* Return the open widget for a session, if any. The panel uses this to
|
|
197
288
|
* switch behavior between "activate existing tab" and "open a new tab
|
|
@@ -236,6 +327,7 @@ export class SessionRegistry implements IDisposable {
|
|
|
236
327
|
}
|
|
237
328
|
this._isDisposed = true;
|
|
238
329
|
this._poll.dispose();
|
|
330
|
+
this._activityPoll.dispose();
|
|
239
331
|
this._terminals.runningChanged.disconnect(this._onRunningChanged, this);
|
|
240
332
|
this._tracker.widgetAdded.disconnect(this._onWidgetAdded, this);
|
|
241
333
|
this._agentSessions?.changed.disconnect(this._onTagChanged, this);
|
|
@@ -296,6 +388,11 @@ export class SessionRegistry implements IDisposable {
|
|
|
296
388
|
this._detected.delete(name);
|
|
297
389
|
}
|
|
298
390
|
}
|
|
391
|
+
for (const name of Array.from(this._activity.keys())) {
|
|
392
|
+
if (!next.has(name)) {
|
|
393
|
+
this._activity.delete(name);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
299
396
|
// Forget launch tags for sessions that have gone away. This matters for
|
|
300
397
|
// correctness as well as bookkeeping: terminado reuses session names, so
|
|
301
398
|
// a stale tag could otherwise mislabel a brand-new terminal that happens
|
|
@@ -337,6 +434,62 @@ export class SessionRegistry implements IDisposable {
|
|
|
337
434
|
}
|
|
338
435
|
}
|
|
339
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Poll body: re-read the buffer of each open coding-agent terminal and cache
|
|
439
|
+
* its latest meaningful line of output. Only sessions that have a running
|
|
440
|
+
* *agent* (not an editor) and an open tab qualify — a closed tab has no live
|
|
441
|
+
* buffer, editors run full-screen UIs that are not "activity", and plain
|
|
442
|
+
* shells would only echo their prompt. While a tab is reopening (its xterm
|
|
443
|
+
* is not ready yet) any existing line is kept; once the buffer is readable
|
|
444
|
+
* but has nothing worth showing the line is dropped, so a screen the agent
|
|
445
|
+
* has cleared doesn't leave a frozen line behind. Cached lines for sessions
|
|
446
|
+
* that no longer qualify are pruned. Emits only when something changed.
|
|
447
|
+
*/
|
|
448
|
+
private async _refreshActivity(): Promise<void> {
|
|
449
|
+
if (!this._activityEnabled) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
let changed = false;
|
|
453
|
+
const qualifying = new Set<string>();
|
|
454
|
+
for (const name of this._live) {
|
|
455
|
+
const command = this.agentCommandFor(name);
|
|
456
|
+
if (command === null || !this._isAgentCommand(command)) {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const widget = this.widgetFor(name);
|
|
460
|
+
if (!widget) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
qualifying.add(name);
|
|
464
|
+
const term = (widget.content as ITerminalContentInternals)._term;
|
|
465
|
+
if (!term) {
|
|
466
|
+
// The tab is reopening and its xterm is not ready; keep any existing
|
|
467
|
+
// line until the buffer can be read again.
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
const line = Private.readActivity(term);
|
|
471
|
+
if (line) {
|
|
472
|
+
if (this._activity.get(name) !== line) {
|
|
473
|
+
this._activity.set(name, line);
|
|
474
|
+
changed = true;
|
|
475
|
+
}
|
|
476
|
+
} else if (this._activity.delete(name)) {
|
|
477
|
+
// Readable buffer with nothing to surface (e.g. the agent cleared its
|
|
478
|
+
// screen) — drop the now-stale line instead of freezing it.
|
|
479
|
+
changed = true;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
for (const name of Array.from(this._activity.keys())) {
|
|
483
|
+
if (!qualifying.has(name)) {
|
|
484
|
+
this._activity.delete(name);
|
|
485
|
+
changed = true;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (changed) {
|
|
489
|
+
this._stateChanged.emit();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
340
493
|
private _onTagChanged(): void {
|
|
341
494
|
this._stateChanged.emit();
|
|
342
495
|
// Re-render once the grace window closes so a tag that detection never
|
|
@@ -434,12 +587,16 @@ export class SessionRegistry implements IDisposable {
|
|
|
434
587
|
private _tracker: ITerminalTracker;
|
|
435
588
|
private _agentSessions: IAgentSessions | null;
|
|
436
589
|
private _detectCommands: () => string[];
|
|
590
|
+
private _isAgentCommand: (command: string) => boolean;
|
|
437
591
|
private _shell: JupyterFrontEnd.IShell | null;
|
|
438
592
|
private _poll: Poll;
|
|
593
|
+
private _activityPoll: Poll;
|
|
439
594
|
private _labels = new Map<string, string>();
|
|
440
595
|
private _ranks = new Map<string, number>();
|
|
441
596
|
private _firstSeen = new Map<string, number>();
|
|
442
597
|
private _detected = new Map<string, string | null>();
|
|
598
|
+
private _activity = new Map<string, string>();
|
|
599
|
+
private _activityEnabled = true;
|
|
443
600
|
private _live = new Set<string>();
|
|
444
601
|
private _currentName: string | null = null;
|
|
445
602
|
private _nextRank = 100;
|
|
@@ -468,10 +625,21 @@ export namespace SessionRegistry {
|
|
|
468
625
|
*/
|
|
469
626
|
agentSessions?: IAgentSessions | null;
|
|
470
627
|
/**
|
|
471
|
-
* Returns the
|
|
472
|
-
*
|
|
628
|
+
* Returns the names the server should look for when detecting running
|
|
629
|
+
* agents — each agent's command together with its canonical id, so a
|
|
630
|
+
* command pointed at an alias (e.g. `ccm` running `claude`) is still
|
|
631
|
+
* matched by the process it spawns. Read on every poll so it tracks the
|
|
632
|
+
* live agent list.
|
|
473
633
|
*/
|
|
474
634
|
detectCommands?: () => string[];
|
|
635
|
+
/**
|
|
636
|
+
* Whether a detected command (or id) belongs to a coding agent rather than
|
|
637
|
+
* an editor. Only agent sessions get a latest-activity line — editors
|
|
638
|
+
* (Neovim/Vim) are badged too, but run full-screen UIs whose buffer is not
|
|
639
|
+
* meaningfully "activity". Defaults to treating every detected command as an
|
|
640
|
+
* agent.
|
|
641
|
+
*/
|
|
642
|
+
isAgentCommand?: (command: string) => boolean;
|
|
475
643
|
}
|
|
476
644
|
}
|
|
477
645
|
|
|
@@ -494,4 +662,137 @@ namespace Private {
|
|
|
494
662
|
}
|
|
495
663
|
return true;
|
|
496
664
|
}
|
|
665
|
+
|
|
666
|
+
/** Rows above the cursor to scan when looking for the latest output. */
|
|
667
|
+
const ACTIVITY_SCAN_ROWS = 64;
|
|
668
|
+
|
|
669
|
+
/** Clamp for the activity string so one runaway block can't bloat a row. */
|
|
670
|
+
const ACTIVITY_MAX_LENGTH = 160;
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Horizontal box-drawing / rule characters, treated as blanks when
|
|
674
|
+
* sanitizing. Agents pad a status line or draw a separator with these (e.g.
|
|
675
|
+
* `Worked for 1m 06s ────`), which is chrome, not text. Vertical bars are
|
|
676
|
+
* deliberately excluded — {@link isMeaningfulActivity} relies on them to
|
|
677
|
+
* recognise (and skip) the contents of an input/quote box.
|
|
678
|
+
*/
|
|
679
|
+
const RULE_CHARS = new Set([
|
|
680
|
+
0x2500, 0x2501, 0x2504, 0x2505, 0x2508, 0x2509, 0x254c, 0x254d, 0x2550
|
|
681
|
+
]);
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* The most recent meaningful line of agent output, or `null` if none is
|
|
685
|
+
* found. Agents park the cursor in an input box at the bottom of the screen,
|
|
686
|
+
* so the scan starts there and walks *up*, skipping that box, any hint or
|
|
687
|
+
* footer beneath it, blank lines and separators, until it reaches the block
|
|
688
|
+
* of real output nearest the input. It then rewinds to that block's first row
|
|
689
|
+
* and stitches the block back together (see {@link joinBlock}), so a reply
|
|
690
|
+
* the agent hard-wrapped across several rows reads as its opening sentence
|
|
691
|
+
* rather than the trailing fragment left next to the cursor.
|
|
692
|
+
*/
|
|
693
|
+
export function readActivity(term: IXtermTerminal): string | null {
|
|
694
|
+
const buffer = term.buffer?.active;
|
|
695
|
+
if (!buffer) {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
const cursorRow = buffer.baseY + buffer.cursorY;
|
|
699
|
+
const limit = Math.max(0, cursorRow - ACTIVITY_SCAN_ROWS);
|
|
700
|
+
for (let row = cursorRow; row >= limit; row--) {
|
|
701
|
+
if (!isMeaningfulActivity(lineText(buffer, row))) {
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
// `row` is the bottom of the output block nearest the input; walk up
|
|
705
|
+
// while rows stay meaningful to find the block's first row.
|
|
706
|
+
let topRow = row;
|
|
707
|
+
while (
|
|
708
|
+
topRow > limit &&
|
|
709
|
+
isMeaningfulActivity(lineText(buffer, topRow - 1))
|
|
710
|
+
) {
|
|
711
|
+
topRow--;
|
|
712
|
+
}
|
|
713
|
+
return clampActivity(joinBlock(buffer, topRow, row));
|
|
714
|
+
}
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Sanitized text of one buffer row. */
|
|
719
|
+
export function lineText(buffer: IXtermBuffer, row: number): string {
|
|
720
|
+
return sanitizeActivity(buffer.getLine(row)?.translateToString(true) ?? '');
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Stitch rows `[topRow, lastRow]` of one output block into a single string,
|
|
725
|
+
* stopping once it is long enough to fill the row. A soft-wrapped row
|
|
726
|
+
* continues the previous one mid-word, so it is appended directly; a hard
|
|
727
|
+
* newline is a word boundary, so it is joined with a space.
|
|
728
|
+
*/
|
|
729
|
+
export function joinBlock(
|
|
730
|
+
buffer: IXtermBuffer,
|
|
731
|
+
topRow: number,
|
|
732
|
+
lastRow: number
|
|
733
|
+
): string {
|
|
734
|
+
let result = lineText(buffer, topRow);
|
|
735
|
+
for (let row = topRow + 1; row <= lastRow; row++) {
|
|
736
|
+
if (Array.from(result).length >= ACTIVITY_MAX_LENGTH) {
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
const bufferLine = buffer.getLine(row);
|
|
740
|
+
const text = sanitizeActivity(bufferLine?.translateToString(true) ?? '');
|
|
741
|
+
if (!text) {
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
result += bufferLine?.isWrapped ? text : ` ${text}`;
|
|
745
|
+
}
|
|
746
|
+
return result;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Truncate by code point (never splitting an emoji) with an ellipsis. */
|
|
750
|
+
export function clampActivity(text: string): string {
|
|
751
|
+
const points = Array.from(text);
|
|
752
|
+
return points.length > ACTIVITY_MAX_LENGTH
|
|
753
|
+
? `${points.slice(0, ACTIVITY_MAX_LENGTH - 1).join('')}…`
|
|
754
|
+
: text;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Collapse a raw buffer row into displayable text: replace control characters
|
|
759
|
+
* and horizontal rule characters with spaces (so no escape sequence or drawn
|
|
760
|
+
* separator rides along), squeeze runs of whitespace, and trim.
|
|
761
|
+
*/
|
|
762
|
+
export function sanitizeActivity(value: string): string {
|
|
763
|
+
let result = '';
|
|
764
|
+
for (const char of value) {
|
|
765
|
+
const code = char.codePointAt(0) ?? 0;
|
|
766
|
+
const isControl = code < 0x20 || (code >= 0x7f && code <= 0x9f);
|
|
767
|
+
result += isControl || RULE_CHARS.has(code) ? ' ' : char;
|
|
768
|
+
}
|
|
769
|
+
return result.replace(/\s+/g, ' ').trim();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Whether a sanitized row is worth showing as activity, i.e. real output
|
|
774
|
+
* rather than chrome. Doubles as the block-boundary test for
|
|
775
|
+
* {@link readActivity}'s walk up. A row is skipped when it:
|
|
776
|
+
* - is blank or pure box-drawing (carries no letter or digit);
|
|
777
|
+
* - begins with a vertical box-drawing bar (the contents of an input or
|
|
778
|
+
* quote box), a prompt chevron (the input row and its ghost suggestion),
|
|
779
|
+
* or Claude's tool-result / tip marker `⎿`; or
|
|
780
|
+
* - is a transient status footer — one carrying a `for <time>` / `(<time>`
|
|
781
|
+
* elapsed-time stamp, as agents print while and after they work
|
|
782
|
+
* ("✻ Churned for 17s", "Working… (8s · …)", "— Worked for 1m 06s"). This
|
|
783
|
+
* carries no real content, so skipping it lets the scan reach the reply
|
|
784
|
+
* the agent wrote just above it.
|
|
785
|
+
*/
|
|
786
|
+
export function isMeaningfulActivity(text: string): boolean {
|
|
787
|
+
if (!text) {
|
|
788
|
+
return false;
|
|
789
|
+
}
|
|
790
|
+
if (/^[│┃║❯❮›‹⎿]/u.test(text)) {
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
if (/(?:for |\()\d+(?:\s?m\s?\d+)?s\b/u.test(text)) {
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
return /[\p{L}\p{N}]/u.test(text);
|
|
797
|
+
}
|
|
497
798
|
}
|
package/src/terminals/widget.tsx
CHANGED
|
@@ -27,7 +27,9 @@ export const RUNNING_TERMINALS_ID = 'xtralab-running-terminals';
|
|
|
27
27
|
* launcher *starts* sessions, this panel surfaces the ones already
|
|
28
28
|
* running so the user can jump back to a backgrounded agent — including
|
|
29
29
|
* sessions whose tab has been closed but which are still alive on the
|
|
30
|
-
* server.
|
|
30
|
+
* server. Each row is labelled with the session's title and, for a session
|
|
31
|
+
* running a coding agent, a smaller line below it showing the agent's latest
|
|
32
|
+
* line of output (from {@link SessionRegistry.activityFor}).
|
|
31
33
|
*
|
|
32
34
|
* Backed by {@link SessionRegistry}: a `UseSignal` re-renders the list
|
|
33
35
|
* whenever the registry emits `stateChanged`, so it tracks
|
|
@@ -199,6 +201,10 @@ function RunningTerminalsComponent(props: {
|
|
|
199
201
|
registry.agentCommandFor(name)
|
|
200
202
|
).react;
|
|
201
203
|
const isCurrent = name === currentName;
|
|
204
|
+
// The latest line of output from the session's agent, shown as a
|
|
205
|
+
// smaller line under the title; `null` for rows with nothing to
|
|
206
|
+
// surface (no agent running, or no open tab to read a live buffer).
|
|
207
|
+
const activity = registry.activityFor(name);
|
|
202
208
|
return (
|
|
203
209
|
<li
|
|
204
210
|
key={name}
|
|
@@ -216,9 +222,20 @@ function RunningTerminalsComponent(props: {
|
|
|
216
222
|
aria-label={tooltip}
|
|
217
223
|
aria-current={isCurrent || undefined}
|
|
218
224
|
>
|
|
219
|
-
<RowIcon
|
|
220
|
-
|
|
221
|
-
|
|
225
|
+
<RowIcon
|
|
226
|
+
tag="span"
|
|
227
|
+
className="jp-xtralab-Terminals-item-icon"
|
|
228
|
+
verticalAlign="middle"
|
|
229
|
+
/>
|
|
230
|
+
<span className="jp-xtralab-Terminals-item-text">
|
|
231
|
+
<span className="jp-xtralab-Terminals-item-label">
|
|
232
|
+
{label}
|
|
233
|
+
</span>
|
|
234
|
+
{activity ? (
|
|
235
|
+
<span className="jp-xtralab-Terminals-item-detail">
|
|
236
|
+
{activity}
|
|
237
|
+
</span>
|
|
238
|
+
) : null}
|
|
222
239
|
</span>
|
|
223
240
|
</button>
|
|
224
241
|
<button
|