From f04d99073a86ced6ec5cfa58caf813d63e8909aa Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Wed, 13 May 2026 15:02:58 -0700 Subject: [PATCH 1/4] Add notebook stdin UX design docs Signed-off-by: Jeremy lewi --- .../design/20260513_notebook_stdin_mocks.html | 491 ++++++++++++++++++ .../design/20260513_notebook_stdin_output.md | 402 ++++++++++++++ 2 files changed, 893 insertions(+) create mode 100644 docs-dev/design/20260513_notebook_stdin_mocks.html create mode 100644 docs-dev/design/20260513_notebook_stdin_output.md diff --git a/docs-dev/design/20260513_notebook_stdin_mocks.html b/docs-dev/design/20260513_notebook_stdin_mocks.html new file mode 100644 index 0000000..27f8f2d --- /dev/null +++ b/docs-dev/design/20260513_notebook_stdin_mocks.html @@ -0,0 +1,491 @@ + + + + + + Notebook stdin UX mocks + + + +
+
+
Mock Study
+

Notebook stdin UX directions

+

+ These mocks compare what upstream Jupyter does today with a more explicit + notebook-native input flow for Runme. All three keep stdin inside the cell + output area. The difference is how obvious the waiting state is and whether + input feels like a terminal or a form submission. +

+
+ +
+
+
+

Mock A: JupyterLab / Notebook 7 style

+

+ Inline stdin widget inside the output area. Prompt text and input live + together as part of cell output. +

+
+
+
+ Notebook 7 / JupyterLab + kernel: Python 3 +
+
+
In [12]:
+
+
name = input("Name: ")
+print(f"Hello {name}")
+
+
+
+ Name:  +
+
+ +
+
+
Name: Ada
+
Hello Ada
+
+
+
+
+
+

Why teams copy this

+
    +
  • It matches upstream behavior closely.
  • +
  • It keeps input in the transcript instead of in a separate terminal.
  • +
  • It works for ordinary `input()` prompts and debugger input.
  • +
+
+
+ +
+
+

Mock B: Runme proposed v1

+

+ More explicit waiting state. Prompt stays in the transcript, but the + editable input is a clearer submit box with a button. +

+
+
+
+ Runme notebook + runner: bash +
+
+
In [7]:
+
+
printf "Continue? [y/N] "
+read -r answer
+printf "\nanswer=%s\n" "$answer"
+
+
Continue? [y/N]
+
+
+ Waiting + Process is blocked on stdin. +
+
+ + +
+
+ One submit sends a single stdin write ending with a newline. +
+
+
+
+
+
+
+

Why this is stronger for Runme

+
    +
  • The waiting state is explicit instead of implied by cursor focus.
  • +
  • It avoids terminal-style key-by-key UX inside notebook cells.
  • +
  • It scales better to browser tests and automation.
  • +
+
+
+ +
+
+

Mock C: Password-safe variant

+

+ Same inline model, but with a masked field when the runtime can say + the prompt should not echo. +

+
+
+
+ Secret input + runner: shell +
+
+
In [19]:
+
+
sudo something-that-prompts
+ +
+
Xcode Command Line Tools is already installed. +Password:
+
+
+ Secure + Password input is masked and not echoed into output. +
+
+ + +
+
+ This needs an explicit backend signal. The UI should not guess. +
+
+
+
+
+
+
+

Boundary

+
    +
  • This is only viable if the runtime tells us `password=true` or `echo=false`.
  • +
  • Without that signal, notebook stdin should stay visible text only.
  • +
  • If we need raw TTY behavior, it belongs in a real terminal surface.
  • +
+
+
+
+ + +
+ + diff --git a/docs-dev/design/20260513_notebook_stdin_output.md b/docs-dev/design/20260513_notebook_stdin_output.md new file mode 100644 index 0000000..2d1e8e3 --- /dev/null +++ b/docs-dev/design/20260513_notebook_stdin_output.md @@ -0,0 +1,402 @@ +# 2026-05-13: Notebook stdin and Output UX + +## Status + +Draft proposal. + +## Summary + +Notebook code cells should not use raw terminal emulation as the primary UX +for interactive stdin. + +We will switch notebook cells to a document-style transcript plus an explicit +stdin composer. The user will type input into a dedicated field and submit it +as one write instead of sending every keystroke into a terminal widget. + +This fixes the main failure reported in +[#99](https://github.com/runmedev/web/issues/99): + +- prompts without a trailing newline are currently invisible while the process + waits for input +- notebook output can appear cut off because terminal rendering suppresses the + normal stdout/stderr view and applies terminal scrollback limits + +[#191](https://github.com/runmedev/web/pull/191) removed renderer callbacks +from notebook mutation paths. It did not change the notebook interaction model. +This document covers that follow-on work. It also overlaps with the broader +output-rendering cleanup tracked in +[#190](https://github.com/runmedev/web/issues/190). + +## Problem + +The current notebook cell experience mixes document rendering and terminal +emulation in a way that is hard to reason about and easy to break. + +Today: + +- `Actions.tsx` renders `CellConsole` when a cell has + `StatefulRunmeTerminal` output or an active stream +- `ActionOutputItems` suppresses normal stdout/stderr blocks when terminal + output is present +- `CellConsole` mounts `console-view` and forwards `terminal:stdin` + keystrokes to the runner one chunk at a time +- both `CellConsole` and `bindStreamsToCell(...)` buffer trailing stdout until + newline or process exit +- `CellConsole` sets terminal scrollback to `4000` + +That creates three concrete problems. + +### 1. Waiting-for-input is invisible + +The issue report includes a prompt like: + +```text +Xcode Command Line Tools is already installed. +Password: +``` + +`Password:` does not end with a newline. The current buffering logic keeps that +text in a pending buffer and does not render it until a newline arrives or the +process exits. If the process is waiting on stdin, the user sees no prompt and +cannot tell why execution stopped. + +This is not a small rendering detail. It breaks the basic interaction loop for +stdin-driven execution. + +### 2. Notebook output can appear cut off + +Notebook output is supposed to be part of the document. Terminal scrollback is +not. + +The current terminal path can hide or drop visible output in several ways: + +- stdout/stderr document rendering is suppressed when terminal output is + present +- `console-view` has a scrollback cap of `4000` +- terminal state is imperative UI state rather than a pure rendering of the + notebook model + +That means a notebook cell can have a fuller persisted transcript than the user +can actually see in the notebook UI. + +### 3. Character-at-a-time terminal input is the wrong notebook UX + +Notebook cells are structured documents. They already separate source from +output. They should not behave like a remote PTY by default. + +For notebook cells, the useful interaction is usually: + +1. read the prompt +2. type a response +3. submit that response + +The useful interaction is not: + +1. focus a terminal widget +2. stream raw keystrokes +3. infer prompt state from cursor position and terminal paint state + +The raw-terminal model also makes replay, testing, and automation harder than +necessary. + +## Goals + +- Make waiting for stdin obvious. +- Preserve complete notebook output without terminal scrollback loss. +- Use the notebook model as the source of truth for rendered output. +- Support ordinary line-oriented stdin for runner-backed notebook cells. +- Align notebook execution UX with the append-only, document-style direction + already used elsewhere in the app. + +## Non-Goals + +- Full TTY emulation inside notebook cells. +- Support for curses or full-screen terminal apps in notebook cells. +- Jupyter `input_request` support in this change. +- Password masking without explicit backend signal. +- Replacing App Console or other dedicated terminal-like surfaces. + +## Decision + +We will use a submit-oriented stdin composer for notebook cells. + +We will not use raw terminal typing as the primary notebook input UX. + +We will render notebook stdout/stderr from notebook state, not from xterm +paint state. + +We will surface partial trailing stdout immediately instead of waiting for a +newline. + +We will keep true terminal UX for App Console or a future dedicated terminal +surface, not for notebook cells. + +## Why This Is The Right Boundary + +Notebook cells and terminal sessions have different product contracts. + +Notebook cells should provide: + +- complete, replayable output +- stable input/output boundaries +- deterministic rerender from model state +- DOM structure that tests and automation can inspect directly + +Terminal sessions should provide: + +- raw keystroke streaming +- cursor-oriented rendering +- terminal scrollback semantics +- support for full-screen terminal apps + +The current implementation tries to make notebook cells act like terminals. +That is the wrong abstraction boundary. + +## Proposal + +### 1. Replace notebook `CellConsole` input with an explicit stdin composer + +When a notebook cell has an active runner stream, the output area should render: + +- the transcript +- run status +- an input composer + +The input composer is the only editable element for stdin. + +Recommended v1 behavior: + +- render a single-line text input +- render a `Send` button +- pressing `Enter` submits the current value +- submission sends one `ExecuteRequest.inputData` write containing the entered + text plus `\n` +- clear the composer after submit +- do not locally echo the submitted text; rely on the remote process to echo if + that is part of its behavior + +The composer can remain visible for any active interactive run. We do not need +perfect blocked-on-stdin detection in v1 to make the UX usable. + +### 2. Render partial stdout immediately + +Generic runner stdout should be appended to the notebook transcript as it +arrives. + +We should not keep generic stdout in a hidden partial-line buffer waiting for a +newline. That buffering makes sense only for protocols that are actually +line-delimited control streams. It is wrong for terminal-like stdout where +unterminated prompts are meaningful UI. + +This is the concrete fix for the missing `Password:` prompt. + +### 3. Stop relying on terminal scrollback for notebook history + +Notebook transcript rendering should come from notebook outputs, not from xterm +state. + +Once the notebook transcript is rendered directly: + +- output is no longer capped by terminal scrollback +- rerender after reload is exact +- output testing no longer depends on terminal DOM internals +- stdout/stderr fallback rendering is always available + +This is also the cleanest fix for the “output is cut off” class of bugs. + +### 4. Keep Jupyter handling separate + +Jupyter is already handled through a different path. Its `input_request` flow +is explicitly unsupported today. + +We should keep that boundary clear: + +- runner-backed shell-like cells get the new stdin composer +- Jupyter cells continue to reject `input_request` in v1 + +We should not mix Jupyter message parsing requirements into the generic runner +stdout path. + +### 5. Treat terminal markers as compatibility hints, not as rendering truth + +`StatefulRunmeTerminal` should not force notebook cells into xterm rendering. + +Short term: + +- continue to read existing terminal markers so older notebooks still render in + a compatible way +- use the marker only as a hint that the cell may have interactive runner + behavior + +Long term: + +- stop using the marker as a model-level rendering switch +- infer notebook output presentation from notebook output items and active run + state instead + +This aligns with the direction already described in #190. + +## Implementation Plan + +### Phase 1: Fix the UX without a backend protocol change + +1. Replace notebook-cell terminal input with a dedicated React stdin composer. +2. Route submitted input through `ExecuteRequest.inputData`. +3. Render generic runner stdout/stderr directly from notebook outputs. +4. Remove newline-delayed buffering from the generic runner stdout path. +5. Stop depending on `console-view` scrollback for notebook output visibility. +6. Keep Jupyter behavior unchanged. + +This phase should solve the practical user problem in #99. + +### Phase 2: Add optional explicit stdin request metadata + +Phase 1 makes stdin usable. It does not make it precise. + +The remaining gap is prompt semantics. The UI still cannot know: + +- whether the process is truly blocked on stdin +- whether the input should be masked +- whether the input is line-oriented or raw + +If Runme can surface that information, we should add an explicit event or state +shape such as: + +```ts +type StdinRequest = { + active: boolean; + prompt?: string; + echo?: boolean; + mode?: "line" | "raw"; +}; +``` + +That would let the UI show: + +- `Waiting for input` +- a masked field when `echo === false` +- better affordances for non-default submission behavior + +This is useful future work, but it should not block Phase 1. + +## Affected Areas + +The change will primarily affect: + +- `app/src/components/Actions/Actions.tsx` +- `app/src/components/Actions/CellConsole.tsx` +- `app/src/lib/notebookData.ts` + +Expected structural changes: + +- move notebook stdin UI into a React component instead of `console-view` +- limit generic runner stream handling to transcript updates +- keep any protocol-specific parsing scoped to the protocol that needs it + +## Password Prompts + +Password prompts are a special case. + +The example in #99 is a password prompt. A browser text input should not guess +whether input needs masking. The runtime must tell us. + +Therefore: + +- v1 should support visible line input only +- notebook cells remain a poor place for secret-entry workflows until the + runtime can signal `echo: false` +- if secret entry matters before then, we should direct users to a true + terminal surface + +This limitation should be explicit in the shipped UX and in tests. + +## Alternatives Considered + +### Keep `console-view` and only flush partial stdout earlier + +This fixes the missing prompt, but it does not fix the architectural problem. + +We would still have: + +- terminal scrollback limits in notebook history +- output rendering split between notebook state and xterm state +- raw keystroke stdin as the primary notebook interaction model + +This is not enough. + +### Keep raw terminal input but add a “waiting” banner + +A banner helps a little, but the user still has to interact with a terminal +widget inside a notebook cell. + +That does not improve replay, testing, automation, or output completeness. + +### Route interactive notebook cells into a separate terminal pane + +This is cleaner than embedding xterm in the cell, but it is heavier than we +need for ordinary prompts such as `y/n` or single-line input. + +It is a reasonable future option for true terminal workflows. It is not the +best v1 fix for #99. + +## Testing + +We should add a focused interactive test notebook and browser coverage. + +Required cases: + +1. Prompt without trailing newline: + +```bash +printf "Continue? [y/N] " +read -r answer +printf "\nanswer=%s\n" "$answer" +``` + +Expected behavior: + +- `Continue? [y/N] ` is visible before input is sent +- user can submit `y` +- final output includes `answer=y` + +2. Long output larger than terminal scrollback: + +```bash +for i in $(seq 1 5005); do + printf "Line %04d\n" "$i" +done +``` + +Expected behavior: + +- the notebook view shows the full transcript after completion +- reload preserves the full transcript + +3. Interactive cell rerun: + +- rerun clears prior live output according to current semantics +- prompt still appears correctly on the new run + +4. Legacy terminal marker notebook: + +- older cells with `StatefulRunmeTerminal` still render output correctly + +5. Jupyter `input_request`: + +- current unsupported message remains explicit + +## Open Questions + +- Should the v1 stdin composer include a `Send EOF` action? +- Should we allow multi-line input submission, or keep notebook stdin strictly + line-oriented in v1? +- Should the composer stay visible for the full duration of an active run, or + only after we have backend `stdinRequested` metadata? + +## Recommendation + +For notebook cells, we should model stdin as explicit submitted input, not as a +terminal keystroke stream. + +That gives us the right UX for notebook documents, fixes the hidden-prompt bug, +and removes terminal scrollback as a source of apparent output truncation. From 7564fb2696b8013dd829260c0d79ec119eada990 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Wed, 13 May 2026 15:06:33 -0700 Subject: [PATCH 2/4] Clarify notebook output duplication regression Signed-off-by: Jeremy lewi --- .../design/20260513_notebook_stdin_output.md | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/docs-dev/design/20260513_notebook_stdin_output.md b/docs-dev/design/20260513_notebook_stdin_output.md index 2d1e8e3..60b8327 100644 --- a/docs-dev/design/20260513_notebook_stdin_output.md +++ b/docs-dev/design/20260513_notebook_stdin_output.md @@ -20,6 +20,8 @@ This fixes the main failure reported in waits for input - notebook output can appear cut off because terminal rendering suppresses the normal stdout/stderr view and applies terminal scrollback limits +- notebook cells can render the same transcript twice as both a `CellConsole` + and normal stdout/stderr output items [#191](https://github.com/runmedev/web/pull/191) removed renderer callbacks from notebook mutation paths. It did not change the notebook interaction model. @@ -44,7 +46,7 @@ Today: newline or process exit - `CellConsole` sets terminal scrollback to `4000` -That creates three concrete problems. +That creates four concrete problems. ### 1. Waiting-for-input is invisible @@ -79,7 +81,34 @@ The current terminal path can hide or drop visible output in several ways: That means a notebook cell can have a fuller persisted transcript than the user can actually see in the notebook UI. -### 3. Character-at-a-time terminal input is the wrong notebook UX +### 3. The same transcript can render twice + +The current rendering policy uses two different signals: + +- `CellConsole` renders when the cell has terminal output or an active stream +- `ActionOutputItems` suppresses stdout/stderr only when the cell has a + `StatefulRunmeTerminal` output marker + +That split creates a duplication bug. + +If a cell has an active stream but no terminal marker, the UI renders: + +- a live `CellConsole` +- the same stdout/stderr as normal output items + +This is likely the follow-on regression after #191. That change stopped seeding +terminal markers during mutation, but the output rendering policy still assumes +that terminal suppression is keyed off the marker. + +The result is exactly the duplicated UI we now see: + +- `Output 0 / Item 0 - mime=application/vnd.code.notebook.stdout` +- a console showing the same transcript + +Notebook cells must choose one transcript presentation path per run. They +should not render both. + +### 4. Character-at-a-time terminal input is the wrong notebook UX Notebook cells are structured documents. They already separate source from output. They should not behave like a remote PTY by default. @@ -105,6 +134,7 @@ necessary. - Preserve complete notebook output without terminal scrollback loss. - Use the notebook model as the source of truth for rendered output. - Support ordinary line-oriented stdin for runner-backed notebook cells. +- Ensure each notebook transcript is rendered exactly once. - Align notebook execution UX with the append-only, document-style direction already used elsewhere in the app. @@ -125,6 +155,8 @@ We will not use raw terminal typing as the primary notebook input UX. We will render notebook stdout/stderr from notebook state, not from xterm paint state. +We will ensure notebook output has a single presentation path per run. + We will surface partial trailing stdout immediately instead of waiting for a newline. @@ -197,6 +229,8 @@ state. Once the notebook transcript is rendered directly: +- console and stdout/stderr duplication goes away because there is only one + transcript renderer - output is no longer capped by terminal scrollback - rerender after reload is exact - output testing no longer depends on terminal DOM internals @@ -244,8 +278,11 @@ This aligns with the direction already described in #190. 2. Route submitted input through `ExecuteRequest.inputData`. 3. Render generic runner stdout/stderr directly from notebook outputs. 4. Remove newline-delayed buffering from the generic runner stdout path. -5. Stop depending on `console-view` scrollback for notebook output visibility. -6. Keep Jupyter behavior unchanged. +5. Remove the split policy where active-stream cells render `CellConsole` while + stdout/stderr suppression still depends on terminal markers. +6. Stop depending on `console-view` scrollback for notebook output visibility. +7. Ensure notebook cells choose one transcript renderer, not both. +8. Keep Jupyter behavior unchanged. This phase should solve the practical user problem in #99. From 57c11b828f5a8d9f843e4ee5667389fd4a73fcab Mon Sep 17 00:00:00 2001 From: Jeremy Lewi Date: Thu, 14 May 2026 17:28:23 -0700 Subject: [PATCH 3/4] Add HTML notebook cells (#208) ## Summary - add a dedicated in-place HTML cell mode backed by - render HTML cells through a sandboxed iframe preview, including inline SVG content - add design documentation, serialization coverage, and a browser CUJ that records a walkthrough video ## Testing - runme run build test - pnpm exec vitest run src/components/Actions/Actions.test.tsx src/lib/markdown/serializeNotebookToMarkdown.test.ts - CUJ_UPLOAD=false CUJ_FRONTEND_URL=http://localhost:5174 CUJ_USE_AUTH=false CUJ_DRIVE_FAKE_ENABLED=false CUJ_FAKE_CHATKIT_ENABLED=false CUJ_SCENARIOS=html-cell AGENT_BROWSER_HEADED=true pnpm -C app run cuj:run --------- Signed-off-by: Jeremy lewi --- app/src/components/Actions/Actions.test.tsx | 43 +++ app/src/components/Actions/Actions.tsx | 159 ++++++-- app/src/components/Actions/HtmlCell.tsx | 214 +++++++++++ app/src/lib/cellContent.ts | 10 + .../serializeNotebookToMarkdown.test.ts | 16 + .../markdown/serializeNotebookToMarkdown.ts | 28 +- app/src/lib/notebookData.test.ts | 22 ++ app/src/lib/notebookData.ts | 5 + app/src/lib/runtime/runmeConsole.test.ts | 46 ++- app/src/lib/runtime/runmeConsole.ts | 27 +- app/src/routes/run.tsx | 15 +- app/test/browser/run-cuj-scenarios.ts | 1 + app/test/browser/test-scenario-html-cell.ts | 335 +++++++++++++++++ app/vite.config.mts | 1 + app/vitest.config.mts | 3 +- docs-dev/design/20260513_html_cells.md | 355 ++++++++++++++++++ 16 files changed, 1244 insertions(+), 36 deletions(-) create mode 100644 app/src/components/Actions/HtmlCell.tsx create mode 100644 app/src/lib/cellContent.ts create mode 100644 app/test/browser/test-scenario-html-cell.ts create mode 100644 docs-dev/design/20260513_html_cells.md diff --git a/app/src/components/Actions/Actions.test.tsx b/app/src/components/Actions/Actions.test.tsx index daf33fe..640f32d 100644 --- a/app/src/components/Actions/Actions.test.tsx +++ b/app/src/components/Actions/Actions.test.tsx @@ -281,6 +281,49 @@ describe("Action component", () => { expect(updatedCell.languageId).toBe("markdown"); }); + it("converts markdown cells to html code cells", () => { + const cell = create(parser_pb.CellSchema, { + refId: "cell-html-convert", + kind: parser_pb.CellKind.MARKUP, + languageId: "markdown", + outputs: [], + metadata: {}, + value: "", + }); + const stub = new StubCellData(cell); + + render(); + + const selector = screen.getByRole("combobox"); + fireEvent.change(selector, { target: { value: "html" } }); + + expect(stub.update).toHaveBeenCalledTimes(1); + const updatedCell = stub.update.mock.calls[0][0] as parser_pb.Cell; + expect(updatedCell.kind).toBe(parser_pb.CellKind.CODE); + expect(updatedCell.languageId).toBe("html"); + }); + + it("renders html cells in-place without the code run toolbar", () => { + const cell = create(parser_pb.CellSchema, { + refId: "cell-html-rendered", + kind: parser_pb.CellKind.CODE, + languageId: "html", + outputs: [], + metadata: {}, + value: "
Hello HTML
", + }); + const stub = new StubCellData(cell); + + render(); + + expect(screen.getByTestId("html-action")).toBeTruthy(); + expect(screen.getByTestId("html-rendered")).toBeTruthy(); + const frame = screen.getByTestId("html-preview-frame") as HTMLIFrameElement; + expect(frame.getAttribute("srcdoc")).toBe("
Hello HTML
"); + expect(frame.getAttribute("sandbox")).toBe(""); + expect(screen.queryByLabelText("Run code")).toBeNull(); + }); + it("shows browser/sandbox runner selector for javascript cells", () => { const cell = create(parser_pb.CellSchema, { refId: "cell-runner-select", diff --git a/app/src/components/Actions/Actions.tsx b/app/src/components/Actions/Actions.tsx index 565206b..233dfd8 100644 --- a/app/src/components/Actions/Actions.tsx +++ b/app/src/components/Actions/Actions.tsx @@ -26,10 +26,12 @@ import { useNotebookStore } from "../../contexts/NotebookStoreContext"; import { useOutput } from "../../contexts/OutputContext"; import CellConsole, { fontSettings } from "./CellConsole"; import Editor from "./Editor"; +import HtmlCell from "./HtmlCell"; import MarkdownCell from "./MarkdownCell"; import { IOPUB_INCOMPLETE_METADATA_KEY } from "../../lib/ipykernel"; import { appLogger } from "../../lib/logging/runtime"; import { copyNotebookShareUrl } from "../../lib/shareLinks"; +import { isHtmlLanguageId, isMarkdownLanguageId } from "../../lib/cellContent"; import { PlayIcon, PlusIcon, @@ -269,6 +271,7 @@ function RunActionButton({ // Action is an editor and an optional Runme console const LANGUAGE_OPTIONS = [ { label: "Markdown", value: "markdown" }, + { label: "HTML", value: "html" }, { label: "Bash", value: "bash" }, { label: "Jupyter", value: "jupyter" }, { label: "Python", value: "python" }, @@ -282,6 +285,7 @@ const JAVASCRIPT_RUNNER_OPTIONS = [ type SupportedLanguage = | "bash" + | "html" | "jupyter" | "javascript" | "markdown" @@ -290,6 +294,13 @@ type SupportedLanguage = const outputTextDecoder = new TextDecoder(); const ALWAYS_SKIP_MIMES = new Set([MimeType.StatefulRunmeTerminal]); +function normalizeBinaryData(data?: Uint8Array | ArrayLike | null): Uint8Array { + if (!data) { + return new Uint8Array(); + } + return data instanceof Uint8Array ? data : Uint8Array.from(data); +} + function isGoogleDriveFileUri(uri: string | null | undefined): uri is string { if (!uri) { return false; @@ -317,9 +328,12 @@ function normalizeLanguageId( switch (kind) { case parser_pb.CellKind.CODE: const normalized = (languageId ?? "").toLowerCase(); - if (normalized === "markdown") { + if (isMarkdownLanguageId(normalized)) { return "markdown"; } + if (isHtmlLanguageId(normalized)) { + return "html"; + } if (normalized === "python" || normalized === "py") { return "python"; } @@ -344,26 +358,28 @@ function normalizeLanguageId( } } -function decodeOutputText(data: Uint8Array): string { - if (!(data instanceof Uint8Array) || data.length === 0) { +function decodeOutputText(data?: Uint8Array | ArrayLike | null): string { + const normalized = normalizeBinaryData(data); + if (normalized.length === 0) { return ""; } try { - return outputTextDecoder.decode(data); + return outputTextDecoder.decode(normalized); } catch { return ""; } } -function uint8ArrayToBase64(data: Uint8Array): string { - if (!(data instanceof Uint8Array) || data.length === 0) { +function uint8ArrayToBase64(data?: Uint8Array | ArrayLike | null): string { + const normalized = normalizeBinaryData(data); + if (normalized.length === 0) { return ""; } let binary = ""; const chunkSize = 0x8000; - for (let i = 0; i < data.length; i += chunkSize) { - const chunk = data.subarray(i, i + chunkSize); + for (let i = 0; i < normalized.length; i += chunkSize) { + const chunk = normalized.subarray(i, i + chunkSize); binary += String.fromCharCode(...chunk); } @@ -457,7 +473,7 @@ export function ActionOutputItems({ outputs }: { outputs: parser_pb.CellOutput[] ) { return null; } - if (!(item.data instanceof Uint8Array)) { + if (normalizeBinaryData(item.data).length === 0) { return null; } return ( @@ -528,6 +544,7 @@ export function Action({ y: number; } | null>(null); const [shareRemoteUri, setShareRemoteUri] = useState(null); + const [htmlEditRequest, setHtmlEditRequest] = useState(0); const [markdownEditRequest, setMarkdownEditRequest] = useState(0); const [pid, setPid] = useState(null); const [exitCode, setExitCode] = useState(null); @@ -668,6 +685,8 @@ export function Action({ const editorLanguage = useMemo(() => { switch (selectedLanguage) { + case "html": + return "html"; case "markdown": return "markdown"; case "javascript": @@ -773,6 +792,15 @@ export function Action({ } return; } + if (selectedLanguage === "html") { + if (initialRunnerName !== DEFAULT_RUNNER_PLACEHOLDER) { + cellData.setRunner(DEFAULT_RUNNER_PLACEHOLDER); + } + if (hasJupyterSelection) { + cellData.clearJupyterKernel(); + } + return; + } if (selectedLanguage === "jupyter" && isAppKernelRunnerName(initialRunnerName)) { cellData.setRunner(DEFAULT_RUNNER_PLACEHOLDER); if (hasJupyterSelection) { @@ -892,21 +920,26 @@ export function Action({ const updatedCell = create(parser_pb.CellSchema, cell); updatedCell.metadata ??= {}; - if (nextValue === "markdown") { - setMarkdownEditRequest((request) => request + 1); - updatedCell.kind = parser_pb.CellKind.MARKUP; - updatedCell.languageId = "markdown"; + const clearRuntimeMetadata = () => { delete updatedCell.metadata[RunmeMetadataKey.RunnerName]; delete updatedCell.metadata[RunmeMetadataKey.JupyterServerName]; delete updatedCell.metadata[RunmeMetadataKey.JupyterKernelID]; delete updatedCell.metadata[RunmeMetadataKey.JupyterKernelName]; + }; + if (nextValue === "markdown") { + setMarkdownEditRequest((request) => request + 1); + updatedCell.kind = parser_pb.CellKind.MARKUP; + updatedCell.languageId = "markdown"; + clearRuntimeMetadata(); + } else if (nextValue === "html") { + setHtmlEditRequest((request) => request + 1); + updatedCell.kind = parser_pb.CellKind.CODE; + updatedCell.languageId = "html"; + clearRuntimeMetadata(); } else if (nextValue === "jupyter") { updatedCell.kind = parser_pb.CellKind.CODE; updatedCell.languageId = "jupyter"; - delete updatedCell.metadata[RunmeMetadataKey.RunnerName]; - delete updatedCell.metadata[RunmeMetadataKey.JupyterServerName]; - delete updatedCell.metadata[RunmeMetadataKey.JupyterKernelID]; - delete updatedCell.metadata[RunmeMetadataKey.JupyterKernelName]; + clearRuntimeMetadata(); } else if (nextValue === "javascript") { updatedCell.kind = parser_pb.CellKind.CODE; updatedCell.languageId = "javascript"; @@ -944,11 +977,12 @@ export function Action({ // Determine if this cell is a markdown cell (either MARKUP kind or CODE with markdown language) const isMarkdownCell = useMemo(() => { if (!cell) return false; - // Check if cell kind is MARKUP if (cell.kind === parser_pb.CellKind.MARKUP) return true; - // Check if cell is CODE but with markdown language - const lang = (cell.languageId ?? "").toLowerCase(); - return lang === "markdown" || lang === "md"; + return isMarkdownLanguageId(cell.languageId); + }, [cell]); + const isHtmlCell = useMemo(() => { + if (!cell) return false; + return cell.kind === parser_pb.CellKind.CODE && isHtmlLanguageId(cell.languageId); }, [cell]); if (!cell) { @@ -1043,6 +1077,89 @@ export function Action({ ); } + if (isHtmlCell) { + return ( +
+
+ + +
+
+
+ + +
+
+ {adjustedContextMenu && ( +
event.preventDefault()} + > + {shareRemoteUri && ( + + )} + +
+ )} +
+ ); + } + // Render code cells as a unified Marimo-style card: editor + toolbar + output // are all inside one bordered container with a distinctive "paper" shadow. // The outer wrapper is a flex row: left gutter (add-cell buttons) + cell card. diff --git a/app/src/components/Actions/HtmlCell.tsx b/app/src/components/Actions/HtmlCell.tsx new file mode 100644 index 0000000..6a0c2dc --- /dev/null +++ b/app/src/components/Actions/HtmlCell.tsx @@ -0,0 +1,214 @@ +import { + type ChangeEvent, + memo, + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type FocusEvent, + type KeyboardEvent, +} from "react"; + +import { create } from "@bufbuild/protobuf"; +import { parser_pb } from "../../runme/client"; +import type { CellData } from "../../lib/notebookData"; +import Editor from "./Editor"; +import { fontSettings } from "./CellConsole"; + +interface HtmlCellProps { + cellData: CellData; + selectedLanguage: string; + languageSelectId: string; + languageOptions: readonly { label: string; value: string }[]; + onLanguageChange: (event: ChangeEvent) => void; + forceEditRequest?: number; +} + +const HtmlCell = memo( + ({ + cellData, + selectedLanguage, + languageSelectId, + languageOptions, + onLanguageChange, + forceEditRequest = 0, + }: HtmlCellProps) => { + const cell = useSyncExternalStore( + useCallback( + (listener) => cellData.subscribeToContentChange(listener), + [cellData], + ), + useCallback(() => cellData.snapshot, [cellData]), + useCallback(() => cellData.snapshot, [cellData]), + ); + + const [rendered, setRendered] = useState(() => { + const value = cell?.value ?? ""; + return value.trim().length > 0; + }); + + const value = cell?.value ?? ""; + + useEffect(() => { + if (!value.trim() && rendered) { + setRendered(false); + } + }, [rendered, value]); + + useEffect(() => { + if (forceEditRequest > 0) { + setRendered(false); + } + }, [forceEditRequest]); + + const handleRenderedKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.target !== event.currentTarget) { + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setRendered(false); + } + }, + [], + ); + + const handleBlur = useCallback( + (event: FocusEvent) => { + if (event.currentTarget.contains(event.relatedTarget as Node | null)) { + return; + } + if (!value.trim()) { + return; + } + setRendered(true); + }, + [value], + ); + + const handleEditorKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape" && value.trim()) { + setRendered(true); + } + }, + [value], + ); + + const handleEditorChange = useCallback( + (newValue: string) => { + if (!cell) { + return; + } + const updated = create(parser_pb.CellSchema, cell); + updated.value = newValue; + cellData.update(updated); + }, + [cell, cellData], + ); + + const handlePreview = useCallback(() => { + if (value.trim()) { + setRendered(true); + } + }, [value]); + + const previewIframe = useMemo( + () => ( +