GDScript conventions
-
Indentation: tabs
-
Icons: All glyphs live in
icons.gdasconststrings (Phosphor Regular PUA codepoints via\uXXXX). To add: pick from phosphoricons.com, get the codepoint, add aconst. CallIcons.style_button(btn)after settingbtn.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 singleButtonwith a childHBox(iconLabel+ textLabel), both labels at the same font size, a 6pxoffset_leftbreathing gap, plus an explicitcustom_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_heightsizes the scroll container fromget_combined_minimum_size()(never hard-coded row heights - they drift from theme metrics and truncate the last row);_apply_row_accentmarks 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_resetand on deleting the active profile), panes by_tm.last_bodyviaset_active_pane(no list rebuild on focus change). The pane list absorbs leftover height viaSIZE_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 ofuser://profiles.json; the sidebar gives them no delete/rename affordance, and their tooltip names the plugin and pinned revision). Dedupe followsadd_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 byadd_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 andfind_profileresolves the collision to the plugin’s row (plugin profiles are listed before user ones), so a click on the user’s row or alayoutLoadby that name would activate the plugin’s profile.refresh_plugin_profiles()is the only store reader and the only emitter: it parsesGptyTerminal.installed_plugin_profiles()(theplugin_profiles_sourceCallable is the test seam), skips malformed entries instead of raising, re-derives the block and emitsprofiles_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:layoutListlists these names,find_profileresolves them forlayoutLoad, and activation clears the workspace (_reset()) then rebuilds tiles (_restore_into()), rebuilt for the remaining built-ins and covered bytest_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 trailingIcons.NEW_WORKSPACEglyph opens it in a workspace of its own (_do_activate(profile, ask_running, open_in_new)→_create_workspace_for_profile: named after the profile throughWorkspaceStore.unique_name(a second one gets " 2"), inheriting the active workspace’s cwd, switched to before the restore, refused atMAX_WORKSPACESbefore the trust prompt, and with no replace confirmation because nothing live is lost). The Workspace Trust dialog carriesopen_in_newthrough, 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 likeT1.pane_labelis reassigned on restore and must not be used as a saved link. It is also the public IPC id: every pane has one (auto-generatedpane-XXXXXXXXviaPaneTypes.generate_attachment_id()when none was saved -PaneBody.apply_settingsis the choke point),newPanereturns it andlistPanesreports it asidalongside the displaylabel. IPC pane targeting accepts eitheridor the legacylabel. 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 untypedArray. Assignment toArray[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 setsPROCESS_MODE_ALWAYS. Subclasses override_on_init()instead of_ready(). Loads read through the typed accessors (_as_int/_as_bool/_as_array/…) rather than a rawd.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 inlineFileAccess.open()- use_read_file/_write_filefor JSON stores and_read_text/_write_textfor text assets. Writes go to a sibling temp file and are renamed into place:FileAccess.WRITEtruncates the target as soon as it opens, and_read_filereads 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>.tmplet anything able to write inuser://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 readuser://. -
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.godotautoload paths andpreload()/load()calls use the fullres://scenes/<dir>/<file>.gdpath. -
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 FFIGptyTerminal.history_store_stats()→gpty_core::history::store_size_on_disk()(the store file plus SQLite’s-wal/-shmsiblings). It cannot be derived fromHistory lines- the store is bounded by rows and bytes, so the real footprint depends on how long the lines were - and it is refreshed onNOTIFICATION_VISIBILITY_CHANGEDrather 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 fromSettingsManagerat_readywith clamps - changes apply to panes created afterward; no_apply_settings_towiring. -
First-run shell:
SettingsManager.default_shell_command()-/bin/bashon Unix,%COMSPEC%(falling back tocmd.exe) on Windows. The Windows branch exists because a POSIX path is not absolute there andvalidate_executablerefuses it, so every pane of a fresh Windows install failed to start.pane-runpicks the “run this and exit” flag from the shell family withPaneTypes.shell_run_args()(/cforcmd.exe,-cotherwise) - 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
Controlnodes. 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
_inputhit-tests tile wrappers withevent.position(raw input, because inner RichTextLabels/ScrollContainers consume clicks before_unhandled_inputever 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)(recordslast_bodyin the body’s own workspace, then_take_focus()for a terminal and a focus release otherwise) - the three activation paths (click, sidebar row, IPCfocusPane) all call it, because the copies had already drifted:focusPanecalledgrab_focus()unconditionally, so focusing a read-only pane raised an engine error and activated nothing while still answeringsuccess: true. A focusable child (Inspector input) re-resolves its owning pane each frame in_refresh_status_barvia_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 leavelast_bodynull, 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 throughactivate_panelike 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 anddrain_agent_events()for Tier 1 agent state. -
Agent-state observation contract:
PaneBody.agent_state_source_id()names the terminal a pane observes by stableattachment_id(a terminal returns its own id - the tiered tracker it reads is that terminal’s state; empty = observes nothing, the base default) andPaneBody.on_agent_state_changed(state)receives that terminal’s state.workspace.gdis 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_observersscan 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_observerdrops a value the observer already holds, the dispatcher-side mirror of the terminal’s own_badge_staterule, so no observer hears the same state twice whichever path carried it. Panes are found through thepanesgroup, so hidden workspaces keep observing. The titlebar badge is the first implementation and runs through this path (TerminalPane.on_agent_state_changedapplies it), not beside it: the hook also records the delivery (_badge_stateis 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() -> Variantis the genericpaneStatushook (null when the pane has no primitives).TerminalPanereturns its process/grid document unchanged;MediaPanereturns{kind,file,state,position_us,duration_us,paused,loop,volume_db,muted}while a file is open.ipc_handlers.gduses the hook for both the aggregate (a pane without primitives keepsstatus: nullrather 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, andPaneBody.has_unsaved_state()is its state half.answerreceives a bool -trueproceeds with the teardown,falsecancels 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 answerstrueat 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, IPCkillPane),_swap_pane(the old body dies),_reset/_do_reset/_do_activate(reset, profile activation) and_close_workspace/_restore, plusrequest_quit()- the app’s only quit path, reached from the window manager’s close request (auto_accept_quitis 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_actionis the seam the tests replace, and the_quit_askingflag 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_thenasks 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) throughrequest_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’sforeground_job: Unix is exact (portable-pty’sprocess_group_leader()/tcgetpgrpon the master compared with the spawned child, socmd &does not count and a pane whose configured program is the child reports none), while Windows/ConPTY has no foreground-group notion and answersnull, which the pane must treat as busy while the shell runs - the conservative fallback._kill/_swap_paneroute 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 checkshas_unsaved_state()and refuses with an error naming the pane (killPane,layoutLoad, the rulelayoutLoadalready applied to an untrusted profile). Stopping processes is not refused, because naming a kill or a load is consent to it:killPanecalls the_kill_nowprimitive andlayoutLoadactivates withask_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_intofollowed byactivate_pane, holdingtypetoPaneTypes.ALLso the spawn surface stays closed. The workspace may answer with an existing pane: acode_viewerrequest 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’sopen_inis per-tree state (viewerdefault,systemoptional), saved in the tile likeroot_path; the origin owns the choice rather than a global setting.