Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -1182,7 +1182,7 @@
"en": "Jupyter Notebook",
"zh-CN": "Jupyter Notebook"
},
"version": "0.1.0",
"version": "0.1.1",
"source": "./plugins/jupyter-notebook",
"description": "在对话侧栏打开 Jupyter Notebook:连接已有的 Jupyter Server 或为工作区启动一个,Agent 与用户一起编辑和运行 cell。",
"description_i18n": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/jupyter-notebook/.zcode-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "jupyter-notebook",
"version": "0.1.0",
"version": "0.1.1",
"description": "在对话侧栏打开 Jupyter Notebook:连接已有的 Jupyter Server 或为工作区启动一个,Agent 与用户一起编辑和运行 cell。",
"author": {
"name": "ZCode"
Expand Down
1 change: 1 addition & 0 deletions plugins/jupyter-notebook/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ You can also open the panel from the side pane "+" menu.
- Each cell says when the agent changed it or is running it; the margin shows whether a cell ran, is running, is queued or failed.
- A running cell's run button turns into a stop button inside a turning ring; queued cells show their place in line. During Run all the kernel status reads "Running 3 of 5", and if the running cell is out of view, a small bar at the bottom of the panel takes you to it.
- Outputs: text with ANSI colours, HTML (tables such as pandas DataFrames), images, SVG, Markdown, LaTeX and JSON. Errors show the exception and the failing line of your cell; the full traceback is one click away. HTML is sanitized and output scripts do not run, so interactive JavaScript outputs (for example Plotly) and ipywidgets show their static fallback.
- Streamed text preserves carriage-return overwrite positions between kernel messages, so partial progress updates do not append to the end of the previous line.
- Jupyter keyboard shortcuts: Shift+Enter / Ctrl+Enter / Alt+Enter run; Esc and Enter switch between command and edit mode; A / B insert, D D delete, M / Y / R change type, I I interrupt, 0 0 restart.
- Kernel menu: interrupt, restart, restart and run all, clear outputs, change kernel.
- **Reference in chat** (@) adds a cell to the agent's context; **Ask agent to fix** on a failing cell sends the error to the conversation.
Expand Down
1 change: 1 addition & 0 deletions plugins/jupyter-notebook/README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Git 市场安装包已包含编译好的 MCP server 和面板,无需自行构
- 每个 cell 会注明 Agent 何时修改过它、是否正在运行它;左侧显示 cell 已运行、运行中、排队中或出错。
- 运行中的 cell,运行按钮变成转动圆环里的停止键;排队的 cell 标出先后。全部运行时 kernel 状态显示“正在运行 3 / 5”;运行中的 cell 不在视野内时,面板底部的提示条可以带你过去。
- 输出:带 ANSI 颜色的文本、HTML(如 pandas DataFrame 表格)、图片、SVG、Markdown、LaTeX、JSON。错误先显示异常和你的 cell 中出错的那一行,完整 traceback 点一下即可展开。HTML 经过清洗,输出中的脚本不会执行,因此交互式 JavaScript 输出(如 Plotly)和 ipywidgets 显示其静态替代内容。
- 流式文本在 kernel 消息之间保留回车覆盖的写入位置,分段到达的进度更新不会错误追加到上一行末尾。
- Jupyter 快捷键:Shift+Enter / Ctrl+Enter / Alt+Enter 运行;Esc 与 Enter 切换命令 / 编辑模式;A / B 插入,D D 删除,M / Y / R 切换类型,I I 中断,0 0 重启。
- Kernel 菜单:中断、重启、重启并全部运行、清除输出、更换 kernel。
- **在对话中引用**(@)把 cell 加入 Agent 上下文;出错的 cell 上 **让 Agent 修复** 会把错误发到对话。
Expand Down
2 changes: 1 addition & 1 deletion ui-plugins/jupyter-notebook/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zcode/plugin-jupyter-notebook",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
14 changes: 12 additions & 2 deletions ui-plugins/jupyter-notebook/src/app/notebooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
normalizeNotebook,
serializeNotebook,
} from "#nb/domain/notebook.ts";
import { reduceOutput, updateDisplay } from "#nb/domain/outputs.ts";
import { reduceOutput, updateDisplay, type StreamCursor } from "#nb/domain/outputs.ts";
import { toServerPath } from "#nb/servers/registry.ts";

export interface ServerHandle {
Expand All @@ -47,6 +47,7 @@ interface OpenNotebook {
/** msg_id → cell id:每个 cell 只保留最近一次运行,迟到的后台输出也能归位。 */
runs: Map<string, string>;
clearPending: Set<string>;
streamCursors: Map<string, StreamCursor>;
/** 最近一次排队用的序号(CellRun.seq)。 */
runSeq: number;
saveTimer: ReturnType<typeof setTimeout> | null;
Expand Down Expand Up @@ -231,6 +232,7 @@ export function createNotebookOwner(deps: OwnerDeps) {
nb.pending = false;
nb.runs.clear();
nb.clearPending.clear();
nb.streamCursors.clear();
nb.doc.save = { state: "saved", savedAt: nb.doc.save.savedAt ?? now(), error: null };
const spec = nb.doc.metadata.kernelspec as { name?: string; display_name?: string; language?: string } | undefined;
if (!nb.session) {
Expand Down Expand Up @@ -337,12 +339,14 @@ export function createNotebookOwner(deps: OwnerDeps) {
return;
}
const state = reduceOutput(
{ outputs: cell.outputs, clearPending: nb.clearPending.has(cell.id) },
{ outputs: cell.outputs, clearPending: nb.clearPending.has(cell.id), stream: nb.streamCursors.get(cell.id) },
type,
message.content,
);
if (state.outputs === cell.outputs && state.clearPending === nb.clearPending.has(cell.id)) return;
cell.outputs = state.outputs;
if (state.stream) nb.streamCursors.set(cell.id, state.stream);
else nb.streamCursors.delete(cell.id);
if (state.clearPending) nb.clearPending.add(cell.id);
else nb.clearPending.delete(cell.id);
if (type === "execute_result" && typeof message.content.execution_count === "number")
Expand Down Expand Up @@ -559,6 +563,7 @@ export function createNotebookOwner(deps: OwnerDeps) {
kernelStarting: null,
runs: new Map(),
clearPending: new Set(),
streamCursors: new Map(),
runSeq: 0,
saveTimer: null,
saving: null,
Expand Down Expand Up @@ -610,6 +615,10 @@ export function createNotebookOwner(deps: OwnerDeps) {
const removed = new Set(nb.doc.cells.map((cell) => cell.id));
for (const cell of result.cells) removed.delete(cell.id);
nb.doc.cells = result.cells;
// 删除 / 清空输出 / 改 cell 类型后释放旧 stream;仅编辑 source 时保留有效光标。
const streams = new Map(result.cells.map((cell) => [cell.id, cell.outputs.at(-1)]));
for (const [cellId, stream] of nb.streamCursors)
if (streams.get(cellId) !== stream.output) nb.streamCursors.delete(cellId);
if (result.contentChanged) nb.doc.revision += 1;
for (const [msgId, cellId] of nb.runs) if (removed.has(cellId)) nb.runs.delete(msgId);
markPending(nb);
Expand Down Expand Up @@ -662,6 +671,7 @@ export function createNotebookOwner(deps: OwnerDeps) {
cell.outputs = [];
cell.executionCount = null;
nb.clearPending.delete(cell.id);
nb.streamCursors.delete(cell.id);
for (const [msgId, cellId] of nb.runs) if (cellId === cell.id) nb.runs.delete(msgId);
if (!cell.source.trim()) {
cell.run = null;
Expand Down
2 changes: 1 addition & 1 deletion ui-plugins/jupyter-notebook/src/contract.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const PLUGIN_NAME = "jupyter-notebook";
export const PLUGIN_VERSION = "0.1.0";
export const PLUGIN_VERSION = "0.1.1";
/** manifest `mcpServers` 键、`ui.surfaces[].id` 与资源命名空间保持一致。 */
export const SERVER_NAME = "jupyter-notebook";
export const SURFACE_ID = "jupyter-notebook";
Expand Down
48 changes: 47 additions & 1 deletion ui-plugins/jupyter-notebook/src/domain/outputs.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { Output } from "#nb/contract.ts";
import { LIMITS, type Output } from "#nb/contract.ts";
import { applyControlChars, capText, reduceOutput, updateDisplay, type OutputState } from "#nb/domain/outputs.ts";

const empty: OutputState = { outputs: [], clearPending: false };
Expand Down Expand Up @@ -30,6 +30,52 @@ describe("reduceOutput", () => {
expect((state.outputs[0] as { text: string }).text).toBe("100%\ndone\n");
});

it.each([
["Hello\rWo", "Wollo"],
["Hello\rWorld!\rX\nnext", "Xorld!\nnext"],
["Hello\rWo\brld\n", "Wrld\n"],
["first\r\nsecond\rXY", "first\nXYcond"],
])("renders every split of %j identically", (text, expected) => {
for (let split = 0; split <= text.length; split++) {
const state = run(
["stream", { text: text.slice(0, split) }],
["stream", { text: text.slice(split) }],
);
expect(state.outputs).toEqual([{ output_type: "stream", name: "stdout", text: expected }]);
}
expect(run(...Array.from(text, (char): [string, Record<string, unknown>] => ["stream", { text: char }])).outputs)
.toEqual([{ output_type: "stream", name: "stdout", text: expected }]);
});

it("does not reuse a cursor after another output or clearing", () => {
const first = run(["stream", { text: "Hello\rW" }]);
for (const message of [
["stream", { name: "stderr", text: "error" }],
["display_data", { data: { "text/plain": "display" } }],
["clear_output", { wait: false }],
["clear_output", { wait: true }],
] as [string, Record<string, unknown>][]) {
const state = reduceOutput(reduceOutput(first, ...message), "stream", { text: "o" });
expect(state.outputs.at(-1)).toEqual({ output_type: "stream", name: "stdout", text: "o" });
}
// A branch must not mutate the original state's continuation.
expect(reduceOutput(first, "stream", { text: "o" }).outputs[0])
.toEqual({ output_type: "stream", name: "stdout", text: "Wollo" });
});

it("keeps long stream output bounded while continuing at the retained head", () => {
const state = run(
["stream", { text: "a".repeat(LIMITS.streamChars + 100) + "\rW" }],
["stream", { text: "o" }],
);
const output = state.outputs[0] as { text: string };
expect(output.text.startsWith("Woaa")).toBe(true);
expect(output.text).toContain("characters omitted");
expect(output.text.length).toBeLessThan(LIMITS.streamChars + 100);
const overwritten = reduceOutput(state, "stream", { text: "\rX\bY" });
expect((overwritten.outputs[0] as { text: string }).text.startsWith("Yaaa")).toBe(true);
});

it("clears immediately, or on the next output with wait", () => {
expect(run(["stream", { name: "stdout", text: "a" }], ["clear_output", { wait: false }]).outputs).toEqual([]);
const waiting = run(["stream", { name: "stdout", text: "a" }], ["clear_output", { wait: true }]);
Expand Down
85 changes: 51 additions & 34 deletions ui-plugins/jupyter-notebook/src/domain/outputs.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,46 @@
import { LIMITS, type Output } from "#nb/contract.ts";
import { LIMITS, type Output, type StreamOutput } from "#nb/contract.ts";
import { normalizeBundle } from "#nb/domain/notebook.ts";

/** 一个 cell 运行中的输出状态。clearPending 对应 `clear_output(wait=True)`:下一个输出到达时才清空。 */
export interface OutputState {
outputs: Output[];
clearPending: boolean;
/** 仅内存:归一化文本不再包含回车,必须单独保留最后一个 stream 的写入位置。 */
stream?: StreamCursor;
}

/**
* 处理终端控制字符:`\r` 回到行首(进度条覆盖),`\b` 退格。
* 只对最后一个未结束的行生效时也正确,因为整段文本每次重算。
*/
export function applyControlChars(text: string): string {
if (!text.includes("\r") && !text.includes("\b")) return text;
const lines = text.split("\n");
return lines
.map((line, index) => {
const last = index === lines.length - 1;
// 行尾 `\r\n` 的 `\r` 无意义;未结束的最后一行保留结尾 `\r`,下一段输出会覆盖它。
const pendingReturn = last && line.endsWith("\r");
let value = line.endsWith("\r") ? line.slice(0, -1) : line;
if (value.includes("\r")) {
// 后写的片段覆盖行首对应长度的字符。
let current = "";
for (const segment of value.split("\r"))
current = segment + current.slice(segment.length);
value = current;
export interface StreamCursor {
output: StreamOutput;
cursor: number;
lineStart: number;
}

/** 只处理新到达的文本;光标以最后一个未结束的行内 UTF-16 位置记录。 */
function appendStream(previous: string, text: string, cursor: number, lineStart: number) {
let value = previous;
// oxlint-disable-next-line no-control-regex -- 终端控制字符需要逐个处理,普通文字按块覆盖。
for (const part of text.split(/([\r\n\x08])/)) {
if (part === "\r") cursor = lineStart;
else if (part === "\n") {
value += "\n";
cursor = value.length;
lineStart = cursor;
} else if (part === "\b") {
if (cursor > lineStart) {
value = value.slice(0, cursor - 1) + value.slice(cursor);
cursor -= 1;
}
// oxlint-disable-next-line no-control-regex -- ANSI / 终端控制字符是有意匹配的
while (value.includes("\b")) value = value.replace(/[^\x08]\x08|^\x08+/, "");
return pendingReturn ? `${value}\r` : value;
})
.join("\n");
} else if (part) {
value = value.slice(0, cursor) + part + value.slice(cursor + part.length);
cursor += part.length;
}
}
return { text: value, cursor, lineStart };
}

/** 处理终端控制字符:`\r` 回到行首(进度条覆盖),`\b` 删除光标前一字符。 */
export function applyControlChars(text: string): string {
return appendStream("", text, 0, 0).text;
}

/** 超长 stream 保留首尾。 */
Expand Down Expand Up @@ -60,15 +69,23 @@ export function reduceOutput(
const text = typeof content.text === "string" ? content.text : "";
const outputs = takeClear(state).slice();
const last = outputs.at(-1);
if (last?.output_type === "stream" && last.name === name) {
outputs[outputs.length - 1] = {
...last,
text: capText(applyControlChars(last.text + text)),
};
} else {
outputs.push({ output_type: "stream", name, text: capText(applyControlChars(text)) });
const previous = last?.output_type === "stream" && last.name === name ? last : undefined;
const continuation = previous && state.stream?.output === previous ? state.stream : undefined;
const next = appendStream(previous?.text ?? "", text, continuation?.cursor ?? previous?.text.length ?? 0,
continuation?.lineStart ?? (previous ? previous.text.lastIndexOf("\n") + 1 : 0));
const capped = capText(next.text);
// 截断仍只保留首尾;把光标映射到保留片段,不能把标记当作新输出再解释。
if (next.text.length > LIMITS.streamChars) {
const head = Math.floor(LIMITS.streamChars * 0.4);
const project = (position: number) => position <= head ? position
: Math.max(head + capped.length - LIMITS.streamChars, capped.length - (next.text.length - position));
next.cursor = project(next.cursor);
next.lineStart = project(next.lineStart);
}
return { outputs, clearPending: false };
const output: StreamOutput = { output_type: "stream", name, text: capped };
if (previous) outputs[outputs.length - 1] = output;
else outputs.push(output);
return { outputs, clearPending: false, stream: { output, cursor: next.cursor, lineStart: next.lineStart } };
}
case "display_data":
case "execute_result": {
Expand Down Expand Up @@ -103,7 +120,7 @@ export function reduceOutput(
clearPending: false,
};
case "clear_output":
return content.wait ? { outputs: state.outputs, clearPending: true } : { outputs: [], clearPending: false };
return content.wait ? { ...state, clearPending: true } : { outputs: [], clearPending: false };
default:
return state;
}
Expand Down
18 changes: 18 additions & 0 deletions ui-plugins/jupyter-notebook/test/jupyter.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,24 @@ describe("launched server", () => {
expect(disk.cells[0].source[0]).toBe("import sys\n");
});

it("keeps stream overwrite positions across real kernel messages and saves only notebook data", async () => {
await owner.open({ path: "work/stream-cursor.ipynb", create: true });
const { inserted } = owner.edit(null, [{
op: "insert",
source: "import sys, time\nfor part in ('Hello\\rW', 'o', '\\n'):\n sys.stdout.write(part)\n sys.stdout.flush()\n time.sleep(0.05)",
}]);
const id = inserted[0]!;
for (let run = 0; run < 2; run++) {
await owner.execute(null, { cellIds: [id] }, { wait: true, timeoutMs: 30_000 });
expect(text(await owner.read(), id)).toBe("Wollo\n");
}
await waitFor(async () => (await owner.read()).save.state === "saved");
const saved = JSON.parse(await readFile(join(workspace, "work/stream-cursor.ipynb"), "utf8"));
expect(saved.cells[0].outputs).toEqual([{ output_type: "stream", name: "stdout", text: ["Wollo\n"] }]);
// Keep the shared notebook selected for the remaining lifecycle tests.
await owner.open({ path: "work/a.ipynb" });
});

it("interrupts a running cell", async () => {
const { inserted } = owner.edit(null, [{ op: "insert", source: "import time\ntime.sleep(60)" }]);
const id = inserted[0]!;
Expand Down