Skip to content

Conversation

@jlewi
Copy link
Collaborator

@jlewi jlewi commented Dec 21, 2025

Motivation

AppConsole

We'd like to be able to use ConsoleView to create an AppConsole. The AppConsole will be a terminal that will allow interacting with the app itself; e.g. managing configuration.

The problem is if try to import @runmedev/renderers we end up with an error

NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry': the name close-cell-button has already been used with this registry

So we need to allow embedding the Runme console alongside other UI without CustomElement collisions or renderer context crashes

RunmeConsole's with different backends

We'd like to have RunmeConsole's talking to different backends. Currently that doesn't work because there is a single RenderContext attached to the underlying ConsoleView.

Bug:

The webcomponent console-view was being registered twice when both @runmedev/react-console and @runmedev/renderers were imported, leading to custom element name clashes; the console also crashed with “Renderer context not defined” when bundled copies of renderers diverged.

I think there are two issues

  1. runmedev/renderers and runmedev/console both were causing the side effect of registering the custom components

  2. the console-view uses a module level render context;

Fix:

Make custom element registration idempotent, externalize/dedupe @runmedev/renderers/@runmedev/react-console as peers to share a single instance, add a safe fallback for missing renderer context

ConsoleView per instance RenderContext

Allow ConsoleView to have per instance RenderContext rather than always relying on the module level RenderContext.
For backwards compatibility continue to fall back to calling getContext if the per instance context isn't set.

@jlewi
Copy link
Collaborator Author

jlewi commented Dec 21, 2025

@sourishkrout I'm slightly confused by how rendering works; if I'm understanding codex; in the current version

  • each RunmeConsole has its own stream object and its own connection to the runner
  • There is a singleton for the context: RenderContext set in messaging.ts
  • The methods postMessage and onDidReceiveMessage aren't actually being used by RunmeConsole which is why multiple RunmeConsoles work
  • RunmeConsole doesn't rely on RenderContext to send/post messages to the ConsoleView but instead writes directly to the terminal inside the ConsolveView

Is that right?

  • I'm verfy confused on how multiple RunmeConsoles works today with condex is a singleton that gets reused across ConsoleViews; this seems like all but one ConsolveView is using the wrong streams object

  • Could we simplify this by removing the use of vscodeRenderer ? Or is that needed to allow using the components inside vscode notebooks?

@jlewi
Copy link
Collaborator Author

jlewi commented Dec 21, 2025

@sourishkrout Any thoughts on how we could make it easy to build a testApp that allows us to use mocks/fakes for the actual Runner? I started building out a small testApp the goal being to have 3 different consoles; 2 Runme and 1 ConsoleView each talking to different backends.

Do you think there's a way to move all the streams logic into a separate class with a simple API for pushing/recieving messages from the console that would allow us to easily fake it out?

@jlewi
Copy link
Collaborator Author

jlewi commented Dec 21, 2025

@sourishkrout Here's more info on what I'm ultimately trying to do.

I want the webapp to be configured with multiple runners; e.g. one running locally and another running in a K8s cluster.
Then I want to be able to configure each cell with the runner it should talk to; so that within one notebook you could have cells running locally and cells running remotely.

The AppConsole comes in as a way of configuring the runners. Rather than continuing to build out the settings page I want to add an app console where one can simple run commands e.g.

> addRunner("local", "wss://localhost:9988/ws")

@jlewi
Copy link
Collaborator Author

jlewi commented Dec 21, 2025

@sourishkrout so I initially hit the issues with ConsoleView which I believe were the result of renderers not being declared a peer dependency resulting in multiple instances of the renderers package being present leading to the error due to custom elements being redefined. While fixing that I started noticing the use of the shared context bridge and started wondering if that would cause problems in the event we want to have RunmeConsole's talking to different runners.

I've been trying to tease this apart. Here's what I understand from ChatGPT and looking at the code.

In VSCode extensions

  • WebView rendering the render console output runs in one process
  • NotebookController talking to Notebook kernel runs in a different process (extension host)
  • Communication happens via message passing via the RendererContext
  • The RendererContext is shared across notebook cells
  • Messages are tagged with an ID which the shared renderer uses to route to the correct cell output

Now it seems like RunmeConsole doesn't actually rely on the RenderContext for communicating with the kernel.
It looks like in maybeInitStreams RunmeConsole directly wires messages from the streams to the terminal console using rxjs. So it looks like the RenderContext is being bypassed for actually streaming input/output to the kernel. I think we can do this because unlike vscode communication with the kernel isn't happening in a separate thred.

However; in installCtxBridge RunmeConsole is calling setContext to set the shared RenderContext. I think each RunmeConsole would override whatever was already set; so we'd have a last one wins situation. The RenderContext looks like it is only used to send ExecuteRequests containing the window size. I think there is a bug here in the case where each RunnerConsole could be talking to a different runner because the request would get sent to whatever #streams were being used as a result of the last RunnerConsole to call SetContext. However, given this is only used to set window size I'm not sure how much this matters.

@sourishkrout
Copy link
Contributor

This fixes the isolated issue where close-cell-button and other components failed to "redefine": #29

@sourishkrout
Copy link
Contributor

Now it seems like RunmeConsole doesn't actually rely on the RenderContext for communicating with the kernel. It looks like in maybeInitStreams RunmeConsole directly wires messages from the streams to the terminal console using rxjs. So it looks like the RenderContext is being bypassed for actually streaming input/output to the kernel. I think we can do this because unlike vscode communication with the kernel isn't happening in a separate thred.

However; in installCtxBridge RunmeConsole is calling setContext to set the shared RenderContext. I think each RunmeConsole would override whatever was already set; so we'd have a last one wins situation. The RenderContext looks like it is only used to send ExecuteRequests containing the window size. I think there is a bug here in the case where each RunnerConsole could be talking to a different runner because the request would get sent to whatever #streams were being used as a result of the last RunnerConsole to call SetContext. However, given this is only used to set window size I'm not sure how much this matters.

That's what streams.ts does.

@sourishkrout
Copy link
Contributor

@sourishkrout Any thoughts on how we could make it easy to build a testApp that allows us to use mocks/fakes for the actual Runner? I started building out a small testApp the goal being to have 3 different consoles; 2 Runme and 1 ConsoleView each talking to different backends.

Do you think there's a way to move all the streams logic into a separate class with a simple API for pushing/recieving messages from the console that would allow us to easily fake it out?

I think the solution is to namespace the RenderContext where non-Runme Runner backed have their own, individually or shared, RenderContext. Looking into this right now.

@sourishkrout
Copy link
Contributor

Here you go: #30, @jlewi.

@jlewi
Copy link
Collaborator Author

jlewi commented Dec 21, 2025

@sourishkrout Is #29 ; using safeCustomElement the right solution? codex originally came up with that same solution (e5a5bd5) and I ultimately rejected it.

Won't multiple imports of the module lead to ill defined behavior and subtle bugs? My simple understanding is that a custom-element can only be registered once. So presumably, if we have two different instances of the module renderersA and renderersB; the custom element gets bound to the implementation / JS code in one or the other. This will lead to problems e.g. if the versions aren't the same. Or in our case since we were relying on a module level variable (context: RenderContext) I think we wind up with problems because some code is using renderserA and other code is using renderersB and its not being consistent.

@jlewi jlewi changed the title Make renderers package peer dependency of react-console Make using renderers and react-console work; support multiple RunmeConsoles Dec 22, 2025
@jlewi
Copy link
Collaborator Author

jlewi commented Dec 22, 2025

@sourishkrout I think this will work and here's how it compares to #29 and #30

  • It makes renderers a peer dependency so that we end up with a single instance

  • It allows RenderContext be set per instance on Console

    • We fall back to using getContext and using module level version to provide backwards compatibility
  • To make it more testable I defined a StreamsLike interface and plumb through Console->RunmeConsole a StreamCreator function

    • This allows us to inject a fake Streams into the RunmeConsole
    • If no StreamCreator is provided we fall back to using Streams

    There's some excess logging that we should probably remove before merging but thought I'd get your initial thoughts on this.

    I noticed some weird behavior with testApp where changes to the packages (e.g. renderers) weren't picked up just by doing pnpm run build. I needed to do

cd ~/git_runmeweb
find ./ -name "node_modules" -exec rm -rf {} ";"
pnpm install
pnpm run build 

@sourishkrout
Copy link
Contributor

sourishkrout commented Dec 22, 2025

Won't multiple imports of the module lead to ill defined behavior and subtle bugs? My simple understanding is that a custom-element can only be registered once. So presumably, if we have two different instances of the module renderersA and renderersB; the custom element gets bound to the implementation / JS code in one or the other. This will lead to problems e.g. if the versions aren't the same. Or in our case since we were relying on a module level variable (context: RenderContext) I think we wind up with problems because some code is using renderserA and other code is using renderersB and its not being consistent.

No. For the purposes of registering custom elements inside the DOM and avoiding collisions, it is a suitable solution. The barrel export/import from a consumer's convenience point of view is well worth preserving which has the side-effect of running the registration logic on every import. Since the DOM is a singleton we just lean on it and very little to no cost. Even multiple instance of the same code (idempotent) is not a problem.

The RenderContext is a separate story which is why I merged #29 independently. I always knew this was going to be a problem but neglect-able thanks to the renderers -> console -> app dependency graph that now will have a coexisting renderers -> app dependency with a non-Runme "stdio" implementation.

@sourishkrout
Copy link
Contributor

@sourishkrout I think this will work and here's how it compares to #29 and #30

  • It makes renderers a peer dependency so that we end up with a single instance

  • It allows RenderContext be set per instance on Console

    • We fall back to using getContext and using module level version to provide backwards compatibility
  • To make it more testable I defined a StreamsLike interface and plumb through Console->RunmeConsole a StreamCreator function

    • This allows us to inject a fake Streams into the RunmeConsole
    • If no StreamCreator is provided we fall back to using Streams

    There's some excess logging that we should probably remove before merging but thought I'd get your initial thoughts on this.
    I noticed some weird behavior with testApp where changes to the packages (e.g. renderers) weren't picked up just by doing pnpm run build. I needed to do

cd ~/git_runmeweb
find ./ -name "node_modules" -exec rm -rf {} ";"
pnpm install
pnpm run build 

I'm fine using the "Streams interface" if that's more convenient. What matters most to me is that both Runme web and the extension continue to work which both are relying parties.


// Optional Streams factory for testing/custom backends
@property({ attribute: false })
StreamCreator?: (props: StreamsProps) => StreamsLike
Copy link
Contributor

@sourishkrout sourishkrout Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why upper-case/PascalCase?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what codex did.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What should it be?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Camel-case like the others: streamCreator...

@jlewi
Copy link
Collaborator Author

jlewi commented Dec 24, 2025

Sorry for the slow reply.

The barrel export/import from a consumer's convenience point of view is well worth preserving which has the side-effect of running the registration logic on every import.

Don't we risk version skew if we have more than one version?
IUC node allows each package to have its own independent version.
So I believe if the app depends on @runmedev/renderers3.16 and @runmedev/react-console3.18 which depends on @runmedev/renderers4.0. there be two copies of renderers corresponding to the different versions.

I think node_modules would end up looking like this.

node_modules/
├─ @runmedev/
│  ├─ renderers/          # v3.16 (used by the app directly)
│  └─ react-console/
│     └─ node_modules/
│        └─ @runmedev/
│           └─ renderers/ # v4.0 (used by react-console)

I'm fine using the "Streams interface" if that's more convenient. What matters most to me is that both Runme web and the extension continue to work which both are relying parties.

Is there a better pattern here to make it more testable? Do we want to allow injecting Runme client fakes? What's a good way to make sure both runmeweb and vscode continue to work?

Can you remind me why we have 2 webcomponents (console-view and runme-console)? It looks like RunmeConsole is binding the console-view to a particular implementation of the logic. Is that just a convenience? Is it necessary for vscode?

I think an alternative would be you just have console-view and you bind it to different handlers; such as a Runme handler. (I think this is where the Streams idea is headed).

@jlewi
Copy link
Collaborator Author

jlewi commented Dec 25, 2025

I pushed a dev version from this branch and started using it; seems to be working.

I find myself needing to implement a line-editor to have the terminal behave as expected (e.g. arrow keys navigate the line; backspace edit the values). Is that expected. codex is telling me that's not provided by xterm.js; xterm.js just provides raw stdin. How does runme-console handle this when you start a shell such as bash? Or maybe it doesn't need to because the actual terminal is handling that for you?

@sourishkrout
Copy link
Contributor

The barrel export/import from a consumer's convenience point of view is well worth preserving which has the side-effect of running the registration logic on every import.

Don't we risk version skew if we have more than one version? IUC node allows each package to have its own independent version. So I believe if the app depends on @runmedev/renderers3.16 and @runmedev/react-console3.18 which depends on @runmedev/renderers4.0. there be two copies of renderers corresponding to the different versions.

I think node_modules would end up looking like this.

node_modules/
├─ @runmedev/
│  ├─ renderers/          # v3.16 (used by the app directly)
│  └─ react-console/
│     └─ node_modules/
│        └─ @runmedev/
│           └─ renderers/ # v4.0 (used by react-console)

I believe you're right that is indeed a possibility. However, it's best-practice to use a single version across all packages/modules in a mono-repo of this size. Inevitably, the problem remains the same, that you can only register a single component under the same name. To avoid, that problem, one could let the consumer of the either renderers or react-console micro-manage the imports by exporting explicitly and/or in conjunction with a registration mechanism but I think that's over-kill, unless we're talking about a hyperscaler-size monorepo.

To avoid the scenario above, both transitive deps should be bumped to v4.0.

I'm fine using the "Streams interface" if that's more convenient. What matters most to me is that both Runme web and the extension continue to work which both are relying parties.

Is there a better pattern here to make it more testable? Do we want to allow injecting Runme client fakes? What's a good way to make sure both runmeweb and vscode continue to work?

Possibly, more on that in response to your question below.

Can you remind me why we have 2 webcomponents (console-view and runme-console)? It looks like RunmeConsole is binding the console-view to a particular implementation of the logic. Is that just a convenience? Is it necessary for vscode?

Yes, necessary on one hand because VS Code's architecture is set but it's also flexible enough to run inside alternative client architectures, such as "the web app" in this repo.

  1. ConsoleView is just a console (xterm.js + hardened config/addons) with a messaging-interface (to run across multiple sandboxes, e.g. vscode's webview for UI and node.js extension hosts for runner integration).
  2. RunmeConsole wraps ConsoleView and connects it to a reliable (retries, reconnect, etc) WebSocket/gRPC streaming translation layer ("streams.ts") to run ExecuteRequest in bidirectionally streaming in the browser.

I think an alternative would be you just have console-view and you bind it to different handlers; such as a Runme handler. (I think this is where the Streams idea is headed).

The PR I drafted essentially uses ConsoleView directly whereas this PR further generalizes streams.ts which is a possibility. Inevitably both is possible, however, I believe the much bigger question to ask is what TTY/PTY this terminal emulator is supposed to attach to. So far it's be exclusively POSIX/Unix-y Shells. More on that below.

@sourishkrout
Copy link
Contributor

sourishkrout commented Dec 26, 2025

I pushed a dev version from this branch and started using it; seems to be working.

Makes sense.

I find myself needing to implement a line-editor to have the terminal behave as expected (e.g. arrow keys navigate the line; backspace edit the values). Is that expected. codex is telling me that's not provided by xterm.js; xterm.js just provides raw stdin. How does runme-console handle this when you start a shell such as bash? Or maybe it doesn't need to because the actual terminal is handling that for you?

xterm.js and its more concrete derivative ConsoleView or RunmeConsole are just terminal emulators. They render ANSI sequences via stdio using TTYs/PTYs. Interpreting these sequences "from scratch" sounds very tedious and error-prone.

If I understand your goal correctly, I'd recommend using the same "frontend" (RunmeConsole + Streams + ExecuteRequest) but create an alternative ExecuteRequest implementation (possibly via a separate gRPC service using the same/wrapped types) and then hook it up to an "terminal app" built using https://github.com/charmbracelet/bubbletea. It should be straightforward to have the service stream an "atomic" config update (e.g. { "runnerEndpoint": "wss://localhost:9988/ws" } from > addRunner("local", "wss://localhost:9988/ws")) back to the client any time the user took such an action.

@jlewi
Copy link
Collaborator Author

jlewi commented Dec 27, 2025

@sourishkrout Thank you.

I've fallen down a rabbit hole. The TL;DR is

  • I'm moving away from using RunmeConsole and just using ConsoleView and wiring it up myself to Streams
  • I think this means most of the changes in this PR can be reverted;

Here's what happened

  • My app has a model view architecture

  • The "model" is protos (Notebook, Cell) wrapped with some helper classes NotebookData and CellData to provide methods

  • I use useSyncExternalStore to notify the REACt components of changes to the underlying data and re-render

  • I think this is different from how the app in this repo (react-components) is architected

    • I think in that app we effectively destructure the protos into state stored on the REACT components
  • My goal is to add a drop down to the cell which lets folks select the runner as illustrated in the screenshot below

  • In trying to get the RunmeConsole to respond to changes to the runner I found the way RunmeConsole was wired up was getting in the way

  • So I decided to move more to the model view architecture; let my CellData object manage the Streams and then wire that up to the console-view

  • I only have this partially working ; so it may turn out to be a terrible idea but that's the hole I'm digging right now.

Screenshot 2025-12-26 at 4 06 27 PM

@sourishkrout
Copy link
Contributor

sourishkrout commented Dec 27, 2025

I think this is different from how the app in this repo (react-components) is architected

  • I think in that app we effectively destructure the protos into state stored on the REACT components

No. While some of the Context APIs aren't 100% true to this the canonical state is in the protos. Otherwise resuming sessions or serializing them wouldn't work. Also, the latest version of the web app has a static language label (no dropdown yet) which I added at some point:

Screenshot 2025-12-26 at 5 05 46 PM

@jlewi
Copy link
Collaborator Author

jlewi commented Dec 30, 2025

I've stripped this PR down to the bare minimum needed to make my app work. There's really only 2 changes in this PR

  1. Allowing the MessageBridge to be set per element (an alternative to Support Consoles outside of Runner Runner #30 )
  2. Make renderers a peer dependency (an alternative to Only define custom web components once #29)

Of these I think only the first really matters to me. I've changed my app (at least for now) to only use ConsoleView and only import renderers. I guess we'll find out whether this makes sense.

I'm not blocked so we can take our time / wait till after the holiday

Make MessageBridge a per context attribute alternative to #30

What's the thinking in #30 of using namespaces and a module level map to keep track of the bridge? As opposed to making them per module?

I'm slightly confused about what purpose the RendererContext servers in the web-app case. Why do we need the MessageBridge vs just reading from the terminal directly?

IUC messaging is a bit asysmetric

  • We rely on the MessageBridge in order to wire up (read) stdin
  • We write directly to the terminal to send stdout

Even with my changes I found the behavior to be somewhat brittle

  • There were subtle bugs where the per element RendererContext wasn't being set so it ended up falling back to the module level value (e.g. by calling getContext) which lead to errors and unexpected behavior
  • I partially fixed that in my app by setting the module level RenderContext to one that just logs an error

The idea of a namespaced RenderContext as in #29 is confusing to me if we are relying on the RenderContext to wire up stdin. I would have thought each console would need their own to have their own stdin?

Peer Dependency

Based on my grasp of the various issues; I think marking renderers a peer dependency of the others so that the app has a single instance is the correct way to solve the issue of the custom element being rendered multiple times.

That said if you prefer to go with #29 I can just revert this. Since I'm not depending on react-console any more its not a huge concern for me.

Code Reuse In WebApps

I'm not sure whether dropping down to console-view is the right thing to do in a webapp but here's some of why I did it. I wanted more control over the stream creation and I wanted that to be independent of the visual/react components.

For web app could we

  • Move relevant logic in RunmeConsole (e.g. buildExecuteRequest) into reusable functions
  • Provide an example of building your own ReactComponent that wires it up?

is react-console intended for webapps or vscode primarily?

external: [
'@buf/googleapis_googleapis.bufbuild_es',
'@runmedev/renderers',
'@runmedev/react-console',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think it'll work to externalize these. The webapp just imports @runmedev/react-components and you shouldn't have to provide these separately. Is this strictly necessary?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're probably right. I can try reverting this.
Since I'm just importing react-console we can probably revert all the peer dependency changes.

@sourishkrout
Copy link
Contributor

Peer Dependency

Based on my grasp of the various issues; I think marking renderers a peer dependency of the others so that the app has a single instance is the correct way to solve the issue of the custom element being rendered multiple times.

That said if you prefer to go with #29 I can just revert this. Since I'm not depending on react-console any more its not a huge concern for me.

I'm okay with the peer dependency inside the workspace. However, I don't think we should externalize unless there's a very good reason for it.

Code Reuse In WebApps

I'm not sure whether dropping down to console-view is the right thing to do in a webapp but here's some of why I did it. I wanted more control over the stream creation and I wanted that to be independent of the visual/react components.

A variation of Console.tsx is likely what you need. It might be possible to extend it's API as long as it isn't breaking. The way the rendering loop excludes certain props from re-rendering likely doesn't account for runnerEndpoint and languageID now no longer being static. Making the latter two part of "state" should be backwards compatible.

I'm not quite sure why you'd have to go down all the way down to console-view to make the two dropdowns concept work.

For web app could we

  • Move relevant logic in RunmeConsole (e.g. buildExecuteRequest) into reusable functions
  • Provide an example of building your own ReactComponent that wires it up?

is react-console intended for webapps or vscode primarily?

@runmedev/react-console is exclusively for webapps. VS Code relies on the Lit-powered Web Components exclusively.

Again, I think a more flexible Console.tsx as part of @runmedev/react-console or a "sibling" is likely the right abstraction. In fact, I'm unclear why the context bridge injection is required if they all remain backed by Runme's streams.ts.

I'm okay with fine with merging an optional per instance context bridge injection. I'm just not sure if it's really needed.

@jlewi
Copy link
Collaborator Author

jlewi commented Jan 1, 2026

I think I can just revert the peer dependency changes.

I'm not quite sure why you'd have to go down all the way down to console-view to make the two dropdowns concept work.
You probably could make it work but I've rearchitected my app.

Specifically if you look at my screenshot; my UI consists of the "input panels" (cell contents, runner, language) and the "output panels" (CellConsole).

The way the input panels communicate with the output (CellConsole) is via the proto.
When you change input; this updates the Cell proto stored in the corresponding Cell.
CellConsole is subscribed to changes emitted by CellData.
CellData has a function run which gets invoked to execute a cell. This handles instantiating the Streams and notifying the CellConsole of a new runID triggering a re-rendering of the CellConsole.

I found this architecture to be at odds with the way Console & RunmeConsole is architected.

IUC; in Console most of the properties needed for execution (cellID, runID, language) are passed as arguments to the Console component.

  • I think execution within the RunmeConsole gets triggered by a window dispatch event
  • Then I think console subscribes to the observable streams created by the RunmeConsole and turns them into window events

I had a hard time wrapping my mind around this and decided to rearchitect it.

  • I didn't understand why we were mapping observable streams into window events as opposed to just letting code subscribe to the streams and do what is needed
  • Console & RunmeConsole seem like they are bundling buisness logic (e.g stream management) with UI/REACT concerns
    • I could see how this is useful if you just want to be able to drop a component into your app and have it work with minimal wiring required

I'm generally trying to decouble all the business logic from REACT/rendering concerns. My hypothesis is that this will lead to a more testable design e.g.

  • UI interactions can be tested with fakes
  • Business logic (e.g streams management) can be tested independent of the Browser/DOM.

e.g. to the earlier point

The way the rendering loop excludes certain props from re-rendering likely doesn't account for runnerEndpoint and languageID now no longer being static. Making the latter two part of "state" should be backwards compatible.

It seems strange to me that the instantiation of the runner clients is managed by the REACT/UI component.

Now that users are selecting runners by name e.g "local" which is mapped to some endpoint e.g. wss://localhost:9988/ws. You need to do late binding; i.e. wait until the cell is executed to map local to its endpoint. At that point you can just as easily construct the streams and wire it up the observable streams to the terminal.

Context-Bridge Injection

In fact, I'm unclear why the context bridge injection is required if they all remain backed by Runme's streams.t

I'm still very confused by context bridge. It looks like that's how the terminal sends messages and in particular stdin to the app. Without it I couldn't capture stdin and pass them along to runme. Canonical example was being to start a shell e.g. by executing "bash" and then get an interactive shell in the cellconsole. My bridge ends up looking like.

function buildMessagingBridge(
  cellData: CellData,
  winsizeRef: MutableRefObject<{ cols: number; rows: number }>,
) {
  return {
    postMessage: (msg: any) => {
      const stream = cellData.getStreams();
      if (!stream) {
        return;
      }
      if (
        msg.type === ClientMessages.terminalOpen ||
        msg.type === ClientMessages.terminalResize
      ) {
        const cols = Number(msg.output?.terminalDimensions?.columns);
        const rows = Number(msg.output?.terminalDimensions?.rows);
        if (!Number.isFinite(cols) || !Number.isFinite(rows)) {
          return;
        }
        if (winsizeRef.current.cols === cols && winsizeRef.current.rows === rows) {
          return;
        }
        winsizeRef.current = { cols, rows };
        const req = create(ExecuteRequestSchema, {
          winsize: create(WinsizeSchema, { cols, rows }),
        });
        stream.sendExecuteRequest(req);
        return;
      }

      if (msg.type === ClientMessages.terminalStdin) {
        const input = typeof msg.output?.input === "string" ? msg.output.input : "";
        const req = create(ExecuteRequestSchema, {
          inputData: textEncoder.encode(input),
        });
        stream.sendExecuteRequest(req);
      }
    },
    onDidReceiveMessage: (cb: (message: unknown) => void) => {
      const stream = cellData.getStreams();
      if (stream?.setCallback) {
        stream.setCallback(cb);
      }
      return { dispose: () => {} };
    },
  };
}

@sourishkrout
Copy link
Contributor

Specifically if you look at my screenshot; my UI consists of the "input panels" (cell contents, runner, language) and the "output panels" (CellConsole).

The way the input panels communicate with the output (CellConsole) is via the proto. When you change input; this updates the Cell proto stored in the corresponding Cell. CellConsole is subscribed to changes emitted by CellData. CellData has a function run which gets invoked to execute a cell. This handles instantiating the Streams and notifying the CellConsole of a new runID triggering a re-rendering of the CellConsole.

I think I'm tracking.

With the introduction of the output registry, I refactored the terminal console's rendering to be proto-reactive. E.g. based on the presence of stateful.runme/terminal. As said before, absorbing the mentioned properties into reactive state is absolutely possible. Even if they are inputs. The mutation of the protos here causes the terminal to be rendered downstream.

I found this architecture to be at odds with the way Console & RunmeConsole is architected.

I'm a bit confused. You might be conflating Web and React Components "interchangeable" which they are not. The composition hierarchy is below and makes sense because it allows HTML-native components and React/Vue/Svelte derivatives to share core functionality.

flowchart TD
    A["<b>console-view</b><br/><i>Web Component</i><br/>━━━━━━━━━━━━━━<br/>xterm.js with styling,<br/>addons, and<br/>x-sandbox messaging"]
    B["<b>runme-console</b><br/><i>Web Component</i><br/>━━━━━━━━━━━━━━<br/>Websockets messaging<br/>with Golang-backed Runner"]
    C["<b>Console</b><br/><i>React Component</i><br/>━━━━━━━━━━━━━━<br/>Thin wrapper for<br/>WC -&gt; React"]
    D["<b>CellConsole</b><br/><i>React Component</i><br/>━━━━━━━━━━━━━━<br/>Console &lt;&gt; Cell Proto<br/>Coupling"]

    A --> B
    B --> C
    C --> D

    classDef webc fill:#e1f5ff,stroke:#01579b,stroke-width:2px
    classDef reactc fill:#f3e5f5,stroke:#4a148c,stroke-width:2px

    class A,B webc
    class C,D reactc
Loading

Again, in my mind (ignoring ContextBridge) there's no need to touch anything below the React-fold because it's not beholden to React's reactive rendering loop. The APIs for those components aren't set in stone and for breaking changes, it's easy enough to write one's own.

IUC; in Console most of the properties needed for execution (cellID, runID, language) are passed as arguments to the Console component.

I think execution within the RunmeConsole gets triggered by a window dispatch event
Then I think console subscribes to the observable streams created by the RunmeConsole and turns them into window events

Using vanilla DOM events here is just a way to make keyboard shortcuts work across the whole app and avoid prop-drilling. Ideally running a cell would be part of CellContext and the events attached there. Didn't have time to refactor this as part of the "component registry".

I had a hard time wrapping my mind around this and decided to rearchitect it.

  • I didn't understand why we were mapping observable streams into window events as opposed to just letting code subscribe to the streams and do what is needed

  • Console & RunmeConsole seem like they are bundling buisness logic (e.g stream management) with UI/REACT concerns

    • I could see how this is useful if you just want to be able to drop a component into your app and have it work with minimal wiring required

I'm generally trying to decouble all the business logic from REACT/rendering concerns. My hypothesis is that this will lead to a more testable design e.g.

  • UI interactions can be tested with fakes
  • Business logic (e.g streams management) can be tested independent of the Browser/DOM.

e.g. to the earlier point

The way the rendering loop excludes certain props from re-rendering likely doesn't account for runnerEndpoint and languageID now no longer being static. Making the latter two part of "state" should be backwards compatible.

It seems strange to me that the instantiation of the runner clients is managed by the REACT/UI component.

It's not. I agree that it would be "wrong" to that. That's why runme-console is wrapping console-view. Another reason is to contain styles at a lower-level in the shadow-DOM to avoid conflicts/bleeding into design systems.

Now that users are selecting runners by name e.g "local" which is mapped to some endpoint e.g. wss://localhost:9988/ws. You need to do late binding; i.e. wait until the cell is executed to map local to its endpoint. At that point you can just as easily construct the streams and wire it up the observable streams to the terminal.

As said above, this is entirely a concern of Console and CellConsole. runme-console does not care what the source of the endpoint config (just an attribute) is much like how an img element is agnostic to the src (e.g. URL or Base64 encoded data) and it's the consumer's concern.

Bottom-line: Some minor refactoring to the context bridge notwithstanding (prototype holdover), above's architecture cleanly separates concerns for both testability and working across VS Code, this webapp, and doc sites integrations etc.

These layers of indirection might seem unintuitive in a pure React application. However, outside of React ecosystem lock-in it would add costly rendering loop reconciliation cost that only slows the lower non-app layers down.

@sourishkrout
Copy link
Contributor

sourishkrout commented Jan 2, 2026

Context-Bridge Injection

In fact, I'm unclear why the context bridge injection is required if they all remain backed by Runme's streams.t

I'm still very confused by context bridge. It looks like that's how the terminal sends messages and in particular stdin to the app. Without it I couldn't capture stdin and pass them along to runme. Canonical example was being to start a shell e.g. by executing "bash" and then get an interactive shell in the cellconsole. My bridge ends up looking like.

Correct. The API is broader than what is being used. The fact that it was/is set as "singleton" is a holdover from the prototype days where getting something working was paramount.

The webcomponents are reusing VS Code's Renderer Messaging API for two reasons: a) it's stable API to x-sandbox (Javascript runtime) communicate without rolling our own, and b) it allows bringing existing renders, e.g. Jupyter's Renderers (plots, tables, etc) to brought into the webapp.

The API itself is documented here: https://code.visualstudio.com/api/extension-guides/notebook#notebook-renderer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants