Concept capture
- Two modes:
UntilStop { stop_timeout_ms, stop_on_input }(buffer output until timeout or user input, then route it to a pane) andSingleLine(notify-only: publish the match on the event socket, capture nothing, route nothing, leave the output in the terminal). A missing or unknowncapture_modeisUntilStop; a legacycmdkey is ignored on parse. - Matching:
concept::match_linetests PTY output lines. Precedence is explicit - a capture outranks a notify: it returns the firstUntilStopmatch, or the firstSingleLinematch when nothing captures, first-wins within each class. (First-match-only plus “notify starts nothing” let a broad notify-only concept consume every line and silently starve the capture behind it.) Within a class the order is the oneConceptManagermerged the concepts in - the visual editor’s canvas order when the store has one (see the Visual Concept Graph bullet). OnUntilStopthe engine enters capture mode - subsequent PTY output is buffered, not fed to the grid; on timeout or user input,finalize_capture()queues aCapturedOutputevent. OnSingleLinethe match goes to the notice queue and nothing else happens. - Capture lifecycle:
- PTY output feeds
LineParser→ lines flow to the compiledConceptMatcherin the engine’s PTY-output handler (one read lock per batch, not per line), which answers “could anything match” with a single combinedRegexSetpass before runningconcept::match_line’s ordered per-concept loop. The gate is derived from the same triggers and conditions are still evaluated by the loop, so it can only skip work, never change a verdict (matcher_agrees_with_match_line_on_every_pair). - On an
UntilStopmatch → engine enters capture state, buffers raw bytes, suppresses grid feed. A typed line that matches is handled on the stdin path the same way. On aSingleLinematch → the concept name is queued (ConceptNotice, bounded at 64, drop-oldest) and nothing else happens;workspace.gddrains it and emits{type: concept, event: matched, mode: single_line, name, source}on the event socket. Metadata only - never the matched line. - Timeout or user input →
finalize_capture()queuesCapturedOutputwith plain-text lines and target label. - GDScript polls via
drain_concept_events()each frame, routes to receiver pane bytarget_pane_type. - Receiver found →
acknowledge_capture(bytes discarded). No receiver →flush_capture(bytes replayed to grid) + toast.
- PTY output feeds
- Prompt restoration: Shell prompts lack trailing
\nsoLineParsernever emits them. On acknowledge, raw bytes after last\nare extracted and fed to grid with\r\nprefix for correct cursor positioning. - Default concepts: Shipped in
godot/concepts.default.json.ConceptManager._merge_concepts()deep-merges defaults + user concepts (user keys overlay default keys). Trigger migration updates old regex patterns to new ones. - Conditions (v0.5.4): a concept may carry
conditions: [regex, …]- extra predicates over the same line the trigger matched; every one must match before the concept fires. They can only narrow a match: a concept whose conditions fail falls through to the next concept (precedence is unchanged) and never starts a capture on its own. Parsing rejects the whole concept when a condition is not a string, is empty/oversized, does not compile, or the list exceedsMAX_CONDITIONS(8) - dropping a single condition would widen matching, the dangerous direction.GptyTerminal.validate_regex()exposes the same engine-dialect check to GDScript. - Visual Concept Graph (v0.5.4):
godot/scenes/ui/concept_graph_editor.gdis a GraphEdit overlay opened from Settings → Concepts → “Open Visual Editor”. Each rule is one chain - trigger → condition* → action (capture & route, or notify only); ports hold one wire, so chains cannot fork or merge. The canvas is not a second content store:conceptsremains the single store. Thegraphsibling key inuser://concepts.jsonholds only the canvas’s own state -positions(keyed by<name>#t/<name>#c<i>/<name>#aand draft ids),drafts(nodes outside a complete rule, so unfinished work survives a close), andorder(rule names, top-to-bottom as the canvas shows them) - never content.ConceptGraphModel(pure, unit-tested) builds the canvas from merged concepts, validates + compiles it back, writes a rule equal to its shipped default as no user entry (defaults keep tracking updates), tombstones a deleted shipped rule exactly like the manual dialog, and keeps content-invalid rules as drafts with an error instead of compiling them. The compiled entry’s key set is closed byentry_for_path- a node can never smuggle another key into the file. Rule order is precedence (v0.5.4):ConceptManager._merge_conceptsputs the rulesordernames first, in that order, and everything the canvas does not name after them - so a newly shipped default can never quietly outrank an arrangement the user made (it appears unranked on the canvas next time), and a store with noorderkey keeps exactly the shipped merge order (defaults first, then user entries in file order).ConceptGraphModel.rule_orderis the single derivation (it accepts the editor’sVector2and the file’s[x, y]alike), each trigger’s title shows the rank it produced, and the editor’s “Fit” re-rows withtidy_positionsrather than Godot’sarrange_nodes(), which lays nodes out by connection shape and would silently reshuffle precedence. Positions stay the canvas’s geometry:orderis derived from them when the editor saves, and nothing derives positions fromorder. - Routed-content contract:
PaneBody.can_receive_content(event)advertises current capability andreceive_content(text, event)returns whether delivery succeeded. Routers must continue past declines and acknowledge source bytes only aftertrue; if all receivers decline, flush the capture back to the terminal. - Concepts that target Inspector (
git_log,cargo_check) ship disabled. Do not re-enable them in defaults: captured terminal output must not be sent to an AI backend without explicit user opt-in. Inspector additionally gates captures behind its ownaccept_concept_capturesexport (defaultfalse). - Legacy observer-target concepts are NOT migrated to inspector:
ConceptManager._migrate_actions_targetsetsenabled = falseon any concept whose action targetsobserver(at merge and save time), so a stale capture can never start an Inspector job. - Matching cost under flood (v0.5.4): the aggregate cost is lines × concepts × regex, and the per-concept loop paid each regex’s own prologue per line (~20 ns per concept), so a 128-concept library cost 2.7 µs on a line matching nothing.
ConceptMatcherputs one combinedRegexSetpass in front of the loop, which cannot change a verdict (same triggers; conditions still evaluated by the loop) and took the measured pane flood from 5.8 → 73.3 MB/s at the parser’s ceiling and 8.6 → 44.9 MB/s for 128 typical triggers on short lines (cargo run --release -p gpty-core --example concept_probe). No rate cap on matches: the parse caps (count, trigger and condition length) bound the per-line work, the gate removes the per-concept multiplier, matching runs inside the pane’s own task (one core, never the UI thread), and dropping matches under load would silently break the feature’s contract. Re-measure with the probe before adding any cap. - Alt-screen suppression: the engine skips
concept::match_linewhile the grid is in alternate-screen mode (TermGrid::is_alt_screen()). Full-screen apps repaint themselves; their redraw lines are presentation, not shell output (an OMP TUI repaint re-emitting a transcript line containingcatmust not fire the cat concept). - Resize is not user input: only
StdinInput::LineandStdinInput::Rawmay stop an activeUntilStopcapture.Resize/FlushCapture/AcknowledgeCaptureshare the stdin channel but are internal plumbing; letting them finalize a capture replays old bytes into the grid and fires spurious “no receiver” toasts. There is also a 750 ms post-resize concept-match suppression window (POST_RESIZE_CONCEPT_SUPPRESS_MS) for the shell’s SIGWINCH redraw. - Concept event routing:
workspace.gd._process()polls all terminal panes, drains events, and tries matching receivers by_pane_type()until one accepts. No accepting receiver → toast + flush. - Concepts capture and display; they never execute. A concept definition is untrusted data (a JSON file a user may have downloaded):
Conceptcarries a trigger, a stop condition, and a routing target - no command, no label, no PTY write. The engine’s only writes to a PTY are user input and resize. The visual editor keeps the same vocabulary: a node carries a regex, a capture mode + stop condition, or a routing target, andConceptGraphModel.entry_for_pathcompiles the closed key set (no command field exists to add). Terminal ids stay unique process-wide (start_shelldraws from a global counter) because they key capture state, status, and logs. - Layout migration:
PaneTypes.migrate_pane_settings()runs before type validation. Legacytype: observer+stream: thinkingbecomesreasoning; other observer tiles becomeinspector. RemovingobserverfromPaneTypes.ALLwithout this step silently drops saved layouts. - Pane-type vocabulary, one definition: the wire spelling is
PaneType::as_str()(gpty-core), and everything else derives from it - serde’sSerialize/Deserializedelegate to it (so a struct cannot emitcode-viewerwhile the GUI registerscode_viewer, which is what#[serde(rename_all = "kebab-case")]did),PaneType::ALLenumerates it, andnew-pane’s accepted names, the plugin-manifest validator’s closed set and the did-you-mean suggestions are built from that enumeration rather than re-listed. The GUI side isPaneTypes.ALLinpane_types.gd, read key-for-key bypane_types_match_the_gui_registry_key_for_key(crates/gpty-cli/src/plugin_manifest.rs,include_str!of the GDScript) - so a rename on either side fails a test instead of shipping, and the two count-only checks that used to guard it (test_pane_types_all_has_six_entrieshere, the literal list there) no longer carry that job alone.observerstays accepted (legacy layouts) in exactly two places:PaneType::from_strandnew-pane’s translation.