gpty-gdext
Godot 4 GDExtension that bridges the Rust terminal engine to Godot’s renderer.
GptyTerminal is a headless Node2D bridge owning the Rust grid; the visible
terminal is the Control-based renderer
godot/scenes/terminal/terminal_pane.gd, which polls
get_grid_updates_packed() and draws in _draw().
Building
# From the workspace root
cargo build -p gpty-gdext && cd godot && godot -egpty.gdextension always loads res://bin/libgpty_gdext.linux.x86_64.so.
For editor work, that file should be a symlink to target/debug/libgpty_gdext.so
so cargo build -p gpty-gdext is enough. Restart Godot after each rebuild.
./scripts/build replaces the symlink with a release copy for export.
GDScript API
The implementation is filed by facet under src/, one #[godot_api(secondary)] impl block
per file - lib.rs holds the class itself (session lifecycle, input, the grid access
helpers), grid_api.rs, history_api.rs, vault_api.rs, capture_api.rs and bridge_api.rs hold the
rest. The sections below are those facets.
GptyMarkdown (extends RefCounted)
| Method | Returns | Description |
|---|---|---|
render(markdown: String, wikilinks: bool) |
String |
Convert untrusted CommonMark/GFM to sanitized RichTextLabel BBCode. wikilinks renders [[target]] / `[[target |
Raw BBCode and HTML are escaped, images render as alt text, and only
http, https, and mailto links become metadata. Godot asks for
confirmation before MarkdownView opens an external link; a gpty-note:
mention is emitted by this renderer alone under wikilinks, and the view
hands it to its owning pane (never to OS.shell_open).
The output never uses Godot’s list tags: [ul]/[ol] lay a document out
superlinearly in the item count (measured: 38915 ms to shape 1.7 MiB of
two-item lists against 819 ms for that same rendered text with the tags
stripped, and 146705 ms for a real 1 MiB note), so each item is a • /N.
line inside one [indent] per list - identical on screen and linear to lay
out. The numbering is the renderer’s own, which is also why an ordered list
that starts at 3 renders as 3. rather than 1..
The input cap (MAX_MARKDOWN_BYTES) is the same 1 MiB the panes read under, so
a document at a pane’s read cap renders whole, and a source past it - a pasted
buffer, a routed capture, a long model turn - is what the answer’s truncated
reports. MarkdownView shows that, and a cut its caller made
(set_source_notice), as one banner above the body.
GptyAi (extends Node)
One private Inspector session. There is no process-global subscriber bus.
| Method | Returns | Description |
|---|---|---|
session_open(request_json: String) |
String |
Open a mock/omp session. JSON: {backend, system_prompt, cwd, model} |
session_prompt(request_json: String) |
String |
Submit one turn. JSON: {session_id, capture, concept_name, source_pane} |
session_poll(request_json: String) |
String |
Drain correlated envelopes. JSON: {session_id, max_events} |
session_cancel(request_json: String) |
String |
Cancel the active turn. JSON: {session_id} |
session_close(request_json: String) |
String |
Tear down the backend process. JSON: {session_id} |
list_backends() |
String |
JSON array of {kind, name, available} |
Inspector omp is launched as omp --mode rpc --no-session --no-tools --no-extensions --no-skills --no-rules.
GptyCliView (extends Node)
One cli_view pane’s CLI process: a program plus argv, never a shell. The
child’s stdout and stderr are merged into one bounded queue of complete lines,
which the pane drains once a frame and renders as plain text. The node strips
the workspace credentials (GPTY_SECRET, GPTY_SOCKET, …) from the child’s
environment, resolves a bare command through PATH and holds an absolute one to
validate_executable, and kills the child and its process group on stop and
on drop.
| Method | Returns | Description |
|---|---|---|
start(config_json: String) |
String |
Start the CLI. JSON: {command, args} - command non-empty and ≤1024 chars, args ≤32 entries of ≤4096 chars, no U+FFFD in either. {"ok":true,"pid":N} or {"ok":false,"error":"..."}. An existing process is stopped first |
poll_lines() |
PackedStringArray |
Every complete line since the last call, stdout+stderr merged. Non-blocking |
status_json() |
String |
JSON: {program, args, pid, running, exit_code, exit_reason}. pid is null once the child is gone; exit_reason is exited, killed (stopped by us) or null while it runs. A pane that never started reports the same fields idle |
stop() |
void | Kill the child and its process group. Safe with nothing running |
GptyMedia (extends Node)
One media pane’s decode session, backed by gpty-media and a pinned FFmpeg
build. All decode work happens on the session’s own OS thread; these methods
only hand over JSON or drain a bounded queue.
| Method | Returns | Description |
|---|---|---|
open(config_json: String) |
String |
Open a file, replacing the current session. JSON: {path, sample_rate, channels, max_width, max_height, video, audio}; sample_rate defaults to 48000, channels to 2, the box to native size, and both stream flags to true (disable one when the caller will not drain its queue). The path is validated synchronously and returns {"ok":true,"state":"opening"}; probing/decoding runs on the session thread, and status() then reports Playing with info ({container,duration_us,video,audio,streams}) or Failed with an error. A refused path is {"ok":false,"error":{"code","message"}} immediately |
poll_video() |
Dictionary |
{} when empty, else {width,height,pts_us,keyframe,data} with data a packed RGBA8 array of width * height * 4 bytes |
poll_audio() |
Dictionary |
{} when empty, else {sample_rate,channels,pts_us,data} with data interleaved f32 at the requested rate/channels |
seek(position_us: int) |
String |
Acknowledged seek; pre-target frames are dropped. {"ok":true} or an error envelope |
status() |
String |
{ok,state,position_us,duration_us,info,error}; state is opening, playing, finished, failed or closed. finished keeps the worker alive for a replay |
close() |
void | Stop the decoder and release the file |
Errors use stable code values (bad_path, unsupported_container,
unsupported_codec, too_large, open_failed, decode_failed,
seek_failed, closed, io_error, plus bad_request/panic at the JSON
edge). Media files are untrusted input: the backend re-validates the path,
checks the demuxer and the selected codec id, caps dimensions/channels/queues,
and compiles no network protocol.
GptyTerminal (extends Node2D)
Terminal lifecycle
| Method | Returns | Description |
|---|---|---|
start_shell(cmd: String, rows: int, cols: int, envs: String, pane_id: String, history_lines: int, args_json: String, cwd: String) |
void | Start a PTY session (injects per-PTY event capability, GPTY_ENV=1, GPTY_PANE_ID); attaches the SQLite history store keyed by pane_id and restores the newest history_lines rows into scrollback. args_json is a JSON array of program arguments (≤32 entries, ≤4096 chars each) - ["-c", cmd] runs through a shell. An absolute cmd must name a regular file that is not group/other-writable and is owned by this user or root; bare names resolve through PATH. cwd is the workspace’s directory: empty inherits gpty’s own, and a non-absolute or missing path is ignored rather than failing the pane |
send_text(text: String) |
void | Send raw text to PTY (no newline) |
send_line(text: String) |
void | Send a line to PTY (appends \n) |
resize_grid(rows: int, cols: int) |
bool |
Resize grid + send SIGWINCH. false = refused (grid lock poisoned) and nothing moved, PTY included; a pane with no shell answers true |
set_palette(hex_csv: String) |
void | Load color scheme (16 hex colors, CSV) |
get_terminal_session_id() |
String |
Opaque id for the current PTY lifetime |
App metadata
| Method | Returns | Description |
|---|---|---|
get_app_version() |
String |
App version from CARGO_PKG_VERSION (static - call as GptyTerminal.get_app_version()) |
get_ipc_protocol_version() |
String |
Static - the control-IPC protocol version (gpty_ipc::protocol::PROTOCOL_VERSION), the single definition the server’s version method reports and the CLI prints; the About tab reads it so the label cannot drift from the wire |
installed_plugin_profiles() |
String |
Static - JSON array of {plugin_id, revision, name, tiles}, one entry per profile of every enabled installed plugin, in store order (tiles are the manifest’s raw tile dictionaries; the caller sanitizes each field). Reads <state_dir>/plugins.json; a missing or broken store is [] plus one warn, never an error. Plugins installed before the profiles migration, and profile entries without a string name or a tiles list, contribute nothing |
omp_event_stats() |
String |
Static - the OMP event pipeline’s counters: cumulative accepted/accepted_bytes/drained_events/drained_bytes, rejected_queue_full/rejected_oversized, and the live queued_events/queued_bytes against max_queued_bytes/max_session_events. The Reasoning pane’s GPTY_REASONING_DEBUG line reads it to separate a slow renderer from a lossy input stream |
Vault index
| Method | Returns | Description |
|---|---|---|
vault_index(vault_path: String) |
String |
Static - walk a vault directory, refresh its index and answer the note list. JSON: {notes: ["note.md", "sub/other.md", …], truncated, added, updated, removed, unreadable} (notes sorted by path, hidden entries skipped, bounded at 2000 notes / depth 16). error is present only when the path cannot be used or the store cannot be opened |
vault_search(vault_path: String, pattern: String, limit: int) |
String |
Static - FTS5 search within one vault, best rank first. JSON: {results: [{path, title, snippet}], total}; free text sanitised into quoted, ANDed terms, notes porter-stemmed, limit clamped 1–500. error only when the query could not run, so a caller can tell “no match” from “not indexed” |
vault_links(vault_path: String, rel_path: String) |
String |
Static - every [[wikilink]] of one note, as the index pass stored it: {"outgoing": [{"target", "resolved", "line", "seq"}, …] (one row per occurrence; resolved is null while the target does not resolve), "backlinks": [{"src", "line", "target"}, …] (the same edges read in reverse), "unresolved": [{"target", "line", "candidates": […]}, …] (candidates name the notes an ambiguous stem could not be chosen between). error` only when the vault or the store cannot be used |
vault_graph(vault_path: String) |
String |
Static - the vault’s link graph as a canvas reads it. JSON: {"nodes": [{"id", "label", "ghost"}, …], "edges": [{"src", "dst", "count", "ghost"}, …], "truncated"}. Nodes are the indexed notes (id = vault-relative path, label = indexed title) plus one ghost:<target> node per distinct unresolved target; edges aggregate one row per ordered (src, dst) pair with its mention count - resolved rows first, then unresolved ones, each ordered by count DESC then names. Caps: 10,000 edges per class and 2,000 ghost nodes (truncated says a cap cut the answer). error only when the vault or the store cannot be used |
vault_link_resolve(vault_path: String, target: String) |
String |
Static - resolve one [[target]] against the vault’s note set with the index’s own rules (exact case, then a unique case-insensitive match; a / names a path; an ambiguous stem is never picked). Answers {resolved: "sub/note.md", candidates: []}, or resolved: null with the sorted candidates for an ambiguous stem, or resolved: null with no candidates when nothing matched. The follow path for a mention - including one typed but not yet saved; error only when the vault or the store cannot be used |
vault_note_read(vault_path: String, rel_path: String) |
String |
Static - load one note for the editor. JSON: {text, truncated, crlf, mtime, size} (the text at the 1 MiB cut, truncated when that cut hit a larger file, crlf when the file uses CRLF endings, and the stamp a save checks), or {error, code}. Only a regular file at a vault-relative .md path the walk could have produced is read |
vault_note_write(vault_path: String, rel_path: String, content: String, expected_mtime: int, expected_size: int, force: bool) |
String |
Static - replace an existing note atomically (hidden sibling temp, renamed over the target, keeping the note’s own mode) and re-index the vault. The resolved path must stay inside the root (a symlink escaping it is refused, one pointing at a sibling note is written through); the file must exist - this edits, never creates. A stamp different from expected_mtime/expected_size answers code: "changed_on_disk" unless force is true. JSON: {mtime, size, indexed} - indexed: false means saved but not searchable yet - or {error, code} |
vault_note_create(vault_path: String, rel_path: String, content: String) |
String |
Static - create one note and re-index the vault. The path follows the save vocabulary and its directory must already exist inside the vault (never created, never through a directory symlinked out); the file is claimed with create_new, so an existing note answers code: "exists" and nothing is ever replaced. JSON: {mtime, size, indexed} or {error, code} |
vault_note_rename(vault_path: String, rel_path: String, new_rel_path: String) |
String |
Static - move one note within the vault and re-index it. Both paths follow the vocabulary, the destination’s directory must exist inside the vault, and the destination is claimed before the rename (code: "exists" instead of an overwrite). The vault entry moves: a symlinked note moves as a link, its target untouched. A rename preserves the file’s mtime/size, so the caller’s stamp stays valid. JSON: {path, indexed} or {error, code} |
vault_link_rewrite_preview(vault_path: String, rel_path: String, new_rel_path: String, include_renamed: bool) |
String |
Static - what a rename’s reference update would touch, writing nothing. rel_path is the old name and new_rel_path the current one: the pass reads the vault after the move and reconstructs the old note set, so only occurrences that resolved to the old name are considered (an ambiguous stem is not one). include_renamed adds the renamed note itself - the pane passes its buffer’s cleanliness, because a dirty buffer must never be written under. JSON: {files, mentions, skipped, truncated} - notes and occurrences that still name the old target, notes that could not be examined (unreadable or over the read cap), and whether the walk’s note cap cut the vault. Answers {error, code} for a vault, path or new name that cannot be used - a new name a [[link]] cannot spell is bad_path |
vault_link_rewrite(vault_path: String, rel_path: String, new_rel_path: String, include_renamed: bool) |
String |
Static - apply the planned reference update and re-index the vault. Only the targets that resolved to the old name are respelled, alias and # fragment are preserved, line endings and every other byte stay as they were, and fenced code and code spans are untouched. include_renamed makes the renamed note itself a candidate when its buffer is clean; with it, the answer carries renamed_note: {text, crlf, mtime, size} - the body that landed and its fresh stamp, for the editor to adopt in place instead of reading the file again. Each write is vault_note_write’s, and the pass re-plans and writes in one call, so an edit made while the question was open is read fresh; a note that changes between that plan and its write answers changed_on_disk and counts as skipped. JSON: {files, mentions, skipped, truncated, indexed, renamed_note?} or {error, code} |
vault_note_delete(vault_path: String, rel_path: String) |
String |
Static - delete one note and re-index the vault. Only an existing note can be deleted; the operation acts on the vault entry (a symlinked note is unlinked, its target untouched). JSON: {indexed} or {error, code} |
vault_scratch_read(vault_path: String, rel_path: String) |
String |
Static - the recovery copy of one note’s unsaved buffer, written by the editor so a crash cannot take it. JSON: {found: false} when there is none (the normal state, and the answer for a copy that cannot be trusted) or {found: true, text, mtime, size} - the buffer, and the stamp of the file revision it was typed against, so a save restored from it still checks the file the user saw. error only when the vault or the note path cannot be used |
vault_scratch_write(vault_path: String, rel_path: String, text: String, expected_mtime: int, expected_size: int) |
String |
Static - snapshot one note’s unsaved buffer as its recovery copy. Goes to gpty’s own state directory (state_dir()/scratch, owner-only) and never to the vault - this is not a note write - and is atomic (hidden random temp, renamed over the previous copy). Each write also prunes the directory: a copy whose own record names a vault that is gone is reclaimed, as is a temp file older than an hour; a vault that cannot be read and a record that cannot be interpreted are left alone. expected_mtime/expected_size are the stamp the buffer was loaded from, recorded so a restore after the file moved on still asks. JSON: {ok: true} or {error, code} |
vault_scratch_clear(vault_path: String, rel_path: String) |
String |
Static - drop one note’s recovery copy: its buffer either reached the file or was deliberately discarded. An absent copy is not an error. JSON: {ok: true} or {error, code} |
Pane API & status
| Method | Returns | Description |
|---|---|---|
get_plain_text(limit: int) |
String |
Plain-text snapshot of screen + scrollback, newline-joined, capped 1–2000 lines (backs paneRead) |
get_status() |
String |
JSON: {pid, running, exit_code, exit_reason, idle_ms, agent_state, agent_state_tier} (backs paneStatus). running requires a live terminal task and no exit code; exit_reason is exited, task_ended (the task died without recording one - a panic, or a child that closed its pty and outlived the pane) or null while it runs |
search_history(pattern: String, limit: int) |
String |
JSON {"results": [[line_num, text], ...]} - FTS5 search of the pane’s persisted scrollback, newest-first (limit 1–500; free text as a user typed it, sanitised into quoted terms, so punctuation such as main.rs is searchable) |
check_lines(pattern: String) |
String |
First recent line matching the Rust-regex pattern, or empty (backs waitForOutput) |
emit_event(json: String) |
void | Static - fan a JSON event out to event-socket subscribers |
Grid & rendering
| Method | Returns | Description |
|---|---|---|
get_grid_updates_packed(force_full: bool) |
Dictionary |
Grid update in packed arrays - see Grid Update Dictionary |
get_grid_generation() |
int |
Monotonic counter, changes on grid update |
get_cursor_row() |
int |
Cursor row (0-based, -1 if none) |
get_cursor_col() |
int |
Cursor column (0-based) |
get_cursor_shape() |
int |
0=Block, 1=Underline, 2=Beam |
get_title() |
String |
Terminal window title (OSC) |
get_rows() |
int |
Grid row count |
get_cols() |
int |
Grid column count |
Scrollback & search
| Method | Returns | Description |
|---|---|---|
scroll_up(lines: int) |
void | Scroll back in history |
scroll_down(lines: int) |
void | Scroll forward in history |
scroll_reset() |
void | Reset scroll to follow output |
get_scroll_offset() |
int |
Lines above visible viewport |
get_history_size() |
int |
Total scrollback lines available |
search_grid(pattern: String) |
Dictionary |
Search scrollback for regex pattern |
key_to_bytes(keycode, shift, alt, ctrl, meta) |
PackedByteArray |
Convert key event to terminal byte sequence |
Selection
The selection is held by the grid (alacritty_terminal::Term::selection), not by the viewport: scrolling moves the highlight with its text, output rotates it with the grid, and the text read back for a copy includes rows scrolled out of view.
| Method | Returns | Description |
|---|---|---|
selection_start(row: int, col: int) |
void | Begin a selection at a viewport cell (0-based row) |
selection_extend(row: int, col: int) |
void | Extend the live selection to a viewport cell |
selection_clear() |
void | Drop the selection |
get_selection_text() |
String |
Selected text from grid rows (scrollback included), "" when none |
get_selection_cells() |
PackedInt32Array |
(row, first column, last column) triples in viewport coordinates for drawing |
Concept engine
| Method | Returns | Description |
|---|---|---|
set_global_concepts(concepts_json: String) |
void | Static - replace all concepts in the engine (JSON array of concept objects with name, trigger, optional conditions regex array, enabled, capture_mode, and actions; parse caps and timeout clamp in gpty_core::concept::concepts_from_json) |
validate_regex(pattern: String) |
String |
Static - validate a user-authored regex against the engine’s dialect: "" when accepted, else the error. GDScript’s RegEx is PCRE2 and accepts look-around the Rust regex crate rejects, which concepts_from_json silently drops |
get_global_concepts() |
Array |
Static - get all concepts as Dict array (each carries conditions, possibly empty) |
drain_concept_events() |
Array |
Drain completed capture events from this terminal |
drain_concept_notices() |
Array |
Drain notify-only concept matches (capture_mode: "single_line"); returns [{concept_name}]. Metadata only - the matched line is never published |
acknowledge_capture(event_id: int) |
void | Discard captured bytes (receiver consumed output) |
flush_capture(event_id: int) |
void | Feed captured bytes to grid (no receiver) |
IPC bridge (static methods)
| Method | Returns | Description |
|---|---|---|
drain_ipc_requests() |
Array |
Drain queued control IPC requests as [{id, method, params, timeout_ms}]. timeout_ms is the request’s remaining fallback deadline (0 once it has passed); the deferred-answer dialogs arm their expiry timer from it, so a dialog cannot outlive the request that asked for it |
respond_ipc(id, success, result_json) |
void | Respond to a drained IPC request |
take_shutdown_request() |
bool |
Whether a client asked the GUI to quit (true once, then false). Polled each frame; the quit runs through the scene tree so _exit_tree still saves |
take_startup_failure() |
String |
Why this instance is quitting itself, when it could not serve the pane API (a refusal to replace a live socket or pipe) - empty when the quit came from a client. Taken once, for the toast the GUI shows as it goes |
drain_agent_events() |
String |
Drain bounded OMP extension events (JSON array) from gpty-events.sock on Unix or \\.\pipe\gpty-events on Windows - see OMP event socket |
The control server registers version/shutdown locally, and the vault’s vaultSearch/vaultRead natively in vault_ipc.rs (a vault is a directory, not workspace state, and the walk plus SQLite run on the blocking pool instead of the GUI thread); the vault’s declared-weight write (vaultWeight) is the exception - its store is Godot’s user://link_weights.json, so it is routed to GDScript like every other stateful method. Every other method (newPane, paneRead, paneStatus, paneRun, paneWait, broadcast, layout, concepts) is listed in ipc.rs gdscript_methods and routed to GDScript - workspace.gd delegates dispatch to WorkspaceIpcHandlers (ipc_handlers.gd). New pane-API methods must be added to both the registration list and the handler module, unless they are directory-scoped like the vault pair.
Grid Update Dictionary
get_grid_updates_packed() returns one of two shapes. Both carry flat
per-cell arrays: fg/bg are PackedColorArray, attrs is
PackedInt32Array (bit flags: 1 bold, 2 italic, 4 underline, 8 inverse,
16 wide), and chars is an Array of strings.
fg/bg are paint-ready: CellInfo::paint_colors has already applied
SGR 7 (inverse), so fg is always the glyph color and bg always the
background. Draw them as they are - do not swap an inverse cell in GDScript.
The 8 inverse bit stays in attrs as cell information, not as a drawing
instruction; swapping only the text color is what painted a bracketed paste
(readline echoes it inside ESC[7m) in its own background.
Full update (is_full = true, first fetch or force_full):
{
"is_full": true,
"rows": 24, "cols": 80, # int - grid dimensions
"chars": ["line0", "line1", …], # Array[String] - one string per row
"fg": PackedColorArray(…), # len = rows × cols
"bg": PackedColorArray(…),
"attrs": PackedInt32Array(…),
}Partial update (is_full = false, damaged cells only):
{
"is_full": false,
"indices": PackedInt32Array(…), # cell index = row * cols + col
"chars": ["A", "B", …], # Array[String] - one string per cell
"fg": PackedColorArray(…),
"bg": PackedColorArray(…),
"attrs": PackedInt32Array(…),
}OMP event socket (Reasoning pane)
Passive OMP observability uses a second local IPC listener beside the
workspace-control socket: gpty.sock → gpty-events.sock on Unix,
\\.\pipe\gpty → \\.\pipe\gpty-events on Windows. Implementation:
src/omp_events.rs. Protocol details: crates/gpty-ipc/README.md.
| Platform | Control IPC | OMP event socket |
|---|---|---|
| Linux / macOS (Unix) | Unix domain socket, gpty.sock |
Unix domain socket, gpty-events.sock - ompEvent JSON-RPC |
| Windows | Named pipe, \\.\pipe\gpty |
Named pipe, \\.\pipe\gpty-events - ompEvent JSON-RPC |
The listener runs on every platform, and it is started unconditionally: every
PTY spawn calls ensure_server_started() and register_terminal() - no
cfg branch stands between the two - and the shell is given GPTY_EVENT_*
when registration succeeds. @gpty/omp-events then forwards semantic events
and workspace.gd polls drain_agent_events() into the Reasoning pane, so
Terminal, Inspector and Reasoning behave the same way on Windows. The
windows-smoke job runs the extension inside a real pane there and asserts the
pane reports Tier 1 working, which observes the whole chain - env injection,
transport, capability check, translation, drain - on the platform.
The shipped extension needs no platform branch to reach either transport:
net.connect(path) opens a Unix socket for a socket path and a named pipe for
a pipe path. The only branch is validation - a Windows path must be a
\\.\pipe\ name, answered from the path alone because a pipe cannot be
stated, while on Unix the socket must be owned by this user and inaccessible
to group/other.
The event code carries no #[cfg(unix)] gates at all: omp_events.rs is
platform-neutral, which is what lets the rust-windows job compile this crate
under RUSTFLAGS: -Dwarnings.
Tips
- Use
get_grid_generation()to skip redundant grid polls when idle - The renderer uses
get_grid_updates_packed()to fetch only damaged cells, merging into_cell_cache - If the grid mutex is held by the background task, or no shell has started yet,
get_grid_updates_packed()returns an emptyDictionaryandget_rows()/get_cols()answer0