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.
Files changed (159) hide show
  1. package/README.md +37 -231
  2. package/lib/agentSessions.d.ts +6 -2
  3. package/lib/askAgent/editorSelection.d.ts +53 -0
  4. package/lib/askAgent/editorSelection.js +95 -0
  5. package/lib/askAgent/icons.d.ts +7 -0
  6. package/lib/askAgent/icons.js +15 -0
  7. package/lib/askAgent/index.d.ts +32 -0
  8. package/lib/askAgent/index.js +741 -0
  9. package/lib/askAgent/popup.d.ts +83 -0
  10. package/lib/askAgent/popup.js +221 -0
  11. package/lib/askAgent/prompt.d.ts +24 -0
  12. package/lib/askAgent/prompt.js +116 -0
  13. package/lib/askAgent/queue.d.ts +93 -0
  14. package/lib/askAgent/queue.js +207 -0
  15. package/lib/askAgent/queuePanel.d.ts +76 -0
  16. package/lib/askAgent/queuePanel.js +188 -0
  17. package/lib/askAgent/targetPicker.d.ts +58 -0
  18. package/lib/askAgent/targetPicker.js +74 -0
  19. package/lib/askAgent/tokens.d.ts +124 -0
  20. package/lib/askAgent/tokens.js +13 -0
  21. package/lib/commandBar/index.d.ts +12 -8
  22. package/lib/commandBar/index.js +58 -41
  23. package/lib/editorBreadcrumbs/index.js +9 -4
  24. package/lib/editorBreadcrumbs/widget.d.ts +7 -7
  25. package/lib/editorBreadcrumbs/widget.js +6 -8
  26. package/lib/fileBrowser/commands.d.ts +2 -8
  27. package/lib/fileBrowser/commands.js +39 -30
  28. package/lib/fileBrowser/contents.d.ts +8 -3
  29. package/lib/fileBrowser/fileBrowser.d.ts +2 -1
  30. package/lib/fileBrowser/widget.d.ts +5 -9
  31. package/lib/fileBrowser/widget.js +1 -1
  32. package/lib/fileTypeIcons/index.js +5 -2
  33. package/lib/git/api.d.ts +0 -26
  34. package/lib/git/api.js +2 -44
  35. package/lib/git/askRequest.d.ts +23 -0
  36. package/lib/git/askRequest.js +90 -0
  37. package/lib/git/commands.d.ts +14 -2
  38. package/lib/git/commands.js +16 -13
  39. package/lib/git/diffModel.d.ts +1 -9
  40. package/lib/git/diffModel.js +1 -1
  41. package/lib/git/diffProvider.js +8 -3
  42. package/lib/git/diffSurface.d.ts +19 -2
  43. package/lib/git/diffSurface.js +64 -18
  44. package/lib/git/diffTheme.d.ts +0 -4
  45. package/lib/git/diffTheme.js +1 -1
  46. package/lib/git/diffWidget.d.ts +9 -0
  47. package/lib/git/diffWidget.js +38 -10
  48. package/lib/git/imageDiff.d.ts +17 -4
  49. package/lib/git/imageDiff.js +90 -30
  50. package/lib/git/index.js +7 -2
  51. package/lib/git/notebookDiff.d.ts +10 -67
  52. package/lib/git/notebookDiff.js +62 -69
  53. package/lib/git/tokens.d.ts +5 -2
  54. package/lib/highlight/index.js +12 -8
  55. package/lib/index.js +2 -0
  56. package/lib/launcher/agents.js +3 -4
  57. package/lib/launcher/commands.d.ts +6 -2
  58. package/lib/launcher/dashboard.d.ts +8 -1
  59. package/lib/launcher/dashboard.js +39 -33
  60. package/lib/launcher/editorRegistry.js +5 -3
  61. package/lib/launcher/editors.d.ts +15 -5
  62. package/lib/launcher/icons.d.ts +0 -37
  63. package/lib/launcher/icons.js +11 -12
  64. package/lib/launcher/index.js +2 -1
  65. package/lib/launcher/invocation.d.ts +0 -15
  66. package/lib/launcher/invocation.js +1 -1
  67. package/lib/launcher/schemaDefaults.d.ts +3 -1
  68. package/lib/launcher/schemaDefaults.js +3 -1
  69. package/lib/mimeData.d.ts +7 -0
  70. package/lib/mimeData.js +16 -0
  71. package/lib/omnibox/index.d.ts +4 -0
  72. package/lib/omnibox/index.js +43 -2
  73. package/lib/omnibox/model.d.ts +43 -14
  74. package/lib/omnibox/model.js +117 -14
  75. package/lib/omnibox/recents.d.ts +65 -0
  76. package/lib/omnibox/recents.js +109 -0
  77. package/lib/omnibox/tokens.d.ts +6 -2
  78. package/lib/omnibox/widget.d.ts +23 -6
  79. package/lib/omnibox/widget.js +26 -7
  80. package/lib/showOutput/index.js +15 -18
  81. package/lib/terminals/agentTerminals.d.ts +63 -0
  82. package/lib/terminals/agentTerminals.js +157 -0
  83. package/lib/terminals/index.d.ts +7 -1
  84. package/lib/terminals/index.js +50 -30
  85. package/lib/terminals/model.d.ts +11 -2
  86. package/lib/terminals/model.js +27 -8
  87. package/lib/terminals/tokens.d.ts +66 -0
  88. package/lib/terminals/tokens.js +7 -0
  89. package/lib/terminals/widget.d.ts +3 -6
  90. package/lib/terminals/widget.js +1 -1
  91. package/lib/topBar/index.js +3 -3
  92. package/lib/walkthrough/index.js +14 -3
  93. package/lib/walkthrough/panel.d.ts +12 -4
  94. package/lib/walkthrough/panel.js +9 -3
  95. package/package.json +49 -51
  96. package/schema/command-bar.json +10 -0
  97. package/schema/omnibox.json +15 -0
  98. package/src/agentSessions.ts +6 -2
  99. package/src/askAgent/editorSelection.ts +138 -0
  100. package/src/askAgent/icons.ts +16 -0
  101. package/src/askAgent/index.ts +915 -0
  102. package/src/askAgent/popup.tsx +407 -0
  103. package/src/askAgent/prompt.ts +144 -0
  104. package/src/askAgent/queue.ts +269 -0
  105. package/src/askAgent/queuePanel.tsx +418 -0
  106. package/src/askAgent/targetPicker.tsx +209 -0
  107. package/src/askAgent/tokens.ts +139 -0
  108. package/src/commandBar/index.ts +75 -49
  109. package/src/editorBreadcrumbs/index.ts +14 -4
  110. package/src/editorBreadcrumbs/widget.ts +11 -9
  111. package/src/fileBrowser/commands.ts +45 -31
  112. package/src/fileBrowser/contents.ts +7 -3
  113. package/src/fileBrowser/fileBrowser.tsx +1 -1
  114. package/src/fileBrowser/widget.tsx +5 -3
  115. package/src/fileTypeIcons/index.ts +6 -3
  116. package/src/git/api.ts +2 -61
  117. package/src/git/askRequest.ts +113 -0
  118. package/src/git/commands.ts +41 -14
  119. package/src/git/diffModel.ts +1 -1
  120. package/src/git/diffProvider.tsx +11 -3
  121. package/src/git/diffSurface.tsx +114 -22
  122. package/src/git/diffTheme.ts +1 -1
  123. package/src/git/diffWidget.tsx +54 -7
  124. package/src/git/imageDiff.tsx +169 -58
  125. package/src/git/index.ts +10 -2
  126. package/src/git/notebookDiff.tsx +91 -57
  127. package/src/git/tokens.ts +4 -2
  128. package/src/highlight/index.ts +12 -8
  129. package/src/index.ts +2 -0
  130. package/src/launcher/agents.ts +3 -4
  131. package/src/launcher/commands.ts +6 -2
  132. package/src/launcher/dashboard.tsx +86 -38
  133. package/src/launcher/editorRegistry.ts +10 -4
  134. package/src/launcher/editors.ts +15 -5
  135. package/src/launcher/icons.ts +11 -12
  136. package/src/launcher/index.ts +2 -1
  137. package/src/launcher/invocation.ts +1 -1
  138. package/src/launcher/schemaDefaults.ts +3 -1
  139. package/src/mimeData.ts +20 -0
  140. package/src/omnibox/index.ts +48 -1
  141. package/src/omnibox/model.ts +173 -33
  142. package/src/omnibox/recents.ts +155 -0
  143. package/src/omnibox/tokens.ts +6 -2
  144. package/src/omnibox/widget.tsx +54 -13
  145. package/src/showOutput/index.ts +20 -23
  146. package/src/terminals/agentTerminals.ts +238 -0
  147. package/src/terminals/index.ts +54 -30
  148. package/src/terminals/model.ts +28 -7
  149. package/src/terminals/tokens.ts +77 -0
  150. package/src/terminals/widget.tsx +4 -2
  151. package/src/topBar/index.ts +12 -6
  152. package/src/walkthrough/index.ts +19 -4
  153. package/src/walkthrough/panel.ts +15 -5
  154. package/style/askAgent.css +544 -0
  155. package/style/commandBar.css +12 -33
  156. package/style/git.css +72 -31
  157. package/style/index.css +1 -0
  158. package/style/index.js +2 -0
  159. 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
+ }
@@ -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<void> = {
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
- ): void => {
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
- // 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
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?.session;
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
- /** Read a boolean setting value, falling back when it is missing or invalid. */
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
  }
@@ -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?.session;
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`, which is exactly the regression we are guarding
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
- /** Rows above the cursor to scan when looking for the latest output. */
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
- /** Clamp for the activity string so one runaway block can't bloat a row. */
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
- /** Sanitized text of one buffer row. */
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
- /** Truncate by code point (never splitting an emoji) with an ellipsis. */
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
+ );
@@ -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
- export const RUNNING_TERMINALS_ID = 'xtralab-running-terminals';
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
- /** Shut the named session down on the server. */
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
@@ -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
- /** Stable widget id (required by `LabShell.add`). */
24
+ /**
25
+ * Stable widget id (required by `LabShell.add`).
26
+ */
25
27
  id: string;
26
- /** Command the button triggers and mirrors the state of. */
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
- /** Side-specific class, used by the stylesheet for placement. */
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
  }
@@ -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
- /** Append a step to the read-only walkthrough panel. */
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
- /** Pull a `{ mimeType, data }` media object out of a JSON command argument. */
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 { mimeType: string; data?: unknown };
31
- return { mimeType: media.mimeType, data: media.data as never };
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
  }