# input_region.py — Rule-based input region extraction and normalization (T2 & T3) import re from lib_py.agents.registry import get_adapter def extract_input_region(pane_text: str, agent_name: str) -> str: """ Extracts the input area text of a pane, using only the agent adapter's facts (issue #30, measured on herdr 0.9.1 captures in tests/fixtures/panes): 1. an `input_block_prefix` -> the last contiguous block of lines starting with it, prefix removed, cut at the first `input_stop_pattern` line; 2. two or more `input_rule_pattern` rules -> the lines between the last two; 3. an `input_prompt` -> from the last line starting (column 0) with the prompt to the end of the pane, minus a last non-blank line matching `input_stop_pattern` (the status footer). Every other line below the prompt line is input: a multi-line draft is never cut to its first line. Then `input_frame_chars` are stripped, and the prompt and the placeholder (whole-line: `input_placeholder` exactly or `input_placeholder_pattern` in full) are stripped from the box's first non-blank line only. There is no whole-pane fallback: a pane with none of these shapes has no input region and yields a non-empty "unknown" result rather than a guess. Raises ValueError for agents without input area facts (like hermes' peers). """ adapter = get_adapter(agent_name) if not adapter or not adapter.input_prompt: raise ValueError(f"Agent {agent_name!r} has no input area facts") lines = pane_text.splitlines() prompt = adapter.input_prompt region_lines = None if adapter.input_block_prefix: prefix = adapter.input_block_prefix block = [] for line in reversed(lines): if line.lstrip().startswith(prefix): block.insert(0, line.lstrip()[len(prefix):]) elif block: break stop_re = re.compile(adapter.input_stop_pattern) if adapter.input_stop_pattern else None for i, line in enumerate(block): if stop_re and stop_re.search(line): block = block[:i] break # No block on screen is not an empty box: fall through to the other shapes. region_lines = block or None if region_lines is None and adapter.input_rule_pattern: rule_re = re.compile(adapter.input_rule_pattern) rule_indices = [i for i, line in enumerate(lines) if rule_re.search(line)] if len(rule_indices) >= 2: region_lines = lines[rule_indices[-2] + 1:rule_indices[-1]] if region_lines is None: # The composer's prompt sits at column 0; a draft's continuation lines # are indented, so an indented prompt character is draft text (review # ad3d8206), never the start of the input box. starts = [i for i, line in enumerate(lines) if line.startswith(prompt)] if starts: region_lines = [line for line in lines[starts[-1]:]] while region_lines and not region_lines[-1].strip(): region_lines.pop() if (len(region_lines) > 1 and adapter.input_stop_pattern and re.search(adapter.input_stop_pattern, region_lines[-1])): region_lines.pop() else: # No input line at all: report the whole tail as "not empty" (unknown). region_lines = [pane_text or '?'] frame = adapter.input_frame_chars if frame: region_lines = [line.strip().strip(frame) for line in region_lines] # The prompt and the placeholder belong to the box's first line only: the # same characters further down are what the user typed. region_lines = list(region_lines) first = next((i for i, line in enumerate(region_lines) if line.strip()), None) if first is not None: head = re.sub(r'^\s*' + re.escape(prompt) + r'\s*', '', region_lines[first], count=1) placeholder = adapter.input_placeholder pattern = adapter.input_placeholder_pattern if (placeholder and head.strip() == placeholder) or (pattern and re.fullmatch(pattern, head.strip())): head = '' region_lines[first] = head return "\n".join(region_lines).strip()