Skip to content
Concept capture

Concept capture

  • Two modes: UntilStop { stop_timeout_ms, stop_on_input } (buffer output until timeout or user input, then route it to a pane) and SingleLine (notify-only: publish the match on the event socket, capture nothing, route nothing, leave the output in the terminal). A missing or unknown capture_mode is UntilStop; a legacy cmd key is ignored on parse.
  • Matching: concept::match_line tests PTY output lines. Precedence is explicit - a capture outranks a notify: it returns the first UntilStop match, or the first SingleLine match 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 one ConceptManager merged the concepts in - the visual editor’s canvas order when the store has one (see the Visual Concept Graph bullet). On UntilStop the engine enters capture mode - subsequent PTY output is buffered, not fed to the grid; on timeout or user input, finalize_capture() queues a CapturedOutput event. On SingleLine the match goes to the notice queue and nothing else happens.
  • Capture lifecycle:
    • PTY output feeds LineParser → lines flow to the compiled ConceptMatcher in the engine’s PTY-output handler (one read lock per batch, not per line), which answers “could anything match” with a single combined RegexSet pass before running concept::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 UntilStop match → 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 a SingleLine match → the concept name is queued (ConceptNotice, bounded at 64, drop-oldest) and nothing else happens; workspace.gd drains 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() queues CapturedOutput with plain-text lines and target label.
    • GDScript polls via drain_concept_events() each frame, routes to receiver pane by target_pane_type.
    • Receiver found → acknowledge_capture (bytes discarded). No receiver → flush_capture (bytes replayed to grid) + toast.
  • Prompt restoration: Shell prompts lack trailing \n so LineParser never emits them. On acknowledge, raw bytes after last \n are extracted and fed to grid with \r\n prefix 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 exceeds MAX_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.gd is 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: concepts remains the single store. The graph sibling key in user://concepts.json holds only the canvas’s own state - positions (keyed by <name>#t / <name>#c<i> / <name>#a and draft ids), drafts (nodes outside a complete rule, so unfinished work survives a close), and order (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 by entry_for_path - a node can never smuggle another key into the file. Rule order is precedence (v0.5.4): ConceptManager._merge_concepts puts the rules order names 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 no order key keeps exactly the shipped merge order (defaults first, then user entries in file order). ConceptGraphModel.rule_order is the single derivation (it accepts the editor’s Vector2 and the file’s [x, y] alike), each trigger’s title shows the rank it produced, and the editor’s “Fit” re-rows with tidy_positions rather than Godot’s arrange_nodes(), which lays nodes out by connection shape and would silently reshuffle precedence. Positions stay the canvas’s geometry: order is derived from them when the editor saves, and nothing derives positions from order.
  • Routed-content contract: PaneBody.can_receive_content(event) advertises current capability and receive_content(text, event) returns whether delivery succeeded. Routers must continue past declines and acknowledge source bytes only after true; 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 own accept_concept_captures export (default false).
  • Legacy observer-target concepts are NOT migrated to inspector: ConceptManager._migrate_actions_target sets enabled = false on any concept whose action targets observer (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. ConceptMatcher puts one combined RegexSet pass 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_line while 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 containing cat must not fire the cat concept).
  • Resize is not user input: only StdinInput::Line and StdinInput::Raw may stop an active UntilStop capture. Resize/FlushCapture/AcknowledgeCapture share 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): Concept carries 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, and ConceptGraphModel.entry_for_path compiles the closed key set (no command field exists to add). Terminal ids stay unique process-wide (start_shell draws from a global counter) because they key capture state, status, and logs.
  • Layout migration: PaneTypes.migrate_pane_settings() runs before type validation. Legacy type: observer + stream: thinking becomes reasoning; other observer tiles become inspector. Removing observer from PaneTypes.ALL without 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’s Serialize/Deserialize delegate to it (so a struct cannot emit code-viewer while the GUI registers code_viewer, which is what #[serde(rename_all = "kebab-case")] did), PaneType::ALL enumerates it, and new-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 is PaneTypes.ALL in pane_types.gd, read key-for-key by pane_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_entries here, the literal list there) no longer carry that job alone. observer stays accepted (legacy layouts) in exactly two places: PaneType::from_str and new-pane’s translation.