Skip to content
GDScript conventions

GDScript conventions

  • Indentation: tabs

  • Icons: All glyphs live in icons.gd as const strings (Phosphor Regular PUA codepoints via \uXXXX). To add: pick from phosphoricons.com, get the codepoint, add a const. Call Icons.style_button(btn) after setting btn.text.

  • Sidebar layout: the sidebar owns every persistent switcher (workspaces, profiles, panes) as vertical sections in its VBox - never add full-window top strips or overlays, they cover sidebar chrome (the window-mode dropdown sits at the sidebar’s top). Sidebar content lives inside a MarginContainer (8px left/right ≈ scrollbar width, 6px vertical) so rows never touch the sidebar’s edges. Icon+text actions are a single Button with a child HBox (icon Label + text Label), both labels at the same font size, a 6px offset_left breathing gap, plus an explicit custom_minimum_size.y (a textless Button does not size from its children and collapses). Section lists share one pattern: SECTION_MAX_VISIBLE_ROWS (5) caps visible rows; _measured_section_height sizes the scroll container from get_combined_minimum_size() (never hard-coded row heights - they drift from theme metrics and truncate the last row); _apply_row_accent marks the active row (Palette.ACCENT_COLOR + pressed look) and is used identically by workspace, profile, and pane rows. Active-row state: workspaces by index, profiles by name (cleared on _do_reset and on deleting the active profile), panes by _tm.last_body via set_active_pane (no list rebuild on focus change). The pane list absorbs leftover height via SIZE_EXPAND_FILL.

  • Profiles: named terminal-layout snapshots, in ProfileManager.get_all_profiles() order: shipped built-ins (res://profiles.default.json - two, “Agent Workspace” and “Knowledge Base”; both name no program, so first-run onboarding opens no dialog: Agent Workspace is pure terminal geometry, Knowledge Base is a wiki tile that names the bundled-vault token instead of a machine path. Tool presets stay in plugin repos.), installed-plugin profiles, then the user’s own (user://profiles.json). Shipped files are never written back, and plugin profiles are not either - they belong to the plugin and vanish with it (their store records keep them out of user://profiles.json; the sidebar gives them no delete/rename affordance, and their tooltip names the plugin and pinned revision). Dedupe follows add_profile’s convention with the user winning: a user or builtin profile keeps the bare name and a colliding plugin profile takes (n); among plugin profiles the first wins, and the naming re-runs whenever a user profile takes or frees a name (_derive_plugin_profiles(), called by add_profile/update_profile/rename_profile/delete_profile), so the winner does not depend on which side arrived first - without that re-run the two rows share one name for the session and find_profile resolves the collision to the plugin’s row (plugin profiles are listed before user ones), so a click on the user’s row or a layoutLoad by that name would activate the plugin’s profile. refresh_plugin_profiles() is the only store reader and the only emitter: it parses GptyTerminal.installed_plugin_profiles() (the plugin_profiles_source Callable is the test seam), skips malformed entries instead of raising, re-derives the block and emits profiles_changed; it runs at init and (deferred 2 s) after an install review is accepted, because the CLI only moves the clone into place after it reads that answer. The CLI’s admin actions (uninstall/enable/disable) are not visible to a running GUI until one of those runs (queued in the roadmap). The sidebar appends a catalog hint row as the section’s last row (→ the registry’s docs page), even once plugin profiles are installed. Everything downstream is unchanged: layoutList lists these names, find_profile resolves them for layoutLoad, and activation clears the workspace (_reset()) then rebuilds tiles (_restore_into()), rebuilt for the remaining built-ins and covered by test_the_shipped_builtins_are_program_free + test_each_preset_tile_passes_sanitize_tile. A profile row offers two destinations and keeps them separate: the label activates in the active workspace (_do_profile_activate, which asks before replacing panes) and the trailing Icons.NEW_WORKSPACE glyph opens it in a workspace of its own (_do_activate(profile, ask_running, open_in_new) → _create_workspace_for_profile: named after the profile through WorkspaceStore.unique_name (a second one gets " 2"), inheriting the active workspace’s cwd, switched to before the restore, refused at MAX_WORKSPACES before the trust prompt, and with no replace confirmation because nothing live is lost). The Workspace Trust dialog carries open_in_new through, so an approval cannot redirect the click to the other destination; the two sidebar signals (request_profile, request_profile_in_new_workspace) are emitted by their own gesture and never both.

  • Attachment IDs: persist attachment_id ([a-z][a-z0-9_-]{0,31}) on panes. Companion panes (Reasoning) attach by this stable id, not ephemeral labels like T1. pane_label is reassigned on restore and must not be used as a saved link. It is also the public IPC id: every pane has one (auto-generated pane-XXXXXXXX via PaneTypes.generate_attachment_id() when none was saved - PaneBody.apply_settings is the choke point), newPane returns it and listPanes reports it as id alongside the display label. IPC pane targeting accepts either id or the legacy label. With multiple workspaces, attachment_ids remain globally unique; label targeting resolves active-workspace-first (workspace.gd:_find_pane_by_label). IPC methods operate on the active workspace’s pane set unless an id targets another workspace’s pane.

  • JSON → typed arrays: JSON.parse() returns untyped Array. Assignment to Array[Dictionary] fails at runtime. Always iterate and build the typed array element-by-element: for item in raw: if item is Dictionary: typed.append(item).

  • Private members: underscore prefix (_cell_w, _settings_panel)

  • Config vars: _cfg_ prefix (_cfg_cursor_shape)

  • Persistence: Managers extend BasePersistenceManager - provides _read_file(path) / _write_file(path, data) for JSON stores and _read_text(path) / _write_text(path, text) for text assets, and sets PROCESS_MODE_ALWAYS. Subclasses override _on_init() instead of _ready(). Loads read through the typed accessors (_as_int/_as_bool/_as_array/…) rather than a raw d.get(key, default) assigned to a typed variable: GDScript raises on a wrong-typed JSON value, which aborts the loader and drops every key after it (and the next save writes that reduced state back). Never inline FileAccess.open() - use _read_file/_write_file for JSON stores and _read_text/_write_text for text assets. Writes go to a sibling temp file and are renamed into place: FileAccess.WRITE truncates the target as soon as it opens, and _read_file reads a truncated file as empty, so an in-place write that fails costs the entire store. That temp name is random (_temp_suffix(), 64 bits) and the file is created 0600 (STORE_UNIX_MODE): FileAccess.open(..., WRITE) follows a symlink, so a fixed <store>.tmp let anything able to write in user:// point the next save at a file of its own choosing. Do not go back to a predictable name, and do not drop the explicit mode - the umask decides otherwise, and a 0644 store hands every pane environment and window geometry to any account that can read user://.

  • Directory layout: Scripts are grouped by role: autoloads/ (shared managers and the persistence base), terminal/ (core terminal), ui/ (sidebar, settings, toast), panes/ (specialty pane types). project.godot autoload paths and preload()/load() calls use the full res://scenes/<dir>/<file>.gd path.

  • Settings pipeline: _cfg_* → _save_settings() → user://settings.json. Read-only rows are allowed (not every row is a setting): the Terminal tab’s “On disk:” label reports what scrollback costs, from the static FFI GptyTerminal.history_store_stats() → gpty_core::history::store_size_on_disk() (the store file plus SQLite’s -wal/-shm siblings). It cannot be derived from History lines - the store is bounded by rows and bytes, so the real footprint depends on how long the lines were - and it is refreshed on NOTIFICATION_VISIBILITY_CHANGED rather than once, because the panel outlives a visit while scrollback keeps growing. To add a new setting: (1) add _cfg_ var, (2) add UI control, (3) add one line to _apply_settings_to(). _build_wrapper() calls it automatically - no other wiring needed. Pane-local settings that are NOT terminal settings (e.g. cfg_reasoning_max_turns, cfg_reasoning_max_turn_bytes) are read by the pane from SettingsManager at _ready with clamps - changes apply to panes created afterward; no _apply_settings_to wiring.

  • First-run shell: SettingsManager.default_shell_command() - /bin/bash on Unix, %COMSPEC% (falling back to cmd.exe) on Windows. The Windows branch exists because a POSIX path is not absolute there and validate_executable refuses it, so every pane of a fresh Windows install failed to start. pane-run picks the “run this and exit” flag from the shell family with PaneTypes.shell_run_args() (/c for cmd.exe, -c otherwise) - the pane API runs through the user’s configured shell, so the flag cannot be hard-coded.

  • Terminal spawning: _build_wrapper() is the sole entry point; all paths go through it

  • Layout Constraints: The tiling grid relies on Godot Control nodes. Prefer using Godot’s built-in Size Flags (Expand/Fill) inside containers (HBoxContainer/VBoxContainer) over manual pixel math. When manual math is absolutely required (like terminal cell reflows), hook into _notification(NOTIFICATION_RESIZED).

  • Pane activation & keyboard routing: clicking any pane (body, titlebar, or inner widget) makes it the active pane - workspace _input hit-tests tile wrappers with event.position (raw input, because inner RichTextLabels/ScrollContainers consume clicks before _unhandled_input ever fires). Only terminals take keyboard focus: activating a non-terminal pane releases the previous focus owner so keystrokes stop flowing to the terminal the user just left (read-only panes swallow keys by design - uniform click-to-activate, no fall-through). That rule lives in exactly one place, Workspace.activate_pane(body) (records last_body in the body’s own workspace, then _take_focus() for a terminal and a focus release otherwise) - the three activation paths (click, sidebar row, IPC focusPane) all call it, because the copies had already drifted: focusPane called grab_focus() unconditionally, so focusing a read-only pane raised an engine error and activated nothing while still answering success: true. A focusable child (Inspector input) re-resolves its owning pane each frame in _refresh_status_bar via _body_of_focus_owner. Profile activation must end with _apply_active_workspace_view() - the same refresh path as workspace switching - or restored wrappers stay unlaid-out and the sidebar pane list stale. That refresh is also where a workspace with no remembered active pane resolves one: launch, a restored workspace never visited, and a profile rebuild all leave last_body null, because it is only set by a click, a spawn, or a focus event. _fallback_active_body() picks the first terminal (only terminals take keyboard focus), falling back to the first pane so a read-only workspace still has an active row, and the result goes through activate_pane like every other path - without it the first keystroke after launch reached nothing until the user clicked a pane (UX-01).

  • Capture Bridge: Rust never calls into Godot. GDScript polls the backend from _process() - drain_concept_events() for completed captures and drain_agent_events() for Tier 1 agent state.

  • Agent-state observation contract: PaneBody.agent_state_source_id() names the terminal a pane observes by stable attachment_id (a terminal returns its own id - the tiered tracker it reads is that terminal’s state; empty = observes nothing, the base default) and PaneBody.on_agent_state_changed(state) receives that terminal’s state. workspace.gd is the only dispatcher: TerminalPane.agent_state_changed (emitted from the slow poll on a real transition) calls _dispatch_agent_state, and a per-frame _prime_new_pane_observers scan delivers the current state once, to the new pane alone, through _tell_observer - any pane-entry route (spawn, restore, swap, a future pane SDK), a follower whose terminal appears later (retried), and a pane re-pointed at a different source (the stored record is the source it was primed for plus the state it was last told), all without re-priming a settled pane. A change dispatch reaches only panes already settled for that source - delivering to an unsettled one would make the scan’s entry the duplicate - and _tell_observer drops a value the observer already holds, the dispatcher-side mirror of the terminal’s own _badge_state rule, so no observer hears the same state twice whichever path carried it. Panes are found through the panes group, so hidden workspaces keep observing. The titlebar badge is the first implementation and runs through this path (TerminalPane.on_agent_state_changed applies it), not beside it: the hook also records the delivery (_badge_state is what the pane was last told), and the slow poll reports only deltas against it - its first read before any delivery is adopted silently, so the entry prime stays the single delivery of the spawn state instead of the poll re-announcing it as a change. Display only: a Tier 2 declaration is spoofable by anything that prints and Tier 3 is a heuristic, so a hook must never feed a decision, an action, a capture, or IPC.

  • Pane status contract: PaneBody.status_document() -> Variant is the generic paneStatus hook (null when the pane has no primitives). TerminalPane returns its process/grid document unchanged; MediaPane returns {kind,file,state,position_us,duration_us,paused,loop,volume_db,muted} while a file is open. ipc_handlers.gd uses the hook for both the aggregate (a pane without primitives keeps status: null rather than disappearing) and a targeted request (a generic “no status primitives” error, never “not found”). Any future pane that has observable state implements the override; do not add a per-type branch in the IPC handler.

  • Close contract: PaneBody.request_close(answer) asks before a pane is destroyed, and PaneBody.has_unsaved_state() is its state half. answer receives a bool - true proceeds with the teardown, false cancels the whole action - and it may arrive deferred (the wiki pane opens a Save/Discard/Cancel dialog), so a caller passes a continuation, never destroys the pane itself, and must honour the bool it gets back (a continuation that ignores it turns a Cancel into a go-ahead - measured: the Back button closed the note it had just been told to keep). The base answers true at once, so a pane with nothing to lose never asks. Every destruction path goes through it: Workspace._kill/_kill_now (the titlebar X, the sidebar’s close button, Ctrl+Shift+W, the palette, IPC killPane), _swap_pane (the old body dies), _reset/_do_reset/_do_activate (reset, profile activation) and _close_workspace/_restore, plus request_quit() - the app’s only quit path, reached from the window manager’s close request (auto_accept_quit is off), the titlebar’s close button and the Rust shutdown flag (daemon stop, a refused socket bind) - which walks every pane of every workspace through the same ask (a Cancel cancels the quit; quit_action is the seam the tests replace, and the _quit_asking flag is cleared on both answers so a canceled quit can be asked again). _ask_close_then(bodies, next, declined, ask_running) is the one walk, in two phases: stopping running processes is the action’s hazard, so _ask_running_then asks once for the whole set - PaneBody.has_running_work(), naming the busy panes - and only then does each pane answer for work only it can describe (the wiki’s Save/Discard/Cancel) through request_close; a bulk close of sixteen terminals must not raise sixteen dialogs. A refusal at either phase stops the walk, so panes it had not reached are never asked. TerminalPane.has_running_work() reads the status document’s foreground_job: Unix is exact (portable-pty’s process_group_leader()/tcgetpgrp on the master compared with the spawned child, so cmd & does not count and a pane whose configured program is the child reports none), while Windows/ConPTY has no foreground-group notion and answers null, which the pane must treat as busy while the shell runs - the conservative fallback. _kill/_swap_pane route through the same two-phase walk, so a titlebar X, Ctrl+Shift+W, the palette, a type swap and a workspace close all ask the same way. A caller that cannot answer a dialog - IPC - must not ask: it checks has_unsaved_state() and refuses with an error naming the pane (killPane, layoutLoad, the rule layoutLoad already applied to an untrusted profile). Stopping processes is not refused, because naming a kill or a load is consent to it: killPane calls the _kill_now primitive and layoutLoad activates with ask_running: false.

  • Pane request contract: panes never spawn panes. A body that needs another pane emits PaneBody.request_pane(type, settings); the workspace connects it in _attach_pane_into (so every entry route - spawn, restore, swap - carries it) and answers through _spawn_pane_into followed by activate_pane, holding type to PaneTypes.ALL so the spawn surface stays closed. The workspace may answer with an existing pane: a code_viewer request for a file some viewer in that workspace already shows is focused and not duplicated (a viewer’s scroll, selection and rendered/source mode belong to its document, so silently repointing one is rejected; a second view is a deliberate second viewer), which is why an emitter must not assume its request created anything and why the viewer needs the explicit Reload action. The file tree’s open_in is per-tree state (viewer default, system optional), saved in the tile like root_path; the origin owns the choice rather than a global setting.