Pitfalls and tool notes
-
Dropimpl for external resources: Any Rust struct holding a child process (portable_pty::Child) or I/O thread MUST implementDropto call.kill(). Otherwise closing a terminal in Godot orphans the shell process and reader thread. -
tokio::select!None branches: When a channel returnsNone(closed),select!disables that branch but keeps polling others instantly - causing 100% CPU. Bind to a variable first (msg = rx.recv()), thenlet Ok(v) = msg else { break; }. -
Locking:
std::sync::Mutexthroughout; a poisoned lock means skip this work and keep the app running, so report it throughgpty_core::lock::lock_or_warn(mutex, "what")rather than a bareif let Ok(..) = m.lock()- the first occurrence of each lock then shows up in the log (a silently frozen pane used to be indistinguishable from an idle one). Do NOT addparking_lot(the deadgpty-ipcdependency was removed in v0.5.4): with no poisoning at all, “a thread died holding this lock” becomes invisible instead of reported.gpty-aiis the exception - it does not depend ongpty-coreand turns poisoning into aResulterror at its own boundary. -
The GDExtension must stay mapped while its threads live: it owns the tokio runtime’s workers, one PTY reader per pane, and one history writer per pane, so Godot’s unload of the library (
dlcloseat exit) used to free code those threads were executing - SIGSEGV in atokio-rt-workeron a quit during IPC activity.pin_library()pins the mapping atInitStage::CorewithRTLD_NODELETE(a referencedlclosecannot drop) on Unix, and on Windows withGetModuleHandleExW+GET_MODULE_HANDLE_EX_FLAG_PIN | ..._FROM_ADDRESS(Godot callsFreeLibrarythere; the address of the function itself names the module, so nothing depends on the DLL’s file name - rawextern "system"+#[link(name = "kernel32")], nowindows-sysdependency). Do NOT remove it, and do not “fix” a shutdown crash by going back toprocess::exit- that skips the teardown the quit exists to run. The Windows branch is compile-verified byci-check’s xwin/clang-cl step and CI’srust-windowsjob; its runtime is covered by the queued Windows smoke item. -
Pane exit detection needs two signals, not one:
pty_rxclosing (Unix EOF when the child exits) and aCHILD_POLL_MS(200 ms)try_waitpoll whose result starts anEXIT_DRAIN_QUIET_MS(500 ms) quiet window before the task ends. Waiting for EOF alone is not enough - ConPTY keeps the pty pipe open after the child exits, so on Windows every pane whose command finished reportedrunning: truewith no exit code (paneStatus/paneRun’s whole contract), and on Unix a SIGHUP-immune descendant holding the pty does the same (the kernel only HUPes the foreground group when the session leader exits). The quiet window is what keeps ending early from truncating output the pty still holds: any output re-arms it.try_waitdoes not consume the status - std caches it on Unix and Windows reads the exit code - so the teardown’s own poll still returns the same answer.a_shell_that_exits_while_the_pty_is_held_open_is_still_reportedpins it (it fails on a no-op mutation, 10 s timeout). A task that ends without recording an exit code is the third case, and the one that used to lie: nothing awaits theJoinHandle, so a panic inside the task left the pane reportingrunning: trueforever and hung every consumer that waits for exit.SpawnedTerminal::task_finished()is the only thing that notices it,runningisexit_code.is_none() && !task_finished(), and the document carriesexit_reason(exited,task_ended, ornullwhile running).SpawnedTerminal::status_jsonbuilds that document in core - the pane API’s promise and the tested artifact are the same bytes. The document also carriesforeground_job: the same 200 ms poll samples the PTY’s foreground process group (tcgetpgrpon Unix, exact;nullon Windows/ConPTY, which cannot tell), and the GUI’s close confirmation is its only consumer - a background job does not count, sosleep 600 &never blocks a close while a typedsleep 600does. -
Core diagnostics do reach the GUI now:
diagnostics.rsinstalls alogbridge atInitStage::Corethat forwardswarn+ records whose target starts withgptytogodot_warn!/godot_error!(dependency targets stay out, andinfo/debugare dropped - a diagnostic meant to be seen must bewarnor above). Do not callgodot_warn!fromgpty-core/gpty-ai: those crates must not depend on gdext, so their message goes throughlogand the bridge carries it. -
PTY output is backpressured, never dropped:
pty_rxismpsc::channel(pty::OUTPUT_QUEUE_CHUNKS)(256 chunks ≈ 1 MiB per pane) and the reader thread blocks inblocking_sendwhen it fills, so a flooding child (yes,cat /dev/urandom) waits on the kernel PTY buffer instead of growing the process - with an unbounded queue it measured +70 MiB/s in the GUI and 2.2 GiB in 20 s in a bare harness. Do NOT make the channel unbounded again, and never “unstick” a full queue withtry_send/drop: these bytes carry ANSI state (SGR, cursor moves,ESC[sequences), so a dropped chunk desynchronises the grid. The reader thread stops only when the pane is gone (receiver dropped) or the child exits. -
Scrollback is write-behind, never written from the terminal path:
TermGrid::store_lineonly updatesrecent_lines(whichwaitForOutputpolls each frame and must see immediately) and callsPaneHistory::push, which queues the line for a per-pane writer thread. Do NOT call into SQLite from the grid (the grid mutex is what the UI renders under) and do NOT make the queue unbounded: it is bounded by the store’s own retention window (row cap + 1 KiB-per-row byte cap), so a line it drops under flood is oneenforce_capdeletes on the next commit - the retained scrollback stays contiguous. One writer per history file at a time for the whole process (history.rs::write_guard): every pane’s writer owns its own connection to the same file, and SQLite’s write lock plus a busy handler that does not queue fairly let N panes starve one another - measured with a probe over one file, 8 unserialized writers dropped 7 batches and 16 dropped 15, all withdatabase is locked(the same messagewindows-smokeshowed), while the guarded version dropped none and committed faster (32.8k rows/s at 8 writers against 26.1k, since a dropped batch is wasted work) for ~5% against a single writer. The guard is always the innermost lock (taken inside a store method while a caller may hold the pane’s store mutex), so the order cannot invert, and a poisoned guard proceeds unserialized rather than skipping the write: the busy timeout is then the backstop for another process on the same file. The trade is staleness: a search can miss the lastFLUSH_INTERVAL(200 ms) of output, and anything buffered when a process dies without teardown (SIGKILL,daemon stop’sprocess::exit) is lost. On a normal quitTerminalPane._exit_tree()callsflush_history, which is the only hook that can live there: the GDExtension class’s ownexit_treevirtual was probed and never fires, while GDScript’s does. -
vte
Perform::executeCR/LF: PTY output uses CRLF pairs. The vte parser callsexecuteper byte. If you commit on both\rand\n, every line produces a spurious empty string. Tracklast_was_crand skip the\ncommit when preceded by\r. -
alacritty_terminaldisplay_iter: returns negative line numbers for scrollback history rows. Never cast directly tousize- it wraps to a huge value. Always add the grid’sdisplay_offset()to normalize:let line = (indexed.point.line.0 + offset) as usize. -
GraphEdit owns internal children (the connection layer, minimap container, popups): rebuilding a canvas by clearing
get_children()deletes them and every later interaction logsconnections_layer is missing. Remove onlyGraphElementchildren (ConceptGraphEditor._rebuild_canvas_ui). Its floating chrome (zoom buttons, view menu, minimap toggle) is viewport-anchored at the canvas origin and covers the first node’s title at the default scroll offset - the editor disables that chrome and keeps zoom on ctrl+wheel. -
GDScript
\UXXXXXXXXescape: GDScript only supports\uXXXX(4-hex-digit BMP).\UXXXXXXXX(8-digit) does not exist - the parser mangles it. For non-BMP codepoints, usechar(0x10XXXX)instatic varinitializers. Prefer BMP alternatives; this project’s icons use Phosphor PUA codepoints (U+E000–U+F8FF), all expressible asconstwith\uXXXX. -
Godot strings cannot hold NUL: on 4.7.2 a
"\u0000"literal (andString.chr(0)) is decoded as U+FFFD and printsUnicode parsing error, some characters were replaced with � (U+FFFD): Unexpected NUL character- six times forvault_graph_model.gd, which used a NUL separator for composite edge keys and so emitted the error on every script load. Never use NUL as a key separator or value sentinel in GDScript; the graph model keys pairs with a nested{a: {b: pair}}map instead (a length-prefixed key would also work).String.contains/findof a NUL can never succeed, and any key built with one is silently a U+FFFD key and can collide with a path that contains that glyph. -
Typed arrays break Rust FFI: gdext
Array<Variant>parameters reject GDScript’sArray[Dictionary]at runtime (“expected array of type Untyped, got Builtin(DICTIONARY)”). Always pass untypedArrayacross the FFI boundary. Preferfunc f(arr: Array)overfunc f(arr: Array[Dictionary])when the array originates from or goes to Rust. -
Multi-line
forarray colon:for x in [...]with a multi-line array literal requires]:at the end. Forgetting the colon produces a parse error at an unrelated line. Double-check after replacing inline array content. -
Godot typed Arrays:
Array[T]won’t accept plainArray. If you type a parameter, check all call sites use matching types (var x: Array[Control] = []). -
GDExtension rebuilds: After changing
#[func]signatures or adding methods,cargo build -p gpty-gdextand fully restart Godot. Keepgodot/bin/libgpty_gdext.linux.x86_64.soas a symlink to../../target/debug/libgpty_gdext.soso the editor sees Cargo output. A release build of the editor (e.g. 4.7.2 stable) does not acceptlinux.debug.*feature keys../scripts/buildoverwrites the symlink with a release copy. -
GDScript default params: Evaluated at definition time, not call time.
func f(x := some_var)captures the value ofsome_varwhen the script loads. Usefunc f(x := -1)and checkif x < 0: x = some_varinside the body for runtime-evaluated defaults. -
extends Nodewon’t renderControlchildren: OnlyControlnodes can render childControls (Labels, Buttons, etc.). If you add a Label to a plainNode, it’s invisible. Useextends Controlfor UI containers and setz_indexfor layering. -
tokio::time::Instant::now() + Duration::MAXpanics: The addition overflows. Use a safe large constant likeDuration::from_secs(86400 * 365)(1 year) for inactive timeout sleeps. -
tokio::pin!+reset()for capture timeouts: Usetokio::pin!(sleep)andsleep.as_mut().reset(deadline)to re-arm a timeout without recreating it each iteration. Oneselect!branch, no code duplication. -
continueinfordoes not skip trailing code: Acontinueinside aforloop only skips the current iteration - code BELOW the loop still executes. Usebreak+ a boolean flag to conditionally skip post-loop grid feeding. -
Concept matching on PTY output:
ConceptMatcher::match_linetests every line against the enabled concepts (first match wins) on both the PTY-output path and the typed-input path;concept::match_lineis its ungated decision loop (same verdict, no prefilter) and stays the function the precedence/condition tests exercise. The match’s return value MUST be used tosession.begin(name, target, deadline)- ignoring it (let _ = match_line(...)) silently disables capture, which looks exactly like “concepts do nothing”. -
Restore ordering and env authority: the env chain is three deliberate steps, in order -
_restore_intostrips anyshell_enva file carries (legacy tiles; a toast names what was dropped - never adopt it into the store, that would launder a hostile file’s payload into the trusted map),SettingsManager.apply_to_terminalsets the user’s global, andTerminalPane._readyoverlaysPaneEnvStore.env_for(attachment_id)last (the id is final there: uniqueness renames run before the wrapper enters the tree). Do NOT re-add file env to any step of that chain, and do NOT writePaneEnvStorefrom anywhere but the pane settings UI path (workspace.gd’ssettings_appliedhandler, which also re-keys the entry when the pane’s id changes). -
Bare CR must never commit a parser line:
LineParsercommits only on\n(LF); a bare\rstashes the pending text - an LF after it commits the CRLF line, a printable after it discards the stash (reprint/overwrite). Shells reprint the prompt on SIGWINCH as\r+ clear-to-EOL +\r+ prompt; committing on bare CR stamps a history row per resize, and restarting restores a growing pile of old prompts. Progress bars (bare-CR updates) likewise only commit their final state once a real line end arrives. -
Tab completion triggers concept matches: Bash reprints the prompt and partial command when showing autocomplete candidates. This reprinted line has no trailing
\n, soLineParsernever emits it. Tab completion never triggers concept matching. -
Raw-byte buffering for grid replay: Never buffer parsed lines for later grid replay - the alacritty_terminal ANSI state machine needs raw bytes with escape sequences intact. Buffer
Vec<Vec<u8>>(chunks), replay withfeed_grid(board, chunk). -
Rendering Performance: GDScript
_drawis slow when callingdraw_rect/draw_stringcharacter-by-character. Avoid generating heavy data structures (likeDictionary) per-cell across the FFI boundary. Prefer packing data into flat arrays (PackedByteArray,PackedInt32Array) in Rust, and batch rendering into glyph runs - consecutive same-attribute cells merged into one draw call; per-line batching is the floor, not the ceiling. Do NOT re-propose a custom GPU texture pipeline (fontdue glyph atlas / instanced quads) without new evidence: Godot already GPU-composites canvas items, the bottleneck is CPU-side command generation, and fontdue would duplicate Godot’s existing glyph atlasing. The instanced-quad renderer is evidence-gated in ROADMAP Future - revisit only if render batching plus flood rate-limiting still shows frame-time pain. -
Resize Rate Limiting: Firing SIGWINCH heavily on every frame during window drag will overwhelm the child PTY process. Always debounce or rate-limit terminal
_on_resizeevents before passing them to the backend. -
Scrollback vs. PageUp/Down:
terminal_pane.gd:_handle_keyboardintercepts PageUp/Down for scrollback navigation. These never reach the PTY, so programs likelessorvimcannot receive them. Users must use alternative keys (b/finless,Ctrl+B/Ctrl+Fin vim). The pane search bar is onCtrl+Shift+F; plainCtrl+Fis never intercepted, so it reaches the PTY (and the documented vim workaround works). -
Alt key handling: For Alt+letter combos, the Rust keymap returns
None, expecting the GDScript layer to prepend\x1b(ESC)._handle_keyboarddoes this in the_key_to_textfallback path. -
Printable keys and the evdev keymap:
GptyTerminal.key_to_bytesMUST early-return empty for printable ASCII keycodes (0x21–0x7E). Routing them throughgodot_key_to_evdevfabricates scancodes that collide with special keys (z→55=KP_MULTIPLY,;→59=F1,`→96=KP_ENTER) and silently swallows the characters. Only Space (→57, for Ctrl+Space→NUL) and true special keys may reach the keymap. -
Ctrl+V passthrough:
Ctrl+VMUST reach the shell as a literal^V(readline quoted-insert, vim visual-block). Paste isCtrl+Shift+Vonly - never bind plainCtrl+Vto paste. -
PTY Enter key: The Enter key MUST send
\r(CR) to the PTY, not\n.pty.rs:write_lineappends\r. The PTY terminal driver translates\r→\nin canonical mode; raw-mode programs read\rdirectly. -
tokio::task::JoinHandledrop detaches: dropping aJoinHandledoes NOT abort the task - it keeps running until it exits naturally. For explicit cleanup (e.g., in aDropimpl), callhandle.abort(). -
std::sync::Oncepoisoning: if the closure passed toOnce::call_oncepanics, theOnceis permanently poisoned - all subsequent calls panic too. For lazy init that spawns fallible work, useAtomicBool::swap(true, Relaxed)orMutex<Option<...>>instead. -
Resize cascades from layout changes: When panes are added/removed, remaining terminals receive multiple
NOTIFICATION_RESIZEDevents in rapid succession. Even when calculated rows×cols are identical, eachresize_grid()call triggers a full grid re-wrap and sync, producing a visible “scrolling through history” animation. Fix:TermGrid::resize()must return early if dimensions are unchanged. Also apply a pixel-level check in the GDScript debounce to avoid redundant calls. -
Shared static state in integration tests: tests that mutate shared
staticstate (queues, maps) must clean up in ALL exit paths - including timeout, error, and panic branches. A stale queue entry from one test will break the next test. Write aclear_state()helper and call it in every test. The process environment is the same class of shared state, and cargo runs one binary’s tests on parallel threads:gpty-ipc’s transport tests guardGPTY_SOCKET/XDG_RUNTIME_DIRthrough one sharedEnvVarlock that restores the previous value on drop (including on panic); an unlockedset_var/remove_varpair failed a realci-checkparallel run. -
Do not scrape agent TUIs: PTY bytes are presentation, not structured events. Semantic observability comes only from documented OMP/Claude/Gemini hooks or extensions. Regex concepts remain valid for ordinary shell output (e.g.
cat→ code viewer). -
Emulator PTY replies: alacritty_terminal emits
Event::PtyWritefor terminal queries (DSR cursor-positionESC[6n→ESC[row;colR, mode reports). gPTY’s event proxy MUST queue these and the engine MUST write them to the child PTY (TermGrid::drain_replies()drained each loop iteration). Dropping them breaks TUIs that query the cursor after SIGWINCH - the OMP TUI re-anchors its transcript and appears to “scroll through history” on every resize, while vim/btop (which never query) are unaffected. Never drop non-Titleevents. -
Transient pane sizes collapse the grid: during split/spawn layout churn, panes transiently report ~38×40 px (4×2 cells). Applying that rewraps the grid to 2×4 - the terminal looks dead.
terminal_pane.gdmust skip resize whensize < custom_minimum_sizeOR computedcols < 8ORrows < 3at BOTH event and apply time, and must recompute dims from the CURRENT size when the debounce fires.resize_grid(gdext) must also no-op when dims are unchanged (no redundant SIGWINCH). -
Resize anchoring surgery:
TermGrid::resizedeletes the rows alacritty pulls in on row-growth and restores the cursor (xterm behavior). Run it ONLY whendisplay_offset == 0 && rows grew && !is_alt_screen() && history_size > 0. Thathistory_sizetest is read from the POST-resize grid andgrow_linesends withdecrease_scroll_limit(lines_added), so it passes only when scrollback was longer than the growth - the case where every new row came from history and no visible row was pushed off. The deletion therefore removes recovered scrollback rows and can never eat on-screen content; scrollback shorter than the growth is zeroed by that same call, failing the guard and skipping the surgery. It is NOT a “this is a TUI” test: a primary-screen app that paints without smcup (the OMP TUI) is guarded only on a fresh pane - launched after the pane had more scrollback than the row growth, it passes every condition and the injectedCUP/DLescapes land in its screen. Only the alternate-screen check catches real full-screen apps. -
Empty grid cache:
_grid_offset()/_mouse_to_cell()run on mouse events before the first grid sync - guard_cell_cache.is_empty()or the editor pauses on a script error and the app appears frozen. -
SGR 7 swaps BOTH cell colors, not just the glyph: an inverse cell’s text is painted from
bgand its background fromfg. Do the swap once, at the presentation boundary -CellInfo::paint_colorsingpty-coreis whatget_grid_updates_packedpacks, so the cache a pane draws from is paint-ready and_draw_cellsmust never re-derive it. Swapping only the text color paints the glyph in its own background, and this is the common case, not an exotic one: bash’s readline wraps the echo of every bracketed paste inESC[7m/ESC[27m(measured under a real PTY), so pasted text read as blank cells with the cursor advancing, until the next arrow key made readline repaint the line unhighlighted.godot/tests/integration/test_inverse_paint.gdpins the cache’s paint-ready colors (and fails on the raw-color packer);inverse_cells_expose_swapped_paint_colorspins the rule itself. -
Textless Buttons collapse: Godot does not derive a Button’s minimum size from child Controls. A Button that carries an icon+text HBox instead of its own
textneeds an explicitcustom_minimum_size.y(see_make_icon_text_button) or it squashes to nothing and overlaps the next VBox row. -
Pressed-state text color: a pressed (toggle-mode) Button resolves text from
font_pressed_color, which falls back to the theme default and silently ignores afont_coloroverride - the active-row accent rendered black until all three states were overridden (_apply_row_accent). -
Container ancestors skip gui_input propagation: clicks on pane bodies never reach a wrapper’s
gui_input(proven by probe). Pane activation therefore lives in the workspace’s raw_inputwith wrapper hit-testing, not in per-wrapper signal wiring. -
z_indexis rendering-only for Control input: Godot 4 GUI picking uses REVERSE TREE ORDER (last sibling first), ignoring z_index. A full-rect overlay with z_index=100 that is NOT the last child draws on top but is dead to clicks - later-added siblings (workspace grids, sidebar, status bar) eat its input. Every overlay (pane settings popup, global settings panel, palette) mustmove_child(overlay, -1)when opened so it is topmost for both picking and rendering. The z bump is still required for RENDERING when grids are added after the overlay. -
Bottom-edge overlays need BOTH anchors: a Control with
anchor_bottom = 1.0but defaultanchor_top = 0.0stretches to FULL parent height - the offsets only shift it, they don’t shrink it (the terminal search bar briefly covered the whole pane). Bottom strips must setanchor_top = 1.0; anchor_bottom = 1.0with a negativeoffset_top. -
Pane labels:
_next_labeltakesmax(existing numeric suffix) + 1- closing the newest pane reuses its number, middle gaps are never filled (T1,T3 → next is T4). Do not reintroduce monotonic counters. -
Event vs control sockets:
ompEventlives ongpty-events.sock. Putting it on the control socket would require giving the extensionGPTY_SECRET(workspace control) or weakening auth. Keep them separate. -
Event capability is per-PTY: unregister on spawn failure and
Drop. A capability for terminal A must never be accepted for terminal B. Compare capabilities in constant time. -
Inspector close must tear down its OMP child: call
session_cancel+session_closefrom_exit_tree(). Dropping a Godot node without closing the session leavesomprunning. -
stream=thinkingobservers are not job owners: after migration they become Reasoning panes and must keepcan_receive_content() == false. Acknowledging a capture without starting analysis discards terminal output. -
Terminal mouse reporting: the grid’s mouse-mode bits (
GptyTerminal.get_mouse_mode,gpty_core::term::MOUSE_MODE_*) are the single source of truth for whether the child owns the mouse. The pane forwards only what the enabled modes cover (DECSET 1000 click, 1002 drag, 1003 any-motion), encodes SGR when 1006 is set and the legacy X10 form otherwise, and must leave selection/scrollback untouched when the bits are zero.Shiftbypasses reporting (xterm’s convention) so text selection stays reachable - removing that traps the user in any full-screen app that grabs the mouse. -
Mouse reports are cells, 1-based, mapped through the same
_grid_offset()/cell metrics as selection. The legacy X10 form offsets by 32 and cannot address past cell 223: drop such an event rather than clamping, or the app receives a click on a cell the user never touched. The report has no trailing newline, so a child that has mouse tracking on but left the PTY in canonical mode never sees it (that is the app’s bug, not the pane’s - the automated check drives a raw-mode child). -
Terminal selection lives in the grid, not in viewport rows:
GptyTerminal.selection_start/extend/cleardrivealacritty_terminal::Term::selection, which alacritty rotates whenever output scrolls the screen (and on resize). A screen-space selection (the pane’s old_sel_start/_sel_end) stayed on whatever text scrolled under it;get_selection_textreads grid rows, so a selection scrolled out of view still copies in full. Do not re-derive the anchor fromdisplay_offsetarithmetic in GDScript - grid line indices shift with every scroll, so only the term’s own selection keeps its text;get_selection_cellsis just the drawing projection for the current offset. -
Pane edge resize belongs to edge strips, not to the wrapper’s
gui_input: aPanelContainerfits every child into its content rect (aControladded directly to the wrapper is stretched over the whole pane and swallows clicks), and the pane body isMOUSE_FILTER_STOP, so the wrapper itself only ever receives events inside its 1 px stylebox border - an edge test there is unreachable by hand. Each wrapper therefore carries anEdgeHost(plainControl,MOUSE_FILTER_IGNORE, added last so it wins Godot’s reverse-order picking) holding four 6 px strips that ownmouse_default_cursor_shape(HSIZE/VSIZE; Godot applies it on hover, whereas an imperativeDisplayServer.cursor_set_shapeis overwritten by the hovered control’s default and only flashes). The drag is driven fromworkspace._input(drive_edge_drag) because the motion right after a press on the border is already over the pane body, which would otherwise read it as a text selection. -
Edge-drag arithmetic (the state machine is
tile_drag.gd;TerminalManagerkeepsdrag_active/begin_edge_drag/drive_edge_drag/end_edge_dragas thin delegators) previews in pixels and commits in units:drive_edge_dragpasses the distance from the press (mouse motion arrives in 1-3 px steps, so a per-event delta would round to zero), and each motion moves the divider line’s wrappers by the raw pixel delta -beforetiles grow their far edge,aftertiles move their near edge - without touching the tile model and without emittingtiles_resized(that signal drives_apply_layout, and emitting it per motion relayouted the grid and reflowed every terminal on every motion event).begin_edge_dragsnapshots everything the preview needs from the press: the tiles on the line, their committed offsets, one unit in pixels, and the unit travel limits - the same clamps the commit uses, so the preview can never show a position the release refuses (MIN_TILE and the grid edges are enforced mid-drag in pixels).end_edge_dragrounds the previewed travel to whole units, rewinds every tile to the press-time spans (normally a no-op; it is what makes the commit idempotent if a spawn or kill moved a tile mid-drag), applies the step, and emitstiles_resizedexactly once - the pane’s ownRESIZE_DEBOUNCEthen settles the terminal content. The preview is absolute from the press, so backtracking is safe, and an edge with no tile on its far side (the window border) never starts a drag. Tiles are addressed by wrapper (_tile_index_for_wrapper), not by body (_tile_index_ofresolves bodies and never matches a wrapper). -
The layout grid has exactly one definition:
PaneTypes.GRID/PaneTypes.MIN_TILE, read by the drag math,PaneTypes.sanitize_tile’s default, andworkspace.gd._apply_layout. A second copy drifted once (the layout divided by 12 while the tiles were in 60), which clamped every restored pane to a sliver and drew it several times the grid - panes outside the visible area, from a one-line constant change. Never re-declare it locally; the tiles on a divider are the ones whose saved span ends on the line and the ones whose span starts on it (TerminalManager._tiles_on_divider), never a neighbour matched by equal extents - a full-height pane flanked by two stacked ones has no equal-extent neighbour at all. -
Pane file sinks (
code_viewer,file_tree,wiki):FileAccess.file_exists()is a regular-file check on Godot 4.7.2 (measured: false for directories, character devices and FIFOs - even reached through a symlink - true for regular files and symlinks to them) andFileAccess.get_size()returns -1 for anything non-regular, so that pair is the type gate and a FIFO never reachesFileAccess.open()(which would block the GUI thread until a writer appears). The read guard isTextRead.read_prefix- one definition every reading pane calls: absolute paths only, that regular-file gate, aTextRead.DEFAULT_MAX_BYTES(1 MiB) cap (the reader is the GUI thread and the path can come from an untrusted layout/profile), and a torn UTF-8 tail decoded by hand, because cutting at the cap often lands inside a sequence.code_viewerandfile_treecall it directly; the wiki pane’s note view callsGptyTerminal.vault_note_read, which applies the same cap, the same regular-file gate and the same torn-tail drop ingpty_core::vault- and its editor is the pane set’s only writer, throughGptyTerminal.vault_note_writeon an explicit save andvault_note_create/vault_note_rename/vault_note_deleteon their explicit actions (the vault bullets above have the rules - including the crash-recovery copy that sits beside the save and never touches a vault).file_treenever hands a bare name to the system: activating a pathViewableFilesnames opens it in acode_viewerpane through the panes-never-spawn-panes request (below; the per-treeopen_insetting can leave every file on the system path), while anything else validates an absolute path, builds the onefile://URI itself (scheme allowlist - a basename cannot contain/, so no scheme can be injected), and asks for confirmation, because a downloaded.desktopfile is run by the mime handler. The table is an allowlist with no content sniff -ViewableFiles.viewer_fordecides from the lowercased extension or an exact extensionless name, so a binary is never decoded as text - and the viewer’s Reload action exists because opening the same file again focuses the existing pane instead of re-reading it; a routed capture clearsfile_path, so a viewer now showing capture text can never be mistaken for the file’s pane. Godot 4’sCodeEdithasadd_comment_delimiter(start, end, line_only), NOTadd_comment_string- the latter raised a script error that silently abortedload_filebefore_refresh_view(). -
The media pane (
media,MediaFiles+MediaPane): a local-file sink under the same trust stance as the text panes, with one sharper hazard - a path handed toResourceLoader.loadwould load whatever a file-supplied tile named (a.gdor.tscnbehind a media extension is code), soMediaFilesis a closed extension table and both streams go throughGptyMedia(see below): video as RGBA frames, audio as PCM pushed into anAudioStreamGenerator.checkis the one path gate (absolute,FileAccess.file_exists’s regular-file check, unknown formats refused before a decoder), andload_mediare-runs it every time because the path can come fromworkspaces.json/a profile; a refusal clearsfile_pathand explains itself in the pane (no toast, nopush_error- a bad path is a caller’s question, and GUT counts engine errors). Remote/URL streaming is deliberately absent (its own trust boundary, queued in v0.7.0), and project schemes (res:///user://) are refused with the non-absolute paths. Video is the Rust backend:VIDEO_EXTENSIONScoversogv/mp4/m4v/mov/mkv/webm/avi/ts/m2ts, a video session opens both streams (the pane drains audio too; an unconsumed queue is backpressure and would stall the other stream), frames are drained only while a small PTS-ordered backlog has room and presented against the media clock below - the backend’s bounded queue is the backpressure, because a fixed drain count followed by “keep the newest four” discarded frames that were due (the decoder decodes a whole clip in milliseconds, so the picture froze on the first frame while audio advanced); stale frames are dropped only when a newer one is waiting, and without audio the clock starts on the first frame’s own PTS (a stream whose timeline does not begin at zero would otherwise be held as “in the future”), and the surface is aTextureRectwith aspect-preserving center stretch. Audio is the same backend:AUDIO_EXTENSIONScoversogg/oga/opus/mp3/wav/flac/m4a/aac, decoded f32 is pushed into anAudioStreamGenerator(48 kHz stereo, 0.2 s buffer), and the generator starts on the first real chunk soget_playback_position()and the media PTS share an origin. Chunks are held in a bounded 32-chunk local lead and drained from the backend even when the generator is full - leaving that queue full would block the worker, which also decodes video. With audio running the video clock isget_playback_position()mapped from the first chunk’s PTS (20 ms tolerance, 150 ms catch-up); without audio it is the monotonic start clock. Volume (-40..6 dB) and mute (-80 dB) are pane settings saved in the tile. Two PCM facts are pinned:pcm_u8is in the component set, and a frame with an unspecified channel layout must be normalized on the frame, not only on the resampler (swresample otherwise answersInputChanged). Transport: a bottom bar offers Play/Pause, anHSliderseek, elapsed/total and Loop. Pause pauses the generator and freezes the clock (video-only offsets_clock_start_uson resume); seek goes through the backend’s acknowledged seek and then drops local frame/audio queues, stops the audio player and releases the generator playback (itsclear_bufferrefuses while the playback is active - paused included - andplay()instantiates a fresh, empty generator playback, so dropping the reference is what discards the samples), and resets the clock epoch; loop seeks to 0 at EOF (the worker survives it) and is saved in layout state, while Play on a finished non-looping file restarts from zero. The slider marks_seekingso the position update does not fight the drag. The backend’s format policy is this table at the edge pluscomponents.txt/the Rust allowlists (see the Rust media bullet), and it refuses a file it cannot decode before any decoder starts. The file tree routes a supported media file throughrequest_paneexactly as it routes text (ViewableFilesfirst, thenMediaFiles.pane_for), and a same-path request focuses the media pane already holding it (two would double the sound and the decode work) - the viewer’s dedupe rule, not a new policy. The pane accepts no routed content; its transport state is reported throughstatus_document()(see the pane status contract). -
The preview path has its own cap, and it is the same number:
GptyMarkdown.rendercuts its input atMAX_MARKDOWN_BYTES, which isTextRead.DEFAULT_MAX_BYTESandgpty_core::vault::MAX_NOTE_BYTES(1 MiB) -crates/gpty-gdext/src/markdown.rs’sthe_input_cap_is_the_read_cap_the_panes_applyreads the GDScript declaration and fails if they ever drift, because a pane that shows a note at its read cap has to render all of it, or its own “showing the first 1 MiB” claim is a lie about a view that stopped sooner. The cut is never silent: the render answer is JSON -{text, truncated, shown, size}, the same shape a read answers (shown/sizeare bytes of the source) - andMarkdownViewis the one place a reader is told, as a banner above the body: its own numbers when the renderer’s cap bit (a pasted buffer, a routed capture, a long model turn), andset_source_noticefor a cut the caller made. A pane must pass its read-cap notice that way rather than appending it to the source: a note at the read cap is at the renderer’s cap too, so an appended notice is the first thing the cut would drop - which is exactly the case the notice exists for. The text panes keep their own notice at the end of the text they hold (TextRead.truncation_notice, now a bare sentence the caller places). -
The Markdown renderer’s form is a rule, not a style: it never emits
[ul]/[ol], because Godot lays a list out superlinearly in its item count and a preview pays that on every update - measured on the Godot 4.7.2 binary: 38915 ms to shape 1.7 MiB of two-item lists (146705 ms for a real 1 MiB note) against 819 ms for that same rendered text with the tags stripped, and 0.5–1.3 s for every other construct (headings, quotes, inline code, links, tables). Each item is therefore a•/N.line inside one[indent]per list (a nested list opens its own), which renders identically (screenshot-checked side by side) and keeps the layout linear; the numbering is the renderer’s own, which is also how a list starting at 3 renders3.. Reintroducing a list tag is a freeze measured in minutes, not a fidelity tweak. Cost model, measured withgodot/tests/bench/markdown_bench.gd(andcargo test -p gpty-gdext --lib probe_render_costfor the Rust half alone) in release: a preview update is render 48 / 50 / 101 ms plus shape 261 / 486 / 973 ms for 256 KiB / 512 KiB / 1 MiB - the shape pass is the dominant term, so nothing may make it superlinear again. -
A live preview renders on settle, never on the streaming throttle:
MarkdownView.set_markdown_settledrestarts its timer on every call (settle_delay, 0.4 s), so a typing run queues no render at all and one lands when the user stops -set_markdown’s non-restarting throttle stays for streams (Reasoning, Inspector) andrender_nowfor explicit acts (opening a note, a mode switch, a save). The wiki pane’s_on_editor_changedmust call the settle form; the throttle there was the original item’s bug (a 75 ms cadence re-rendering and re-shaping a large note under the typist’s fingers). -
Release bundles carry the pinned FFmpeg runtime: the GDExtension has an
$ORIGIN(Linux) /@loader_path(macOS) rpath, soscripts/bundle-mediais the one packer -linux/windowscopy the shared libraries and anlddcheck refuses an unresolved bundle,macos-libslipos the two per-arch builds into universal dylibs and ad-hoc signs them (the macOS FFmpeg build uses--install-name-dir=@rpathso the GDExtension records relocatable names), andmacos-zipinjects those pre-signed libraries into the exported.appon the Linux export job.scripts/builduses the same helper. FFmpeg’sCOPYING.LGPLv3/COPYING.LGPLv2.1andcomponents.txtride inlicenses/; the AUR recipe installs the libs and licenses under/usr/lib/gptyand/usr/share/licenses/gpty. When real macOS signing lands, sign after the injection. Never copy a distro FFmpeg into the bundle: the pinned soname is the point. -
Release bundles name the GUI
gpty-gui, nevergpty: the CLI ownsgpty(its crate bin name,mcp.json’scommand, the plugin API’sGPTY_BIN_PATH) andfind_gui_binary()auto-spawns a sibling calledgpty-guiorgpty-editor. Renaming the export back togptyshadows the CLI on PATH and breaks auto-spawn. Linux bundles also carry Godot’s generatedgpty-gui.shlauncher (clears the screen, sets the title, execs the sibling export). On macOS the.appname is preset-owned (gPTY.app), so discovery looks inside the bundle next to the CLI and under/Applicationsas well. -
Godot resolves both the data pack and the GDExtension library against the real executable path (
/proc/self/exe), so a bundle binary may be symlinked but not renamed behind a wrapper: renaminggpty-guiwithout renaminggpty-gui.pckyieldsCouldn't load project data ... Is the .pck file missing?.godot/export_presets.cfgkeepsbinary_format/embed_pck=falsefor Linux (true for Windows), so the pack sits beside the binary. -
App icon and bundle version are project settings, not packaging details:
godot/project.godot’sconfig/icon(godot/icon.png, cropped square fromassets/icon-source.jpg) is what macOS turns intoContents/Resources/icon.icnsand what Linux uses as the window icon, while the Windows preset needs the explicitapplication/icon="res://icon.ico"(Godot 4.7 embeds the PE resources itself - no rcedit).config/versionfeeds macOSCFBundleShortVersionString/CFBundleVersion, so the release workflow overwrites it from the tag and the committed value stays at the last release. Keepapplication/app_categorysingle-word: Godot lowercases it verbatim, so a multi-word name becomes an invalid UTI (public.app-category.developer tools).
Agent tool notes
- A renamed or unmarshaled
#[func]breaks GDScript at runtime, not at parse time: the call is syntactically valid and fails only in the pane that exercises it.scripts/check-ffi-surface(run byci-check) cross-checks every call on a GDExtension handle against the exported surface - name and arity. A#[func]is attributed to the class its enclosing#[godot_api] implblock names (primary or#[godot_api(secondary)], which is how one class is implemented across files), and a#[func]outside such a block is reported - the old rule took the firststructin the file, so a class split across files would have quietly lost every moved method from the checked surface. It is fail-closed: a Godot-inherited member it does not know about (has_methodis allowlisted) is reported, so add it toINHERITED_MEMBERSdeliberately rather than widening the check. The same walk reads the crate’s own prose -crates/gpty-gdext/README.mdand its///comments - where a backtickedname(...)must be an export, a plainfnin the crate, or a reasonedDOC_ALLOWED_NAMESentry: the reference is what a caller reads before writing the first call, and a name that exists nowhere (three prose mentions ofget_grid_rows(), a method no version of the class ever had) costs the same round trip as a renamed export. - gdext
#[func]parameter types: ONLYGString,bool, andi64are reliably marshaled as input parameters.Array<Variant>,Dictionary, and bareVariantall silently fail - the GDScript call succeeds but the Rust body never executes. Workaround: serialize complex data to JSON in GDScript (JSON.stringify()), pass asGString, deserialize in Rust withserde_json::from_str. - Concept push startup race: GDExtension classes aren’t registered when autoloads initialize, so
ConceptManager._on_init()defers its push viacall_deferred("_push_to_rust"), andworkspace.gdrepeats it afterawait create_timer(2.0)as the fallback. There is no FFI object to own:set_global_concepts/get_global_conceptsare static (the engine’s concept set is process-wide), so the push is a plainGptyTerminal.set_global_concepts(...)guarded byClassDB.class_exists- the oldClassDB.instantiate("GptyTerminal")vehicle leaked one ObjectDB entry per push, and aGptyTerminal.new()during autoload init would have been a zombie whose#[func]methods silently no-op.ConceptManageris also the single subscriber to its ownconcepts_changed(the store owns the push; a per-workspace connect both duplicated the push per workspace and collided acrossWorkspaceinstances with Godot’s “signal is already connected”), and_push_to_rustis the one entry point - the copy inworkspace.gdthat scanned tiles only to borrow a terminal’s instance is gone. - GDScript
///comments: GDScript uses#or##for comments. Rust-style///causes a parse error. Always use##for doc comments in GDScript. - Edit tool on structured formats (YAML, TOML, Markdown frontmatter): the line-based
edittool can corrupt delimiter-sensitive files (YAML---blocks, TOML[sections], frontmatter bounds). When editing config files, workflow YAML, or Hugo content, preferevalwith Python (yaml.safe_load,tomllib) to parse → modify → serialize. Reserveeditfor Rust, GDScript, and plain Markdown where line semantics hold. gpty --jsonprints the JSON-RPC envelope on stdout and exits 1 when the response carries an error:format_response/check_errorprint first, then fail, so stdout stays parseable and the exit status is the success signal a script branches on (error.data.codecarries the stable discriminator). The custom-output commands (list-panes,layout list, the all-panespane-status) callcheck_errorafter their own output rather than re-deciding the rule. The MCP server is separate: it builds its own in-band JSON-RPC errors.- CLI
gpty versionis local-only: it prints the CLI’s own crate version and exits without touching IPC. It cannot probe a running GUI. Usegpty daemon statusorlist-panesto test connectivity/auth. - Font-size changes now auto-recalculate cell metrics via a setter on
font_size- no need to recreate terminals. - The global tokio runtime is initialized once at GDExtension init and shared across all GptyTerminal nodes.
- CLI auto-spawn policy: only
new-paneandlayout loadmay start a GUI, and--no-daemonrefuses even those (main.rs::may_autospawn, exhaustive so a new subcommand must choose a side). Every other command reaches the GUI through its own call and, when nothing is listening, printsno gpty GUI is running (start one with \gpty daemon start`)and exits 1 - the classification isdaemon::is_not_runningoverClientError::Connection(NotFound | ConnectionRefused)in the error chain, so a refused insecure path keeps its own message.daemon startis the explicit spawner (it refuses under–no-daemon),daemon stopwith nothing running is an idempotent no-op, anddaemon statuscan never create what it checks. The MCP server never auto-spawns - itsdaemon-start` tool is the bootstrap, and a tool call that finds no GUI answers with the same hint plus “call the daemon-start tool first”. A GUI that cannot start reports its own stderr (captured to a private temp file, removed once it answers) instead of waiting out the timeout with the reason discarded; a spawn happens only when the probe says the endpoint is genuinely empty (a listener that does not answer is waited out, never duplicated). - CLI runtime shape:
crates/gpty-cli/src/main.rsruns on#[tokio::main(flavor = "current_thread")]. Every command is one request in a process that exits as soon as it has an answer, and nothing in the CLI orgpty-ipcusesspawn_blocking,block_in_placeortokio::process, so the multi-thread flavor’s workers bought no parallelism - they were the only concurrency aroundprocess::exit, the plausible shape behind the single WindowsSTATUS_HEAP_CORRUPTION(0xC0000374) the smoke saw on aninjectthat printed nothing (v0.7.0). A worker pool adds startup cost (measured on Linux: 300gpty versionruns went from 1.04 s to 0.60 s) and an exit race to a one-request CLI; do not switch it back without evidence. The smoke’s CLI teardown stress step (100 throwawaypane-statusprocesses ≈ 200 connections) is the amplifier if it ever needs reproducing.