xtralab 0.9.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.
Files changed (75) hide show
  1. package/README.md +40 -9
  2. package/lib/commandBar/index.d.ts +14 -0
  3. package/lib/commandBar/index.js +114 -0
  4. package/lib/fileBrowser/fileBrowser.js +6 -1
  5. package/lib/highlight/index.d.ts +15 -0
  6. package/lib/highlight/index.js +214 -0
  7. package/lib/index.js +15 -1
  8. package/lib/launcher/agents.d.ts +7 -0
  9. package/lib/launcher/agents.js +24 -0
  10. package/lib/launcher/editorRegistry.js +5 -3
  11. package/lib/launcher/editors.d.ts +8 -0
  12. package/lib/launcher/editors.js +17 -0
  13. package/lib/launcher/index.js +4 -0
  14. package/lib/launcher/schemaDefaults.d.ts +15 -0
  15. package/lib/launcher/schemaDefaults.js +44 -0
  16. package/lib/omnibox/files.d.ts +7 -0
  17. package/lib/omnibox/files.js +50 -0
  18. package/lib/omnibox/index.d.ts +14 -0
  19. package/lib/omnibox/index.js +83 -0
  20. package/lib/omnibox/model.d.ts +45 -0
  21. package/lib/omnibox/model.js +142 -0
  22. package/lib/omnibox/tokens.d.ts +23 -0
  23. package/lib/omnibox/tokens.js +11 -0
  24. package/lib/omnibox/widget.d.ts +36 -0
  25. package/lib/omnibox/widget.js +161 -0
  26. package/lib/searchReplace/index.d.ts +25 -0
  27. package/lib/searchReplace/index.js +68 -0
  28. package/lib/showOutput/index.d.ts +17 -0
  29. package/lib/showOutput/index.js +154 -0
  30. package/lib/terminalNotifications/index.d.ts +15 -0
  31. package/lib/terminalNotifications/index.js +213 -0
  32. package/lib/terminals/index.d.ts +4 -0
  33. package/lib/terminals/index.js +65 -16
  34. package/lib/terminals/model.d.ts +56 -3
  35. package/lib/terminals/model.js +261 -5
  36. package/lib/terminals/widget.d.ts +3 -1
  37. package/lib/terminals/widget.js +11 -3
  38. package/lib/walkthrough/index.d.ts +14 -0
  39. package/lib/walkthrough/index.js +162 -0
  40. package/lib/walkthrough/panel.d.ts +54 -0
  41. package/lib/walkthrough/panel.js +98 -0
  42. package/package.json +1 -1
  43. package/schema/launcher.json +3 -2
  44. package/schema/terminal-notifications.json +21 -0
  45. package/schema/terminals.json +15 -0
  46. package/src/commandBar/index.ts +165 -0
  47. package/src/fileBrowser/fileBrowser.tsx +8 -1
  48. package/src/highlight/index.ts +251 -0
  49. package/src/index.ts +15 -1
  50. package/src/launcher/agents.ts +25 -0
  51. package/src/launcher/editorRegistry.ts +8 -4
  52. package/src/launcher/editors.ts +18 -0
  53. package/src/launcher/index.ts +4 -0
  54. package/src/launcher/schemaDefaults.ts +53 -0
  55. package/src/omnibox/files.ts +68 -0
  56. package/src/omnibox/index.ts +105 -0
  57. package/src/omnibox/model.ts +210 -0
  58. package/src/omnibox/tokens.ts +29 -0
  59. package/src/omnibox/widget.tsx +322 -0
  60. package/src/searchReplace/index.ts +86 -0
  61. package/src/showOutput/index.ts +199 -0
  62. package/src/terminalNotifications/index.ts +295 -0
  63. package/src/terminals/index.ts +79 -16
  64. package/src/terminals/model.ts +304 -3
  65. package/src/terminals/widget.tsx +21 -4
  66. package/src/walkthrough/index.ts +202 -0
  67. package/src/walkthrough/panel.ts +152 -0
  68. package/style/commandBar.css +104 -0
  69. package/style/highlight.css +11 -0
  70. package/style/index.css +5 -0
  71. package/style/index.js +5 -0
  72. package/style/omnibox.css +120 -0
  73. package/style/showOutput.css +11 -0
  74. package/style/terminals.css +54 -10
  75. package/style/walkthrough.css +80 -0
@@ -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
- * The command of the agent running in the session, or `null` if none.
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 agent commands the server should look for when detecting
472
- * running agents. Read on every poll so it tracks the live agent list.
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
  }
@@ -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 tag="span" verticalAlign="middle" />
220
- <span className="jp-xtralab-Terminals-item-label">
221
- {label}
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
@@ -0,0 +1,202 @@
1
+ import {
2
+ ILabShell,
3
+ JupyterFrontEnd,
4
+ JupyterFrontEndPlugin
5
+ } from '@jupyterlab/application';
6
+ import { ICommandPalette } from '@jupyterlab/apputils';
7
+ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
8
+ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
9
+ import { tocIcon } from '@jupyterlab/ui-components';
10
+
11
+ import { IWalkthroughMedia, IWalkthroughStep, WalkthroughPanel } from './panel';
12
+
13
+ const PLUGIN_ID = 'xtralab:walkthrough';
14
+
15
+ /** Append a step to the read-only walkthrough panel. */
16
+ const WALKTHROUGH_COMMAND = 'xtralab:walkthrough';
17
+
18
+ const HIGHLIGHT_LINES_COMMAND = 'xtralab:highlight-lines';
19
+
20
+ const PANEL_ID = 'xtralab-walkthrough';
21
+
22
+ /** Pull a `{ mimeType, data }` media object out of a JSON command argument. */
23
+ function readMedia(value: unknown): IWalkthroughMedia | undefined {
24
+ if (
25
+ value &&
26
+ typeof value === 'object' &&
27
+ !Array.isArray(value) &&
28
+ typeof (value as { mimeType?: unknown }).mimeType === 'string'
29
+ ) {
30
+ const media = value as { mimeType: string; data?: unknown };
31
+ return { mimeType: media.mimeType, data: media.data as never };
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ function toLine(value: unknown): number | undefined {
37
+ const n = Math.trunc(Number(value));
38
+ return Number.isFinite(n) && n >= 1 ? n : undefined;
39
+ }
40
+
41
+ /**
42
+ * Contribute `xtralab:walkthrough`: build a persistent, read-only walkthrough
43
+ * in the right side area instead of narrating only in the agent's chat.
44
+ *
45
+ * Each call appends a step (Markdown prose, an optional embedded visual, and an
46
+ * optional code reference). When a step names a file, the editor follows along
47
+ * (opening it full-width in the main area, no split) and its lines are
48
+ * highlighted; the step also keeps a button so the user can jump back to it
49
+ * later. The panel accumulates the whole tour beside the code, so the user can
50
+ * read at their own pace rather than chasing the chat.
51
+ */
52
+ const plugin: JupyterFrontEndPlugin<void> = {
53
+ id: PLUGIN_ID,
54
+ description: 'Build a read-only guided walkthrough in the side panel.',
55
+ autoStart: true,
56
+ requires: [IRenderMimeRegistry, ILabShell],
57
+ optional: [ICommandPalette, ITranslator],
58
+ activate: (
59
+ app: JupyterFrontEnd,
60
+ rendermime: IRenderMimeRegistry,
61
+ labShell: ILabShell,
62
+ palette: ICommandPalette | null,
63
+ translator: ITranslator | null
64
+ ): void => {
65
+ const { commands } = app;
66
+ const trans = (translator ?? nullTranslator).load('jupyterlab');
67
+
68
+ let panel: WalkthroughPanel | null = null;
69
+
70
+ const ensurePanel = (): WalkthroughPanel => {
71
+ if (!panel || panel.isDisposed) {
72
+ panel = new WalkthroughPanel({ rendermime, commands, trans });
73
+ panel.id = PANEL_ID;
74
+ panel.title.icon = tocIcon;
75
+ panel.title.label = trans.__('Walkthrough');
76
+ panel.title.caption = trans.__('Walkthrough');
77
+ // Closable so it gets a close button if the user drags it out of the
78
+ // side area into the main document area. Closing disposes it; the next
79
+ // xtralab:walkthrough call recreates it back in the side area.
80
+ panel.title.closable = true;
81
+ labShell.add(panel, 'right', { rank: 1000, type: 'Walkthrough' });
82
+ const created = panel;
83
+ created.disposed.connect(() => {
84
+ if (panel === created) {
85
+ panel = null;
86
+ }
87
+ });
88
+ }
89
+ return panel;
90
+ };
91
+
92
+ commands.addCommand(WALKTHROUGH_COMMAND, {
93
+ label: trans.__('Add Walkthrough Step'),
94
+ caption: trans.__('Append a step to the read-only walkthrough panel'),
95
+ describedBy: {
96
+ args: {
97
+ type: 'object',
98
+ properties: {
99
+ title: {
100
+ type: 'string',
101
+ description: 'Optional heading for the step.'
102
+ },
103
+ body: {
104
+ type: 'string',
105
+ description:
106
+ 'Markdown for the step. Renders code snippets, lists, math, and Mermaid diagrams from a ```mermaid fenced block.'
107
+ },
108
+ path: {
109
+ type: 'string',
110
+ description:
111
+ 'Optional file the step is about, relative to the server root. The editor opens it (full width, no split) and highlights the lines, and the step keeps a button to jump back.'
112
+ },
113
+ line: {
114
+ type: 'number',
115
+ description: 'First line to highlight (1-indexed).'
116
+ },
117
+ endLine: {
118
+ type: 'number',
119
+ description: 'Last line to highlight (1-indexed, inclusive).'
120
+ },
121
+ media: {
122
+ type: 'object',
123
+ description:
124
+ 'Optional embedded visual, e.g. { "mimeType": "application/vnd.vegalite.v5+json", "data": <spec> }.',
125
+ properties: {
126
+ mimeType: { type: 'string' },
127
+ data: {}
128
+ },
129
+ required: ['mimeType', 'data']
130
+ },
131
+ reveal: {
132
+ type: 'boolean',
133
+ description:
134
+ 'Whether to navigate and highlight the code as the step is added. Defaults to true.'
135
+ },
136
+ reset: {
137
+ type: 'boolean',
138
+ description:
139
+ 'Clear the existing walkthrough before adding this step (start a new tour).'
140
+ }
141
+ }
142
+ }
143
+ },
144
+ execute: async args => {
145
+ const current = ensurePanel();
146
+ if (args['reset'] === true) {
147
+ current.clear();
148
+ }
149
+
150
+ const path =
151
+ typeof args['path'] === 'string' ? args['path'] : undefined;
152
+ const line = toLine(args['line']);
153
+ const endLine = toLine(args['endLine']);
154
+ const media = readMedia(args['media']);
155
+ const step: IWalkthroughStep = {
156
+ title: typeof args['title'] === 'string' ? args['title'] : undefined,
157
+ body: typeof args['body'] === 'string' ? args['body'] : undefined,
158
+ media,
159
+ path,
160
+ line,
161
+ endLine
162
+ };
163
+
164
+ if (
165
+ !step.title &&
166
+ !step.body &&
167
+ !step.media &&
168
+ !step.path &&
169
+ args['reset'] !== true
170
+ ) {
171
+ throw new Error(
172
+ 'xtralab:walkthrough needs at least one of: title, body, media, path'
173
+ );
174
+ }
175
+
176
+ // Reveal the panel, then render the step into it.
177
+ labShell.activateById(current.id);
178
+ await current.addStep(step);
179
+
180
+ // Let the editor follow the newest step.
181
+ if (path && args['reveal'] !== false) {
182
+ await commands.execute(HIGHLIGHT_LINES_COMMAND, {
183
+ path,
184
+ line,
185
+ endLine
186
+ });
187
+ }
188
+
189
+ return trans.__('Added walkthrough step');
190
+ }
191
+ });
192
+
193
+ if (palette) {
194
+ palette.addItem({
195
+ command: WALKTHROUGH_COMMAND,
196
+ category: trans.__('Other')
197
+ });
198
+ }
199
+ }
200
+ };
201
+
202
+ export default plugin;