Visual modeling in the VS Code extension¶
How the extension grows from a text-only language client into a surface a model can be read as a diagram and, later, authored through. This note is the design for three tiers, each of which is shippable on its own:
| Tier | What a user gets | Authoring surface |
|---|---|---|
| 1 | A diagram panel that re-renders as the file is typed, with click-to-source | text only |
| 2 | Diagram-side actions that add and change elements | text, written by the tool |
| 3 | A drag-and-drop diagram with persisted layout | diagram, text kept in step |
The tiers are ordered by what they require of the Go side: tier 1 needs a rendering carried over the wire, tier 2 needs the source-rewriting layer widened, tier 3 needs a place to keep layout that is not the model.
What exists today¶
internal/core/viewrenders a view of the semantic model into aRendering: nodes, edges, table rows, and the notices for what the kind could not represent. Five kinds are produced —tree,interconnection,state,action,table— read fromsemantics.Model.ExposedElements, the model's connectors, and the loweredActionGraph/StateGraph, never from source text.Rendering.Writewrites it astext,mermaidormarkdown.- The frontends that use it are
sysml <model> -render <view> -render-form mermaidand the REPL's%view/%render.Session.viewRenderer(internal/repl/view.go) is the pattern: build a resolver and asemantics.Modelover the browse index, handview.NewRendereraSourceTextso verbatim labels read as written. internal/lspserves completion, hover, diagnostics, document and workspace symbols, semantic tokens, definition, references, rename, formatting and code actions over onemodel.Workspace.Server.applyDidChangefolds each keystroke into the workspace and republishes diagnostics, debounced for the other open documents.internal/core/editrewrites the source a model was parsed from without disturbing its comments or layout: anOperationnames an element by FQN, only the bytes the parse says carry that element's name or value are replaced, and the result is re-parsed and re-analyzed before it is handed back — an edit that would make the model unreadable is refused. It has two operations,OpSetValueandOpRename, and one caller,internal/grpc/edit.go.editors/vscodecontributes the two grammars, the language configuration, theopensysml.*settings, oneSysML: Restart Language Servercommand, and aLanguageClientoversysml-lspfound on the setting, in the workspace'sbin/, or onPATH. There is no webview, no diagram and no command that writes a model.
So the renderer is the part that would have been hardest, and it is already
spec-anchored and tested. What is missing is a transport, a panel, and — for
authoring — more operations in edit.
Tier 1 — live diagram panel¶
The request¶
A custom LSP request, because the rendering must come from the same analysis the
diagnostics come from. Re-parsing in the extension would drift, and shelling out to
sysml -render per keystroke would pay for the standard library each time.
opensysml/render (request)
params: { textDocument: { uri }, view?: string, form?: "mermaid" | "text" | "markdown" }
result: {
view: string, // qualified name, as the notation writes it
kind: string, // tree | interconnection | state | action | table
stated: string, // how the kind was decided, "" for the default
form: string, // the form actually written
artifact: string, // the Mermaid / text / Markdown document
nodes: [ { id, kind, name, detail, origin? } ],
edges: [ { from, to, label, kind } ],
notices: [ string ],
version: number // the document version the rendering was made from
}
opensysml/views (request)
params: { textDocument: { uri } }
result: { views: [ { name, kind, supported, reason? } ] }
opensysml/renderChanged (notification, server → client)
params: { textDocument: { uri }, version: number }
opensysml/views is what fills the panel's view picker, and it reports an
unsupported kind (sequence, geometry, textual) with the reason rather than
omitting it, so the panel can say why a view cannot be drawn.
origin on a node is { uri, range }. view.Node carries no source location
today; tier 1 adds one, as a source.Span plus the document it belongs to, set
where the node is built from a symbol. It is additive: the text and Mermaid forms
ignore it, so their goldens do not move.
opensysml/renderChanged exists so the server, not the client, decides when a
rendering is stale — it is emitted after the same analysis that publishes
diagnostics, on the same debounce as the cross-document sweep
(crossDocRefreshWindow, 200ms). The client answers it with a fresh
opensysml/render. Push-the-notification/pull-the-artifact keeps a large diagram
off the wire when the panel is hidden.
Capability: the server advertises experimental: { openSysmlRender: true } in
initialize, and the client only registers the panel when it sees it, so an old
server and a new extension degrade to today's behavior instead of erroring.
The Go side¶
view.Nodeandview.Edgegrow an origin.Renderinggrows aJSON()-shaped companion ininternal/core/view— a plain data type inview, marshaled by the LSP layer, soviewkeeps no protocol knowledge.model.WorkspacegrowsRenderView(doc, fqn string) (*view.Rendering, error)andViews(doc string) []ViewInfo, built onnewResolverexactly asSession.viewRendererbuilds its own, withSourceTextreading the workspace's content for the document. This is where the REPL and the LSP converge: the REPL's helper stays, but both go through one workspace-level entry point.internal/lspgainsrender.gohandling the two requests and emitting the notification, wired through the samechangeHandler/AsyncHandlerchain. A request naming no view renders the single view in the document, and reports the ambiguity when there are several.
A document with no view declaration is the common case for a model being written,
and a panel that can only draw declared views would be empty most of the time. So
opensysml/render accepts, in place of a view name, a #tree / #state:<fqn> /
#action:<fqn> / #interconnection:<fqn> pseudo-view: the server renders the
element as if a view exposing it had been declared, through the same renderer, and
reports stated: "no view declared; rendering <element> directly". Nothing
synthetic is added to the model or the index — the exposed set is passed to the
renderer directly.
The extension side¶
SysML: Open Diagramopens aWebviewPanelbeside the editor, one per document, retained across tab switches withretainContextWhenHiddenoff and state restored throughsetState/getState.- The webview bundles Mermaid locally (no CDN, and a
Content-Security-Policywith a nonce and noconnect-src), renders the artifact, and re-renders on the extension'spostMessage. - Clicking a node posts its id back; the extension maps id → origin and calls
vscode.window.showTextDocumentwith that range selected. The reverse — cursor in the editor highlights the node whose origin contains it — comes fromonDidChangeTextEditorSelection. - A rendering that fails (a parse error mid-keystroke) leaves the last good diagram on screen, dimmed, with the error in the panel's status line. Blanking the panel on every incomplete keystroke makes it unusable while typing.
- Notices are shown as a collapsible list under the diagram, not dropped, matching what the text form does.
Test contract¶
internal/core/view: origins are covered by the existing render tests, extended to assert that each node's origin spans the declaration it was built from, and that the text/Mermaid goldens are unchanged.internal/lsp/render_test.go: request/response over the in-process server for each kind, for a pseudo-view, for a document with no views, for an unsupported kind (asserting the reason), and for a stale-version request. Plus a didChange →renderChangedordering test.editors/vscode:npm run typecheckand a GUI pass per.agents/skills/testing-vscode-extension/SKILL.md— open a model, open the panel, type, watch it redraw, click a node and land on the declaration.
What tier 1 is not¶
It is a viewer. The model is authored in text; the diagram never writes.
Tier 2 — authoring through diagram actions¶
The diagram gains a palette and a context menu whose actions are text edits: the
.sysml file stays the single source of truth, and the diagram is always a
rendering of what the file now says. This is the tier that makes "create models
visually" true without a graphical editor's bookkeeping.
Widening internal/core/edit¶
Three operations are added, in the package's existing style — name the target the way symbols name it, splice bytes the parse located, re-analyze before returning:
OpAddMember{Owner, Kind, Name, Type}inserts a member into an owner's body:part engine : Engine;intopart def Vehicle { … }. The insertion point is the end of the owner's body span, indented to the body's own level, and an owner declared without a body gets one. The notation is emitted by a small writer inedit, then the whole document is passed throughinternal/core/formatso the result matches the file's indentation conventions.OpAddConnection{Owner, Kind, From, To, Name}inserts aconnect a to b;,flow,interface,successionortransitioninto the owner's body, with the endpoints written as the names that resolve from that scope.OpDelete{Target}removes a declaration and the trivia that belongs to it: its leading comment block and its own line, never a neighbor's.
Each refuses rather than writes when re-analysis reports an error the original did
not have — an added member whose type does not resolve, a connection whose endpoint
is out of scope, a delete that orphans a reference. The refusal names the
diagnostic, and the panel shows it. OpSetValue and OpRename already exist and
are reused as-is for editing a value or a name from the diagram.
Deletion is where a "refuse on new errors" rule is least obviously right: deleting a
part that something connects to should be reported. The operation therefore
carries Cascade bool; without it the delete is refused and names the referents,
with it the referring declarations are deleted in the same operation, and the panel
asks before setting it.
From the diagram to the file¶
opensysml/applyModelEdit (request)
params: { textDocument: { uri }, version: number, operations: [ Operation ] }
result: { edit: WorkspaceEdit } | { refused: [ { operation: number, diagnostic } ] }
The server translates the operations into edit operations, runs them against the
workspace's current content, and returns the byte diff as a WorkspaceEdit — it
does not write the file. The client applies it with
vscode.workspace.applyEdit, which puts the change in VS Code's undo stack, so a
diagram action is undone with ctrl+z like anything typed. A version that no
longer matches is rejected, and the panel re-requests and retries once.
The diagram never mutates itself. It applies the edit, the edit re-triggers
analysis, analysis emits renderChanged, and the panel redraws from the model. One
direction of truth, so a diagram that disagrees with the file is not
representable.
The palette¶
Which actions are offered is decided by the rendering's kind, not hardcoded:
interconnection offers parts, ports and connections; state offers states,
transitions, entry/exit; action offers action nodes, successions, forks and
joins; tree offers a member of any definition kind. The names come from the same
tables view already keys its kinds by, so a kind added later does not need the
palette rewritten.
Test contract¶
internal/core/edit: per operation, a golden pair (source in, source out) proving comments, blank lines and indentation survive; a refusal test per new-error class; a cascade-delete test; an idempotence test throughformat.internal/lsp:applyModelEditreturning aWorkspaceEditwhose application reproduces the golden output, a stale-version rejection, and a refusal shape.- GUI: add a part and a connection from the palette, check the file,
ctrl+z, check the file again.
Tier 3 — a drag-and-drop graphical editor¶
Tiers 1 and 2 avoid the two problems a real graphical editor has: layout, and edits whose intent is not a text operation. Tier 3 takes them on.
Layout is not model data¶
A node's position is not in the model and must never be written to the .sysml
file — that would put tool state into a spec-conformant document and make two
authors' files differ by nothing but pixels. Layout lives beside the model, in
<model>.sysml.layout.json, an explicitly tool-defined sidecar:
{ "schema": 1, "view": "Views::vehicleView", "kind": "interconnection",
"nodes": { "Vehicle::engine": { "x": 120, "y": 40, "w": 160, "h": 80 } },
"edges": { "Vehicle::c1": { "waypoints": [ [200,80], [260,120] ] } } }
Keys are FQNs, not the rendering's node ids: an id is assigned per render
(nodeIDs.take) and is not stable across edits, while an FQN is as stable as the
model. An element with no entry is auto-placed by the layout engine and its
position written back on first drag; an entry whose element is gone is dropped on
save. The sidecar is optional, and committing it is the user's choice — a model
opened without one still draws, just auto-laid-out.
Direct manipulation¶
Dragging a node changes layout only, so it touches the sidecar and not the model.
Every action that changes the model goes through tier 2's operations — drawing an
edge between two nodes is OpAddConnection, dropping a palette item is
OpAddMember, deleting a node is OpDelete, renaming in place is OpRename. So
tier 3 adds no new way to write a model; it adds gestures over tier 2's vocabulary.
Two things genuinely new:
- Re-parenting (dragging a part into a different definition) is a move: delete
from one body and add to another, as one operation so a failure leaves neither
half applied.
editgainsOpMove{Target, NewOwner}for it, rather than the client issuing two operations it cannot make atomic. - Batching. A drag that creates several elements at once must be one
WorkspaceEditso it is one undo step.applyModelEditalready takes a list; tier 3 requires that the list is applied all-or-nothing, which is howeditcomposes operations already.
Rendering surface¶
Mermaid is a fine read-only renderer and a poor editing surface — it lays out the
graph itself and exposes no handles. Tier 3 replaces the webview's renderer with an
SVG canvas driven by the rendering's nodes and edges plus the sidecar's geometry,
keeping Mermaid as the export path (SysML: Export Diagram) so the docs pipeline
and tier 1's panel keep working. The panel is registered as a
CustomTextEditorProvider for .sysml, so a user can open a model as a diagram
and VS Code handles dirty state, undo and save.
Test contract¶
- Sidecar: schema round-trip, unknown-element pruning, missing-file defaulting, and a golden auto-layout for a fixture model.
edit:OpMovegoldens, and an all-or-nothing test where the second operation of a batch is refused and the source is unchanged.- GUI: draw a connection between two dragged nodes, save, reopen, check the layout and the model both came back.
Known limitations, stated rather than hidden¶
- The
sequenceandgeometryview kinds are not rendered byinternal/core/viewand no tier here adds them; the panel reports them as unsupported. - Multi-document models render per document. A view exposing elements from another open file draws them, but the panel is anchored to one document's URI, and a cross-document layout sidecar is out of scope.
- Tier 3's layout sidecar is tool-defined. SysML v2 §10.2 leaves how a view is drawn to the tool, so nothing here claims to be a normative diagram interchange, and no attempt is made to read or write another tool's layout.
- The palette writes the notation OpenSysML's writer emits, which is
spec-conformant but not necessarily byte-identical to what a user would have
typed.
formatmakes it consistent with the file; it does not make it a particular author's style.