From 619f88f61e39f3847de6425ba46959639b02d6f0 Mon Sep 17 00:00:00 2001 From: Ricardo-M-L <69202550+Ricardo-M-L@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:38:49 +0800 Subject: [PATCH] fix(jupyter-notebook): preserve stream overwrite cursors --- marketplace.json | 2 +- .../.zcode-plugin/plugin.json | 2 +- plugins/jupyter-notebook/README.md | 1 + plugins/jupyter-notebook/README_CN.md | 1 + ui-plugins/jupyter-notebook/package.json | 2 +- .../jupyter-notebook/src/app/notebooks.ts | 14 ++- ui-plugins/jupyter-notebook/src/contract.ts | 2 +- .../src/domain/outputs.test.ts | 48 ++++++++++- .../jupyter-notebook/src/domain/outputs.ts | 85 +++++++++++-------- .../test/jupyter.integration.test.ts | 18 ++++ 10 files changed, 134 insertions(+), 41 deletions(-) diff --git a/marketplace.json b/marketplace.json index 8afcd9c7..2a6594c3 100644 --- a/marketplace.json +++ b/marketplace.json @@ -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": { diff --git a/plugins/jupyter-notebook/.zcode-plugin/plugin.json b/plugins/jupyter-notebook/.zcode-plugin/plugin.json index 6c447944..c2c91e47 100644 --- a/plugins/jupyter-notebook/.zcode-plugin/plugin.json +++ b/plugins/jupyter-notebook/.zcode-plugin/plugin.json @@ -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" diff --git a/plugins/jupyter-notebook/README.md b/plugins/jupyter-notebook/README.md index 1a5107fc..ee05c37a 100644 --- a/plugins/jupyter-notebook/README.md +++ b/plugins/jupyter-notebook/README.md @@ -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. diff --git a/plugins/jupyter-notebook/README_CN.md b/plugins/jupyter-notebook/README_CN.md index e30b25bb..2c9f6276 100644 --- a/plugins/jupyter-notebook/README_CN.md +++ b/plugins/jupyter-notebook/README_CN.md @@ -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 修复** 会把错误发到对话。 diff --git a/ui-plugins/jupyter-notebook/package.json b/ui-plugins/jupyter-notebook/package.json index 41a3ffe0..1d8fd5ec 100644 --- a/ui-plugins/jupyter-notebook/package.json +++ b/ui-plugins/jupyter-notebook/package.json @@ -1,6 +1,6 @@ { "name": "@zcode/plugin-jupyter-notebook", - "version": "0.1.0", + "version": "0.1.1", "private": true, "license": "MIT", "type": "module", diff --git a/ui-plugins/jupyter-notebook/src/app/notebooks.ts b/ui-plugins/jupyter-notebook/src/app/notebooks.ts index 0b21a1ae..0799f47c 100644 --- a/ui-plugins/jupyter-notebook/src/app/notebooks.ts +++ b/ui-plugins/jupyter-notebook/src/app/notebooks.ts @@ -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 { @@ -47,6 +47,7 @@ interface OpenNotebook { /** msg_id → cell id:每个 cell 只保留最近一次运行,迟到的后台输出也能归位。 */ runs: Map; clearPending: Set; + streamCursors: Map; /** 最近一次排队用的序号(CellRun.seq)。 */ runSeq: number; saveTimer: ReturnType | null; @@ -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) { @@ -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") @@ -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, @@ -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); @@ -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; diff --git a/ui-plugins/jupyter-notebook/src/contract.ts b/ui-plugins/jupyter-notebook/src/contract.ts index 981fe935..7a66fdf1 100644 --- a/ui-plugins/jupyter-notebook/src/contract.ts +++ b/ui-plugins/jupyter-notebook/src/contract.ts @@ -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"; diff --git a/ui-plugins/jupyter-notebook/src/domain/outputs.test.ts b/ui-plugins/jupyter-notebook/src/domain/outputs.test.ts index e83f666d..ec48ed3f 100644 --- a/ui-plugins/jupyter-notebook/src/domain/outputs.test.ts +++ b/ui-plugins/jupyter-notebook/src/domain/outputs.test.ts @@ -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 }; @@ -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] => ["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][]) { + 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 }]); diff --git a/ui-plugins/jupyter-notebook/src/domain/outputs.ts b/ui-plugins/jupyter-notebook/src/domain/outputs.ts index 55577d2a..ce9e0f9a 100644 --- a/ui-plugins/jupyter-notebook/src/domain/outputs.ts +++ b/ui-plugins/jupyter-notebook/src/domain/outputs.ts @@ -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 保留首尾。 */ @@ -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": { @@ -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; } diff --git a/ui-plugins/jupyter-notebook/test/jupyter.integration.test.ts b/ui-plugins/jupyter-notebook/test/jupyter.integration.test.ts index 6a59c994..1fdcaae0 100644 --- a/ui-plugins/jupyter-notebook/test/jupyter.integration.test.ts +++ b/ui-plugins/jupyter-notebook/test/jupyter.integration.test.ts @@ -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]!;