An adapter can now name its placeholder as a pattern the box's first line matches in full (input_placeholder_pattern); input_placeholder stays the literal lib.sh greps for. opencode: `Ask anything… "<example>"`, the closing quote optional because a box under ~65 columns cuts the example. Fixtures: the 140-column herdr capture and a 60-column one with the cut example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
89 lines
4.2 KiB
Python
89 lines
4.2 KiB
Python
# 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()
|