@nteract/pi 0.1.2 → 0.1.5

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 CHANGED
@@ -1,34 +1,53 @@
1
1
  # @nteract/pi
2
2
 
3
- Pi extensions for running Python through the local nteract `runtimed` daemon.
4
- The package provides a persistent notebook-backed REPL for coding agents and
5
- terminal workflows that need stateful Python execution.
6
-
7
- ## Extensions
8
-
9
- - `extensions/repl.ts` registers a persistent `python` tool backed by
10
- `@runtimed/node`.
11
- - `python` accepts an optional `dependencies` array. On first use, those
12
- packages are recorded before the kernel starts; on later calls they are
13
- hot-synced before executing the cell.
14
- - `python_add_dependencies` batch-records notebook dependencies and hot-syncs
15
- them into the environment using the direct Node binding.
16
- - `python_save_notebook` saves the backing notebook.
17
- - `/python-reset` shuts down the backing notebook room and starts fresh on the
18
- next `python` call.
3
+ **Persistent notebook-backed Python REPL for Pi coding agents.** Stateful execution, hot dependency sync, zero cold starts.
4
+
5
+ Run Python in a real IPython runtime. State (variables, imports, matplotlib figures) persists between agent turns. Perfect for data analysis, plotting, and multi-step workflows.
6
+
7
+ ## What you get
8
+
9
+ - **`python_repl`** — Execute Python in your persistent REPL. Backed by a real IPython runtime. Variables, imports, and state stick around between calls. The last expression is the result; use `print()` or `display()` for intermediate output. Images (matplotlib, PIL, widgets) are returned inline.
10
+
11
+ - **`python_add_dependencies`** Install packages mid-session without restarting the kernel. Or pass `dependencies` on the first `python_repl` call to pre-install before kernel start.
12
+
13
+ - **`python_save_notebook`** Save your session as an `.ipynb` file you can open in Jupyter or nteract.
14
+
15
+ - **`/python-reset`** Start fresh: new kernel, clean slate.
19
16
 
20
17
  ## Install
21
18
 
22
19
  ```bash
23
- pi install npm:@nteract/pi@next
20
+ pi install npm:@nteract/pi
24
21
  ```
25
22
 
26
- Use the `next` tag for prerelease builds until the package is promoted to the
27
- default npm tag.
23
+ ## How it works
24
+
25
+ This extension uses **`@runtimed/node`**, the Node.js bindings for the nteract daemon (the same runtime that powers the nteract desktop app). If you have nteract installed, you already have everything you need. The daemon manages isolated Jupyter kernels and environments per working directory, handles dependency installation via `uv`, and keeps your Python state hot between agent calls.
26
+
27
+ ## Building your own
28
+
29
+ This extension is a starting point. Want more control? Build your own Pi extensions using `@runtimed/node`.
30
+
31
+ See [`packages/runtimed-node/README.md`](../../packages/runtimed-node/README.md) for the full API. The source for this extension ([`extensions/repl.ts`](./extensions/repl.ts)) shows how to wire it up to Pi's tool registration.
32
+
33
+ ### Inspecting and managing sessions
34
+
35
+ The daemon is controlled by the `runt` CLI (installed with nteract). Use it to inspect active sessions, open notebooks in the desktop app, or troubleshoot:
36
+
37
+ ```bash
38
+ # List active Python sessions
39
+ runt list
40
+
41
+ # Open a session in nteract Desktop
42
+ runt show <notebook-id>
43
+
44
+ # Check daemon status
45
+ runt daemon status
46
+ ```
28
47
 
29
- ## Local Use
48
+ ## Local development
30
49
 
31
- From this checkout:
50
+ From this repo:
32
51
 
33
52
  ```bash
34
53
  pi --extension ./plugins/nteract/pi/extensions/repl.ts
@@ -7,7 +7,11 @@
7
7
  *
8
8
  * Config (env vars):
9
9
  * NTERACT_RUNTIMED_NODE_PATH override the runtimed-node package path.
10
- * NTERACT_SOCKET_PATH override the daemon socket path.
10
+ *
11
+ * Daemon socket selection follows the runtimed-node contract:
12
+ * `RUNTIMED_SOCKET_PATH` overrides outright, `RUNTIMED_WORKSPACE_PATH` (or
13
+ * `RUNTIMED_DEV=1` plus a git checkout) selects the per-worktree dev daemon,
14
+ * otherwise the running channel is auto-detected.
11
15
  *
12
16
  * After editing, run `/reload` in pi.
13
17
  */
@@ -25,7 +29,6 @@ import { Type } from "typebox";
25
29
 
26
30
  type RuntimedNode = {
27
31
  defaultSocketPath(): string;
28
- socketPathForChannel(channel: "stable" | "nightly"): string;
29
32
  PackageManager?: { Uv: "uv"; Conda: "conda"; Pixi: "pixi" };
30
33
  createNotebook(opts?: {
31
34
  runtime?: string;
@@ -72,16 +75,30 @@ type JsOutput = {
72
75
 
73
76
  type CellResult = {
74
77
  cellId: string;
78
+ executionId: string;
75
79
  executionCount?: number;
76
80
  status: string; // "done" | "error" | "timeout" | "kernel_error"
77
81
  success: boolean;
78
- outputs: JsOutput[];
82
+ outputs?: JsOutput[];
83
+ };
84
+
85
+ type QueuedExecution = {
86
+ cellId: string;
87
+ executionId: string;
79
88
  };
80
89
 
81
90
  type Session = {
82
91
  readonly notebookId: string;
83
- runCell(source: string, opts?: { timeoutMs?: number; cellType?: string }): Promise<CellResult>;
84
- addUvDependency(pkg: string): Promise<void>;
92
+ runCell(
93
+ source: string,
94
+ opts?: { timeoutMs?: number; cellType?: string; onUpdate?: (progress: CellResult) => void },
95
+ ): Promise<CellResult>;
96
+ queueCell(source: string, opts?: { cellType?: string }): Promise<QueuedExecution>;
97
+ waitForExecution(
98
+ executionId: string,
99
+ opts?: { timeoutMs?: number; cellId?: string; onUpdate?: (progress: CellResult) => void },
100
+ ): Promise<CellResult>;
101
+ addUvDependency?(pkg: string): Promise<void>;
85
102
  addDependencies?(
86
103
  packages: string[],
87
104
  opts?: { packageManager?: "uv" | "conda" | "pixi" },
@@ -121,15 +138,42 @@ function loadRuntimedNode(): RuntimedNode | null {
121
138
  return null;
122
139
  }
123
140
 
124
- function resolveSocketPath(rn: RuntimedNode): string {
125
- if (process.env.NTERACT_SOCKET_PATH) return process.env.NTERACT_SOCKET_PATH;
141
+ // --- bootstrap detection -----------------------------------------------------
126
142
 
127
- for (const channel of ["nightly", "stable"] as const) {
128
- const socketPath = rn.socketPathForChannel(channel);
129
- if (existsSync(socketPath)) return socketPath;
143
+ const INSTALL_HINT =
144
+ "The nteract daemon (runtimed) isn't installed yet.\n" +
145
+ "Quick install: curl -fsSL https://sh.nteract.io | bash\n" +
146
+ "Or visit: https://nteract.io";
147
+
148
+ type BootstrapStatus =
149
+ | { kind: "ready"; rn: RuntimedNode }
150
+ | { kind: "missing"; reason: "binding" | "daemon" };
151
+
152
+ function findOnPath(cmd: string): string | undefined {
153
+ const dirs = (process.env.PATH ?? "").split(path.delimiter);
154
+ for (const dir of dirs) {
155
+ if (!dir) continue;
156
+ const candidate = path.join(dir, cmd);
157
+ if (existsSync(candidate)) return candidate;
130
158
  }
159
+ return undefined;
160
+ }
131
161
 
132
- return rn.defaultSocketPath();
162
+ function detectBootstrap(): BootstrapStatus {
163
+ const rn = loadRuntimedNode();
164
+ if (!rn) return { kind: "missing", reason: "binding" };
165
+ // defaultSocketPath() already honors RUNTIMED_SOCKET_PATH and the dev /
166
+ // worktree-aware resolution from runt-workspace, so this is the single point
167
+ // of truth. The socket file only exists while the daemon is running, but the
168
+ // parent cache dir survives shutdowns, so dir-existence covers "installed
169
+ // but stopped".
170
+ const socket = rn.defaultSocketPath();
171
+ const installed =
172
+ existsSync(socket) ||
173
+ existsSync(path.dirname(socket)) ||
174
+ Boolean(findOnPath("runt")) ||
175
+ Boolean(findOnPath("runt-nightly"));
176
+ return installed ? { kind: "ready", rn } : { kind: "missing", reason: "daemon" };
133
177
  }
134
178
 
135
179
  // --- DataTable TUI component --------------------------------------------------
@@ -164,6 +208,63 @@ function formatStat(stats: ColumnStats | null): string {
164
208
  }
165
209
  }
166
210
 
211
+ const SPARK_CHARS = "▁▂▃▄▅▆▇█";
212
+
213
+ function sparkline(values: number[], width: number): string {
214
+ if (values.length === 0 || width <= 0) return "";
215
+ const min = Math.min(...values);
216
+ const max = Math.max(...values);
217
+ const bins = new Array(Math.min(width, 8)).fill(0);
218
+ if (min === max) {
219
+ return SPARK_CHARS[3].repeat(bins.length);
220
+ }
221
+ const range = max - min;
222
+ for (const v of values) {
223
+ const bi = Math.min(Math.floor(((v - min) / range) * bins.length), bins.length - 1);
224
+ bins[bi]++;
225
+ }
226
+ const maxCount = Math.max(...bins);
227
+ return bins
228
+ .map(
229
+ (c) =>
230
+ SPARK_CHARS[maxCount === 0 ? 0 : Math.round((c / maxCount) * (SPARK_CHARS.length - 1))],
231
+ )
232
+ .join("");
233
+ }
234
+
235
+ function sparklineForColumn(
236
+ rows: string[][],
237
+ ci: number,
238
+ stats: ColumnStats | null,
239
+ colType: string,
240
+ width: number,
241
+ ): string {
242
+ if (stats?.kind === "boolean") {
243
+ const t = stats.true_count ?? 0;
244
+ const f = stats.false_count ?? 0;
245
+ const total = t + f;
246
+ if (total === 0) return "";
247
+ const barW = Math.min(width, 8);
248
+ const filled = Math.round((t / total) * barW);
249
+ return (
250
+ SPARK_CHARS[SPARK_CHARS.length - 1].repeat(filled) + SPARK_CHARS[0].repeat(barW - filled)
251
+ );
252
+ }
253
+ if (stats?.kind === "numeric" || /int|float|decimal|uint/.test(colType)) {
254
+ const nums: number[] = [];
255
+ for (const row of rows) {
256
+ const n = parseFloat(row[ci]);
257
+ if (!isNaN(n)) nums.push(n);
258
+ }
259
+ return sparkline(nums, width);
260
+ }
261
+ if (stats?.kind === "string" && stats.top && stats.top.length > 0) {
262
+ const counts = stats.top.map(([, c]) => c);
263
+ return sparkline(counts, Math.min(width, counts.length));
264
+ }
265
+ return "";
266
+ }
267
+
167
268
  class DataTable {
168
269
  private columns: string[];
169
270
  private rows: string[][];
@@ -205,11 +306,12 @@ class DataTable {
205
306
  }
206
307
 
207
308
  const t = this.theme;
208
- const numCols = this.columns.length;
209
309
  const numRows = this.rows.length;
310
+ const totalCols = this.columns.length;
311
+ const MIN_COL_W = 6;
210
312
 
211
- // Calculate column widths: max of header, type, stat, and data
212
- const colWidths = this.columns.map((col, ci) => {
313
+ // Calculate natural column widths: max of header, type, stat, and data
314
+ const naturalWidths = this.columns.map((col, ci) => {
213
315
  let w = col.length;
214
316
  w = Math.max(w, (this.colTypes[ci] ?? "").length);
215
317
  const statStr = formatStat(this.colStats[ci] ?? null);
@@ -220,14 +322,35 @@ class DataTable {
220
322
  return w;
221
323
  });
222
324
 
223
- // Clamp total width shrink columns proportionally if needed
224
- const borderOverhead = 1 + numCols * 3; // "│ " + " │ " * (n-1) + " │"
325
+ // Greedily fit columns left-to-right within the available width.
326
+ // Each column costs its width + 3 chars of border ("│ " + " │").
327
+ let budget = width - 1; // leading "│"
328
+ let visibleCount = 0;
329
+ for (let i = 0; i < totalCols; i++) {
330
+ const colCost = Math.max(naturalWidths[i], MIN_COL_W) + 3;
331
+ if (budget - colCost < 0 && visibleCount > 0) break;
332
+ budget -= colCost;
333
+ visibleCount++;
334
+ }
335
+ visibleCount = Math.max(1, visibleCount);
336
+ const hiddenCols = totalCols - visibleCount;
337
+
338
+ // Slice to visible columns
339
+ const columns = this.columns.slice(0, visibleCount);
340
+ const colTypes = this.colTypes.slice(0, visibleCount);
341
+ const colStats = this.colStats.slice(0, visibleCount);
342
+ const rows = this.rows.map((r) => r.slice(0, visibleCount));
343
+ const numCols = columns.length;
344
+ const colWidths = naturalWidths.slice(0, visibleCount);
345
+
346
+ // Shrink proportionally only if still over budget after column pruning
347
+ const borderOverhead = 1 + numCols * 3;
225
348
  const totalColWidth = colWidths.reduce((a, b) => a + b, 0);
226
349
  const availableForCols = width - borderOverhead;
227
350
  if (totalColWidth > availableForCols && availableForCols > numCols) {
228
351
  const ratio = availableForCols / totalColWidth;
229
352
  for (let i = 0; i < numCols; i++) {
230
- colWidths[i] = Math.max(3, Math.floor(colWidths[i] * ratio));
353
+ colWidths[i] = Math.max(MIN_COL_W, Math.floor(colWidths[i] * ratio));
231
354
  }
232
355
  }
233
356
 
@@ -242,7 +365,7 @@ class DataTable {
242
365
  };
243
366
 
244
367
  // Detect numeric columns for right-alignment
245
- const isNumeric = this.colTypes.map((dt) => /int|float|decimal|uint/.test(dt));
368
+ const isNumeric = colTypes.map((dt) => /int|float|decimal|uint/.test(dt));
246
369
 
247
370
  const align = (s: string, ci: number, w: number) => (isNumeric[ci] ? rpad(s, w) : pad(s, w));
248
371
 
@@ -253,21 +376,28 @@ class DataTable {
253
376
  lines.push(topBorder);
254
377
 
255
378
  // ── Column headers ──
256
- const headerCells = this.columns.map((col, ci) =>
257
- t.fg("accent", t.bold(pad(col, colWidths[ci]))),
258
- );
379
+ const headerCells = columns.map((col, ci) => t.fg("accent", t.bold(pad(col, colWidths[ci]))));
259
380
  lines.push(
260
381
  t.fg("muted", "│") + " " + headerCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
261
382
  );
262
383
 
263
384
  // ── Type row ──
264
- const typeCells = this.colTypes.map((dt, ci) => t.fg("dim", pad(dt, colWidths[ci])));
385
+ const typeCells = colTypes.map((dt, ci) => t.fg("dim", pad(dt, colWidths[ci])));
265
386
  lines.push(
266
387
  t.fg("muted", "│") + " " + typeCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
267
388
  );
268
389
 
269
- // ── Stats row (sparkline or range) ──
270
- const statCells = this.colStats.map((stats, ci) => {
390
+ // ── Sparkline row ──
391
+ const sparkCells = colStats.map((stats, ci) => {
392
+ const spark = sparklineForColumn(rows, ci, stats, colTypes[ci] ?? "", colWidths[ci]);
393
+ return t.fg("dim", pad(spark, colWidths[ci]));
394
+ });
395
+ lines.push(
396
+ t.fg("muted", "│") + " " + sparkCells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│"),
397
+ );
398
+
399
+ // ── Stats row ──
400
+ const statCells = colStats.map((stats, ci) => {
271
401
  const statStr = formatStat(stats);
272
402
  return t.fg("dim", pad(statStr, colWidths[ci]));
273
403
  });
@@ -281,7 +411,7 @@ class DataTable {
281
411
 
282
412
  // ── Data rows ──
283
413
  for (let r = 0; r < numRows; r++) {
284
- const row = this.rows[r];
414
+ const row = rows[r];
285
415
  const cells = row.map((v, ci) => align(v, ci, colWidths[ci]));
286
416
  const rowStr =
287
417
  t.fg("muted", "│") + " " + cells.join(t.fg("muted", " │ ")) + " " + t.fg("muted", "│");
@@ -293,8 +423,12 @@ class DataTable {
293
423
  lines.push(botBorder);
294
424
 
295
425
  // ── Footer info ──
296
- const showing = numRows < this.totalRows ? `showing ${numRows} of ` : "";
297
- const info = t.fg("dim", `${showing}${this.totalRows} rows × ${numCols} columns`);
426
+ const showingRows = numRows < this.totalRows ? `showing ${numRows} of ` : "";
427
+ const hiddenSuffix = hiddenCols > 0 ? ` (${hiddenCols} more columns)` : "";
428
+ const info = t.fg(
429
+ "dim",
430
+ `${showingRows}${this.totalRows} rows × ${totalCols} columns${hiddenSuffix}`,
431
+ );
298
432
  lines.push(info);
299
433
 
300
434
  // Indent all lines to align with Out[n]: prompt
@@ -320,16 +454,18 @@ function formatResult(result: CellResult): {
320
454
  content: (TextContent | ImageContent)[];
321
455
  isError: boolean;
322
456
  } {
457
+ const outputs = result.outputs ?? [];
323
458
  const isError =
324
459
  result.status === "error" ||
325
460
  result.status === "kernel_error" ||
326
- result.outputs.some((o) => o.outputType === "error");
461
+ result.status === "kernel_failed" ||
462
+ outputs.some((o) => o.outputType === "error");
327
463
 
328
464
  const parts: (TextContent | ImageContent)[] = [];
329
465
  const header = `cell ${result.cellId} [${result.executionCount ?? "?"}] ${result.status}`;
330
466
  const textChunks: string[] = [];
331
467
 
332
- for (const o of result.outputs) {
468
+ for (const o of outputs) {
333
469
  switch (o.outputType) {
334
470
  case "stream": {
335
471
  const prefix = o.name === "stderr" ? "[stderr] " : "";
@@ -397,8 +533,30 @@ function formatResult(result: CellResult): {
397
533
  return { content: parts, isError };
398
534
  }
399
535
 
536
+ function findParquetBlobPath(result: CellResult): string | undefined {
537
+ for (const o of result.outputs ?? []) {
538
+ if (!o.blobPathsJson) continue;
539
+ try {
540
+ const paths = JSON.parse(o.blobPathsJson);
541
+ const pqPath = paths["application/vnd.apache.parquet"];
542
+ if (typeof pqPath === "string") return pqPath;
543
+ } catch {}
544
+ }
545
+ return undefined;
546
+ }
547
+
400
548
  // --- extension ---------------------------------------------------------------
401
549
 
550
+ // Braille frames for the "waiting on first token" spinner shown in `In [*]:`
551
+ // before any input_json_delta arrives. Anthropic validates the JSON server-side,
552
+ // so the gap between toolcall_start and the first delta can be hundreds of ms.
553
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
554
+ const SPINNER_INTERVAL_MS = 80;
555
+
556
+ type InCallState = {
557
+ spinner?: { frame: number; timer: ReturnType<typeof setInterval> };
558
+ };
559
+
402
560
  const PYTHON_PARAMS = Type.Object({
403
561
  code: Type.String({
404
562
  description:
@@ -419,11 +577,34 @@ const PYTHON_PARAMS = Type.Object({
419
577
  });
420
578
 
421
579
  export default function nteractReplExtension(pi: ExtensionAPI) {
422
- const rn = loadRuntimedNode();
423
- if (!rn) {
424
- console.error("[nteract-repl] runtimed-node not loaded; python tool will not be registered.");
580
+ const bootstrap = detectBootstrap();
581
+ if (bootstrap.kind === "missing") {
582
+ // Surface a one-liner toast each session so the user sees the hint without
583
+ // having to invoke the tool first.
584
+ pi.on("session_start", async (_event, ctx) => {
585
+ ctx.ui.notify(INSTALL_HINT, "warning");
586
+ });
587
+ // Register python_repl with a stub execute() so the LLM can still advertise
588
+ // the capability and surface install guidance the moment it tries to run
589
+ // Python. Throwing routes the message into the tool-result channel that the
590
+ // model relays back to the user.
591
+ pi.registerTool({
592
+ name: "python_repl",
593
+ label: "Python REPL",
594
+ description:
595
+ "Execute Python in your persistent REPL. Requires the nteract daemon (runtimed). " +
596
+ "If unavailable, recommend the user install with `curl -fsSL https://sh.nteract.io | bash` " +
597
+ "or download the desktop app at https://nteract.io.",
598
+ promptSnippet:
599
+ "python_repl: requires the nteract daemon. Install with `curl -fsSL https://sh.nteract.io | bash` or via https://nteract.io.",
600
+ parameters: PYTHON_PARAMS,
601
+ async execute() {
602
+ throw new Error(INSTALL_HINT);
603
+ },
604
+ });
425
605
  return;
426
606
  }
607
+ const rn = bootstrap.rn;
427
608
 
428
609
  let session: Session | null = null;
429
610
  let opening: Promise<Session> | null = null;
@@ -433,11 +614,13 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
433
614
  const unique = Array.from(new Set(packages.map((pkg) => pkg.trim()).filter(Boolean)));
434
615
  if (!unique.length) return;
435
616
  if (sess.addDependencies) {
436
- await sess.addDependencies(unique, { packageManager: rn.PackageManager?.Uv ?? "uv" });
437
- } else {
617
+ await sess.addDependencies(unique);
618
+ } else if (sess.addUvDependency) {
438
619
  for (const pkg of unique) {
439
620
  await sess.addUvDependency(pkg);
440
621
  }
622
+ } else {
623
+ throw new Error("@runtimed/node Session does not support dependency edits");
441
624
  }
442
625
  await sess.syncEnvironment();
443
626
  }
@@ -456,14 +639,14 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
456
639
  return opened;
457
640
  }
458
641
  opening = (async () => {
459
- const socketPath = resolveSocketPath(rn);
642
+ // Omit socketPath so the binding resolves through defaultSocketPath(),
643
+ // which honors RUNTIMED_SOCKET_PATH / RUNTIMED_WORKSPACE_PATH and the
644
+ // channel auto-detect.
460
645
  session = await rn.createNotebook({
461
646
  runtime: "python",
462
- socketPath,
463
647
  peerLabel: "pi",
464
648
  description: "pi Python REPL",
465
649
  dependencies,
466
- packageManager: rn.PackageManager?.Uv ?? "uv",
467
650
  });
468
651
  return session;
469
652
  })();
@@ -474,29 +657,59 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
474
657
  }
475
658
  }
476
659
 
477
- pi.registerTool({
478
- name: "python",
479
- label: "Python (nteract)",
660
+ pi.registerTool<typeof PYTHON_PARAMS, unknown, InCallState>({
661
+ name: "python_repl",
662
+ label: "Python REPL",
480
663
  description:
481
- "Execute Python in a persistent nteract notebook session backed by the local runtimed daemon. State (variables, imports) persists across calls within this pi session. Stdout, the final expression value, and exceptions are returned as text. Images (matplotlib.show(), IPython.display.Image, etc.) are returned inline so you can see them.",
664
+ "Execute Python in your persistent REPL. Backed by a real IPython runtime. Variables, imports, and state stick around between calls. The last expression is the result; use print() or display() for intermediate output. Images (matplotlib, PIL, widgets) are returned inline.",
482
665
  promptSnippet:
483
- "python: run Python code against a persistent nteract kernel (state persists across calls; stdout + last-expr value + any images are returned).",
666
+ "python_repl: run Python in your persistent REPL (variables and imports persist; returns stdout + last expression + images).",
484
667
  promptGuidelines: [
485
- "Prefer `python` over `bash python -c ...` for anything stateful, multi-step, or data-heavy.",
486
- "State (imports, variables) persists across `python` calls in this session.",
487
- "Use print() for side-effect output; the last expression is echoed back as the result.",
488
- "Matplotlib / PIL images are returned inline you can iterate on plots by looking at them.",
489
- "If code needs imports that may be missing, pass `dependencies` on the same `python` call so first-use packages are recorded before kernel start when possible.",
490
- "Install additional packages with the `python_add_dependencies` tool (it uses the notebook environment and hot-reloads without restarting the kernel).",
668
+ "Use `python_repl` for data analysis, plotting, and multi-step workflows. State persists between calls in a real IPython runtime.",
669
+ "Variables and imports stick around. No need to re-import or redefine on every turn unless the user has reloaded the session.",
670
+ "The last expression is the result; use print() or display() for intermediate output.",
671
+ "Images (matplotlib, PIL, widgets) come back inline. The user sees them if their terminal supports graphics.",
672
+ "Pass `dependencies` on the first call to pre-install packages before the kernel starts.",
673
+ "Use `python_add_dependencies` to install packages mid-session without restarting the kernel.",
491
674
  ],
492
675
  parameters: PYTHON_PARAMS,
493
676
  renderCall(args, theme, _context) {
494
677
  const text =
495
678
  (_context.lastComponent as InstanceType<typeof Text> | undefined) ?? new Text("", 0, 0);
496
- const code = args?.code ?? "";
679
+ const code = (args?.code ?? "").replace(/^\n+/, "");
497
680
  const count = nextExecCount;
498
681
  const prompt = count != null ? `In [${count}]:` : "In [*]:";
499
682
  const promptStr = theme.fg("accent", theme.bold(prompt));
683
+
684
+ // Anthropic gates input_json_delta on server-side validation, so there is
685
+ // a visible pause between toolcall_start and the first token of `code`.
686
+ // Show a braille spinner in the prompt slot until either the first
687
+ // character streams in or the call resolves another way.
688
+ const state = _context.state as InCallState;
689
+ const waitingForFirstToken =
690
+ !code && !_context.executionStarted && !_context.argsComplete && !_context.isError;
691
+ if (waitingForFirstToken) {
692
+ if (!state.spinner) {
693
+ const timer = setInterval(() => {
694
+ const s = state.spinner;
695
+ if (!s) return;
696
+ s.frame = (s.frame + 1) % SPINNER_FRAMES.length;
697
+ _context.invalidate();
698
+ }, SPINNER_INTERVAL_MS);
699
+ // Don't keep the event loop alive on the spinner alone.
700
+ timer.unref?.();
701
+ state.spinner = { frame: 0, timer };
702
+ }
703
+ const frame = SPINNER_FRAMES[state.spinner.frame];
704
+ text.setText(`${promptStr} ${theme.fg("muted", frame)}`);
705
+ return text;
706
+ }
707
+
708
+ if (state.spinner) {
709
+ clearInterval(state.spinner.timer);
710
+ state.spinner = undefined;
711
+ }
712
+
500
713
  const lines = highlightCode(code, "python");
501
714
  // Indent continuation lines to align with first line after prompt
502
715
  const pad = " ".repeat(prompt.length + 1);
@@ -555,8 +768,14 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
555
768
  const text =
556
769
  (_context.lastComponent instanceof Text ? _context.lastComponent : undefined) ??
557
770
  new Text("", 0, 0);
558
- const tableLines = dt.render(200);
559
- text.setText(`${promptStr}\n${tableLines.join("\n")}`);
771
+ const termWidth = process.stdout.columns || 120;
772
+ const tableLines = dt.render(termWidth - indent);
773
+ // Put first table line on the Out[n]: line instead of below it
774
+ const indentStr = " ".repeat(indent);
775
+ if (tableLines.length > 0 && tableLines[0].startsWith(indentStr)) {
776
+ tableLines[0] = `${promptStr} ${tableLines[0].slice(indent)}`;
777
+ }
778
+ text.setText(tableLines.join("\n"));
560
779
  return text;
561
780
  }
562
781
  } catch {}
@@ -568,8 +787,8 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
568
787
  .map((c: any) => c.text)
569
788
  .join("\n");
570
789
 
571
- // Strip the "cell cell-xxx [n] status" header we put in the text content
572
- const body = textContent.replace(/^cell cell-[^\n]*\n?/, "").trim();
790
+ // Strip the "cell <id> [n] status" header we put in the text content
791
+ const body = textContent.replace(/^cell \S* \[[^\]]*\] \S*\n?/, "").trim();
573
792
 
574
793
  const text =
575
794
  (_context.lastComponent instanceof Text ? _context.lastComponent : undefined) ??
@@ -592,26 +811,31 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
592
811
  }
593
812
  return text;
594
813
  },
595
- async execute(_toolCallId, params, signal) {
814
+ async execute(_toolCallId, params, signal, onUpdate) {
596
815
  if (signal?.aborted) throw new Error("aborted");
597
816
  const sess = await ensureSession(params.dependencies ?? []);
598
817
  const timeoutSecs = Math.max(1, params.timeout_secs ?? 120);
599
818
  const result = await sess.runCell(params.code, {
600
819
  timeoutMs: Math.round(timeoutSecs * 1000),
820
+ onUpdate: (progress) => {
821
+ const { content, isError } = formatResult(progress);
822
+ onUpdate?.({
823
+ content,
824
+ details: {
825
+ notebook_id: sess.notebookId,
826
+ cell_id: progress.cellId,
827
+ execution_id: progress.executionId,
828
+ status: progress.status,
829
+ execution_count: progress.executionCount,
830
+ is_error: isError,
831
+ parquet_blob_path: findParquetBlobPath(progress),
832
+ streaming: true,
833
+ },
834
+ });
835
+ },
601
836
  });
602
837
  // Extract parquet blob path for human-side table rendering
603
- let parquetBlobPath: string | undefined;
604
- for (const o of result.outputs) {
605
- if (!o.blobPathsJson) continue;
606
- try {
607
- const paths = JSON.parse(o.blobPathsJson);
608
- const pqPath = paths["application/vnd.apache.parquet"];
609
- if (typeof pqPath === "string") {
610
- parquetBlobPath = pqPath;
611
- break;
612
- }
613
- } catch {}
614
- }
838
+ const parquetBlobPath = findParquetBlobPath(result);
615
839
 
616
840
  const { content, isError } = formatResult(result);
617
841
  return {
@@ -619,6 +843,7 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
619
843
  details: {
620
844
  notebook_id: sess.notebookId,
621
845
  cell_id: result.cellId,
846
+ execution_id: result.executionId,
622
847
  status: result.status,
623
848
  execution_count: result.executionCount,
624
849
  is_error: isError,
@@ -633,11 +858,11 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
633
858
 
634
859
  pi.registerTool({
635
860
  name: "python_add_dependencies",
636
- label: "Add Dependencies (nteract)",
861
+ label: "Add Dependencies",
637
862
  description:
638
- "Add packages to the current nteract notebook environment and hot-sync them so the running kernel can import them immediately. Accepts pip-style specs, e.g. 'matplotlib', 'numpy>=2', 'requests'.",
863
+ "Install packages into the running Python environment without restarting. Accepts pip-style specs like 'matplotlib', 'numpy>=2', 'requests'. The kernel stays hot.",
639
864
  promptSnippet:
640
- "python_add_dependencies: add packages to the persistent Python notebook environment (hot-installs without restarting the kernel).",
865
+ "python_add_dependencies: install packages into the running Python session (no restart needed).",
641
866
  parameters: Type.Object({
642
867
  packages: Type.Array(Type.String(), {
643
868
  description: "Package specs (e.g. ['matplotlib', 'pandas>=2']).",
@@ -664,10 +889,10 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
664
889
 
665
890
  pi.registerTool({
666
891
  name: "python_save_notebook",
667
- label: "Save Notebook (nteract)",
892
+ label: "Save Notebook",
668
893
  description:
669
- "Save the current nteract notebook to an .ipynb file on disk. If no path is given, saves to the original location (if it was opened from a file). Provide a path to save to a new location.",
670
- promptSnippet: "python_save_notebook: save the current notebook session to an .ipynb file.",
894
+ "Save the current Python session as an .ipynb file. If no path is given, saves to the original location (if it was opened from a file). Provide a path to save elsewhere.",
895
+ promptSnippet: "python_save_notebook: save the current session as an .ipynb file.",
671
896
  parameters: Type.Object({
672
897
  path: Type.Optional(
673
898
  Type.String({
@@ -690,7 +915,7 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
690
915
 
691
916
  pi.registerCommand("python-reset", {
692
917
  description:
693
- "Start a fresh nteract Python notebook session (next /python call opens a new kernel)",
918
+ "Start fresh: next /python_repl call opens a new kernel (clean slate, no prior variables or imports)",
694
919
  handler: async (_args, ctx) => {
695
920
  const old = session;
696
921
  session = null;
@@ -705,7 +930,7 @@ export default function nteractReplExtension(pi: ExtensionAPI) {
705
930
  } catch {}
706
931
  }
707
932
  ctx.ui.notify(
708
- "nteract REPL: session closed; next python call will start a fresh kernel.",
933
+ "Python session closed. Next python_repl call will start a fresh kernel.",
709
934
  "info",
710
935
  );
711
936
  },
package/icon.png ADDED
Binary file
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "@nteract/pi",
3
- "version": "0.1.2",
4
- "description": "Pi extensions that run Python through the local nteract runtimed daemon.",
3
+ "version": "0.1.5",
4
+ "description": "Persistent notebook-backed Python REPL for Pi coding agents. Stateful execution, hot dependency sync, zero cold starts.",
5
+ "author": "nteract contributors",
6
+ "icon": "icon.png",
5
7
  "type": "module",
6
8
  "license": "BSD-3-Clause",
7
9
  "repository": {
@@ -30,10 +32,11 @@
30
32
  },
31
33
  "files": [
32
34
  "README.md",
35
+ "icon.png",
33
36
  "extensions"
34
37
  ],
35
38
  "dependencies": {
36
- "@runtimed/node": "0.2.0"
39
+ "@runtimed/node": "0.2.3"
37
40
  },
38
41
  "peerDependencies": {
39
42
  "@mariozechner/pi-coding-agent": "*",
@@ -43,6 +46,10 @@
43
46
  "publishConfig": {
44
47
  "access": "public"
45
48
  },
49
+ "devDependencies": {
50
+ "@mariozechner/pi-coding-agent": "^0.73.0",
51
+ "@mariozechner/pi-tui": "^0.73.0"
52
+ },
46
53
  "scripts": {
47
54
  "pack:dry-run": "pnpm pack --dry-run"
48
55
  }