fleetd #782: tell a placeholder hint apart from real typed text in the prompt box
The box gate held every delivery into an idle lead pane. Claude Code
draws a placeholder hint in the empty input box - the pane's own last
submitted prompt - and draws it faint. PromptBox read the pane with
source 'detection', which carries no escape codes at all, so the
dimness was gone before classify ran and the hint read as a draft.
- AgentControl gains readWithStyling, which sends strip_ansi: false.
The two-argument read is untouched for its other callers.
- PROBE_SOURCE moves to 'visible', the source that does carry styling.
- markerLength skips leading SGR codes, because the grey drawn on an
empty box's caret sits before the marker glyph.
- boxContent decodes the line into (char, faint) pairs and drops every
character inside a faint span.
- boxContent also uses Character.isSpaceChar, not only isWhitespace.
This was cosmetic on 'detection', which trimmed a trailing U+00A0;
on 'visible' an empty box arrives as '<caret>\u00a0' and would
otherwise read as DRAFT with 1 character.
Verified in a throwaway worktree at 4ef8156: mvn clean install, BUILD
SUCCESS, 179 test classes, 2184 tests, 0 failures, 0 errors, 0 skipped,
counted from fleetd/target/surefire-reports/*.xml. PromptBoxTest is 21
tests, up from 15.
Measurements behind the four fixtures, taken from seven live panes with
'herdr agent read <paneId> --source visible --ansi': the two held panes
carried SGR 2 around their hint text, the five empty boxes carried no
faint span, and the caret grey on one empty box was a 24-bit colour
sitting before the marker.
Two checks on the move to 'visible' that the fix depends on:
- The active-turn marker 'esc to interrupt' still arrives as one
contiguous string with styling kept, so the UNREADABLE guard for a
live turn is intact.
- Across all seven panes the only intensity codes emitted are 2 and 0.
SGR 22 (normal intensity) never appears, and no compound code mixes
an intensity with a colour, so exact-matching those two codes is
enough for this build. A build that emitted 22 would leave faint on
and could read a drafted box as empty; tracked on #782.
PR #784's HOLD_GIVE_UP_STREAK is deliberately not here. I asked for it
and it was wrong: the hold cadence measures 15.07s, so 1200 holds is
about five hours, and giving up submits whatever sits in the box.
This commit is contained in:
@@ -137,6 +137,18 @@ public final class AgentControl {
|
||||
return result.path("read").path("text").asText("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an agent's terminal with its ANSI styling kept, instead of the stripped text {@link
|
||||
* #read} returns. Needed when a caller must tell apart text the pane draws dim (a placeholder
|
||||
* hint) from text drawn plain (the operator's own typing).
|
||||
*
|
||||
* @param source one of {@code visible|recent|recent_unwrapped|detection}
|
||||
*/
|
||||
public String readWithStyling(String target, String source) {
|
||||
JsonNode result = agentCall("agent.read", target, Map.of("source", source, "strip_ansi", false));
|
||||
return result.path("read").path("text").asText("");
|
||||
}
|
||||
|
||||
/** Current agent record (status, session UUID, pane). */
|
||||
public Agent get(String target) {
|
||||
return Agent.from(agentCall("agent.get", target, Map.of()).get("agent"));
|
||||
|
||||
@@ -3,9 +3,12 @@ package dev.ltms.fleet.herdr;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Whether an agent pane's input box is clear for a delivery.
|
||||
@@ -29,20 +32,22 @@ public final class PromptBox {
|
||||
private static final Logger log = LoggerFactory.getLogger(PromptBox.class);
|
||||
|
||||
/**
|
||||
* herdr {@code agent.read} source. {@code detection} is the region herdr itself uses for status
|
||||
* detection, so the input box is always drawn in it. It is not a short tail: it carries transcript
|
||||
* scrollback above the box, including earlier prompts the operator has already submitted, which is
|
||||
* why only the last box line on it is the live one.
|
||||
* herdr {@code agent.read} source, read with its ANSI styling kept. The input box is always
|
||||
* drawn here, carrying transcript scrollback above it, which is why only the last box line is
|
||||
* the live one. Styling must survive the read because the pane draws a placeholder hint — the
|
||||
* pane's own last submitted prompt — in the same spot as unsubmitted text, dimmed; only the
|
||||
* escape codes tell the two apart.
|
||||
*/
|
||||
static final String PROBE_SOURCE = "detection";
|
||||
static final String PROBE_SOURCE = "visible";
|
||||
|
||||
/** Consecutive holds for one target before one warning is logged. */
|
||||
static final int HOLD_WARN_STREAK = 20;
|
||||
|
||||
/**
|
||||
* Input box markers, each matched only as a line's first characters: the caret the current TUI
|
||||
* draws, and the bordered box an older one drew. A marker further along a line is transcript text,
|
||||
* such as a caret inside something the operator quoted.
|
||||
* Input box markers, each matched only as a line's first characters once any leading ANSI
|
||||
* escape codes are skipped: the caret the current TUI draws, and the bordered box an older one
|
||||
* drew. A marker further along a line is transcript text, such as a caret inside something the
|
||||
* operator quoted.
|
||||
*/
|
||||
private static final List<String> BOX_MARKERS = List.of("❯", "│ >");
|
||||
|
||||
@@ -52,6 +57,15 @@ public final class PromptBox {
|
||||
/** Block glyphs a terminal capture can leave in an otherwise empty box for the cursor cell. */
|
||||
private static final String CURSOR_GLYPHS = "█▉▊▋▌▍▎▏";
|
||||
|
||||
/** An SGR escape sequence, e.g. {@code ESC[2m} (faint) or {@code ESC[0m} (reset). */
|
||||
private static final Pattern SGR = Pattern.compile("\u001b\\[([0-9;]*)m");
|
||||
|
||||
/** The SGR code that dims text — herdr's placeholder hint is drawn inside a span of this. */
|
||||
private static final String FAINT_CODE = "2";
|
||||
|
||||
/** The SGR code (or an empty code list) that clears every attribute, including faint. */
|
||||
private static final List<String> RESET_CODES = List.of("", "0");
|
||||
|
||||
/** What a box holds: nothing, unsubmitted characters, or a pane this cannot read as a box. */
|
||||
public enum State { EMPTY, DRAFT, UNREADABLE }
|
||||
|
||||
@@ -94,7 +108,7 @@ public final class PromptBox {
|
||||
private Reading inspect(String target) {
|
||||
String pane;
|
||||
try {
|
||||
pane = agents.read(target, PROBE_SOURCE);
|
||||
pane = agents.readWithStyling(target, PROBE_SOURCE);
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("prompt box read for {} failed, holding delivery: {}", target, e.getMessage());
|
||||
return new Reading(State.UNREADABLE, 0);
|
||||
@@ -114,10 +128,9 @@ public final class PromptBox {
|
||||
* an earlier turn's marker survives; treating that as a live turn would make {@link State#EMPTY}
|
||||
* unreachable and hold every delivery forever.
|
||||
*
|
||||
* <p>Whitespace, a trailing box border and a cursor block count as nothing. Any other character
|
||||
* counts as the operator's unsubmitted text, including a placeholder hint a future TUI might draw
|
||||
* there; that direction holds a delivery it could have sent, which {@link #HOLD_WARN_STREAK} makes
|
||||
* visible.
|
||||
* <p>Whitespace, a trailing box border and a cursor block count as nothing. A placeholder hint —
|
||||
* text the pane draws faint, in the same spot as unsubmitted text — also counts as nothing: only
|
||||
* a character drawn outside a faint span is the operator's own typing.
|
||||
*/
|
||||
static Reading classify(String pane) {
|
||||
if (pane == null || pane.isBlank()) return new Reading(State.UNREADABLE, 0);
|
||||
@@ -142,28 +155,77 @@ public final class PromptBox {
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Length of the box marker this line starts with, or {@code 0} if it starts with none. */
|
||||
/**
|
||||
* Length of the marker prefix — any leading SGR escape codes, then a box marker — this line
|
||||
* starts with, or {@code 0} if it starts with neither. The colour drawn on the caret itself
|
||||
* (e.g. an empty box's grey) sits before the marker glyph, so it must be skipped before the
|
||||
* marker can match.
|
||||
*/
|
||||
private static int markerLength(String line) {
|
||||
int skip = leadingEscapeLength(line);
|
||||
for (String marker : BOX_MARKERS) {
|
||||
if (line.startsWith(marker)) return marker.length();
|
||||
if (line.startsWith(marker, skip)) return skip + marker.length();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Length of the run of SGR escape codes starting at the beginning of {@code line}. */
|
||||
private static int leadingEscapeLength(String line) {
|
||||
Matcher m = SGR.matcher(line);
|
||||
int pos = 0;
|
||||
while (m.find(pos) && m.start() == pos) pos = m.end();
|
||||
return pos;
|
||||
}
|
||||
|
||||
private static String firstLine(String text) {
|
||||
int newline = text.indexOf('\n');
|
||||
return newline < 0 ? text : text.substring(0, newline);
|
||||
}
|
||||
|
||||
/** The text the box holds: its own line after the marker, stripped of border, padding and cursor. */
|
||||
/** One rendered character of a box line, and whether it was drawn inside a faint (dim) span. */
|
||||
private record Glyph(char c, boolean faint) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The text the box holds: its own line after the marker, with border, padding, cursor and any
|
||||
* faint (placeholder-hint) text left out — only a character drawn outside a faint span is the
|
||||
* operator's own typing.
|
||||
*/
|
||||
private static String boxContent(String boxLine) {
|
||||
String line = boxLine.substring(markerLength(boxLine)).stripTrailing();
|
||||
if (line.endsWith("│")) line = line.substring(0, line.length() - 1);
|
||||
List<Glyph> glyphs = renderedGlyphs(boxLine.substring(markerLength(boxLine)));
|
||||
int end = glyphs.size();
|
||||
while (end > 0 && isBoxPadding(glyphs.get(end - 1).c())) end--;
|
||||
if (end > 0 && glyphs.get(end - 1).c() == '│') end--;
|
||||
StringBuilder content = new StringBuilder();
|
||||
for (char c : line.toCharArray()) {
|
||||
if (Character.isWhitespace(c) || CURSOR_GLYPHS.indexOf(c) >= 0) continue;
|
||||
content.append(c);
|
||||
for (int i = 0; i < end; i++) {
|
||||
Glyph glyph = glyphs.get(i);
|
||||
if (glyph.faint() || isBoxPadding(glyph.c())) continue;
|
||||
content.append(glyph.c());
|
||||
}
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
/** Decode {@code text} into its rendered characters, tracking the faint (SGR 2) span each sits in. */
|
||||
private static List<Glyph> renderedGlyphs(String text) {
|
||||
List<Glyph> glyphs = new ArrayList<>();
|
||||
Matcher m = SGR.matcher(text);
|
||||
boolean faint = false;
|
||||
int i = 0;
|
||||
while (i < text.length()) {
|
||||
if (m.find(i) && m.start() == i) {
|
||||
String codes = m.group(1);
|
||||
if (RESET_CODES.contains(codes)) faint = false;
|
||||
else if (FAINT_CODE.equals(codes)) faint = true;
|
||||
i = m.end();
|
||||
continue;
|
||||
}
|
||||
glyphs.add(new Glyph(text.charAt(i), faint));
|
||||
i++;
|
||||
}
|
||||
return glyphs;
|
||||
}
|
||||
|
||||
private static boolean isBoxPadding(char c) {
|
||||
return Character.isWhitespace(c) || Character.isSpaceChar(c) || CURSOR_GLYPHS.indexOf(c) >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,10 +254,11 @@ public final class FakeHerdr implements HerdrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the text {@code agent.read} returns for the {@code detection} source only — the
|
||||
* prompt/footer tail herdr uses for status detection, a different region from the transcript the
|
||||
* other sources carry. Needed by a test whose subject reads the input box, since one
|
||||
* {@link #readText} cannot be both a worker's transcript and a lead's empty prompt.
|
||||
* Override the text {@code agent.read} returns for the {@code detection} and {@code visible}
|
||||
* sources only — the prompt/footer tail herdr uses for status detection and input-box probing, a
|
||||
* different region from the transcript the other sources carry. Needed by a test whose subject
|
||||
* reads the input box, since one {@link #readText} cannot be both a worker's transcript and a
|
||||
* lead's empty prompt.
|
||||
*/
|
||||
public FakeHerdr detectionText(String text) {
|
||||
this.detectionText = text;
|
||||
@@ -413,8 +414,8 @@ public final class FakeHerdr implements HerdrClient {
|
||||
}
|
||||
case "agent.read" -> {
|
||||
Object source = params instanceof Map<?, ?> m ? m.get("source") : null;
|
||||
String text = "detection".equals(source) && detectionText != null
|
||||
? detectionText : readText;
|
||||
boolean probeSource = "detection".equals(source) || "visible".equals(source);
|
||||
String text = probeSource && detectionText != null ? detectionText : readText;
|
||||
yield mapper.readTree(mapper.writeValueAsString(
|
||||
java.util.Map.of("type", "agent_read", "read", java.util.Map.of("text", text))));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ class PromptBoxTest {
|
||||
private static final String EMPTY = FakeHerdr.IDLE_PROMPT_CARET;
|
||||
private static final String DRAFTED = FakeHerdr.DRAFTED_PROMPT_CARET;
|
||||
|
||||
/** An empty box: the pane draws a placeholder hint (its last submitted prompt) at the caret, faint. */
|
||||
private static final String HINT_1 = "❯\u00a0\u001b[0m\u001b[2mstart md2trilium work for #2\u001b[0m";
|
||||
|
||||
/** Same shape as {@link #HINT_1}, a different placeholder hint. */
|
||||
private static final String HINT_2 = "❯\u00a0\u001b[0m\u001b[2myes, push them\u001b[0m";
|
||||
|
||||
/** An empty box with no hint: the caret itself is drawn grey, with escape codes before the marker. */
|
||||
private static final String EMPTY_GREY_CARET = "\u001b[0m\u001b[38;2;153;153;153m❯\u00a0\u001b[0m";
|
||||
|
||||
/** An empty box with no styling at all. */
|
||||
private static final String EMPTY_PLAIN = "❯\u00a0";
|
||||
|
||||
// --- pure classification -------------------------------------------------
|
||||
|
||||
@Test
|
||||
@@ -57,7 +69,7 @@ class PromptBoxTest {
|
||||
void theLastBoxLineOnThePaneIsTheLiveOne() {
|
||||
assertEquals(PromptBox.State.DRAFT,
|
||||
PromptBox.classify("❯ an earlier prompt\n⏺ its answer\n❯ typing now").state(),
|
||||
"the detection region carries scrollback, so earlier prompts sit above the live box");
|
||||
"the probed region carries scrollback, so earlier prompts sit above the live box");
|
||||
assertEquals(PromptBox.State.EMPTY,
|
||||
PromptBox.classify("❯ an earlier prompt\n⏺ its answer\n❯").state());
|
||||
}
|
||||
@@ -90,18 +102,54 @@ class PromptBoxTest {
|
||||
"that marker survives in scrollback, and holding on it would hold every delivery forever");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPlaceholderHintReadsAsAnEmptyBox() {
|
||||
assertEquals(new PromptBox.Reading(PromptBox.State.EMPTY, 0), PromptBox.classify(HINT_1),
|
||||
"the hint is the pane's own last prompt, drawn faint — it is not the operator's typing");
|
||||
assertEquals(new PromptBox.Reading(PromptBox.State.EMPTY, 0), PromptBox.classify(HINT_2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEmptyBoxWithAGreyCaretReadsAsEmpty() {
|
||||
assertEquals(new PromptBox.Reading(PromptBox.State.EMPTY, 0), PromptBox.classify(EMPTY_GREY_CARET),
|
||||
"the caret's own colour sits before the marker and must not stop the marker matching");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEmptyBoxWithNoStylingAtAllReadsAsEmpty() {
|
||||
assertEquals(new PromptBox.Reading(PromptBox.State.EMPTY, 0), PromptBox.classify(EMPTY_PLAIN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void typedTextWithNoStylingIsADraftAndCountsItsCharacters() {
|
||||
PromptBox.Reading reading = PromptBox.classify("❯\u00a0deploy the thing");
|
||||
assertEquals(PromptBox.State.DRAFT, reading.state());
|
||||
assertEquals("deploythething".length(), reading.characters());
|
||||
}
|
||||
|
||||
@Test
|
||||
void typedTextAfterAFaintHintCountsOnlyTheTextOutsideTheFaintSpan() {
|
||||
PromptBox.Reading reading =
|
||||
PromptBox.classify("❯\u00a0\u001b[0m\u001b[2mhint\u001b[0m and typed");
|
||||
assertEquals(PromptBox.State.DRAFT, reading.state());
|
||||
assertEquals("andtyped".length(), reading.characters(),
|
||||
"the faint hint is excluded; only \"and typed\" was drawn plain");
|
||||
}
|
||||
|
||||
// --- the gate ------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void anEmptyBoxClearsTheGateAndReadsTheDetectionRegion() {
|
||||
void anEmptyBoxClearsTheGateAndReadsTheVisibleRegionWithStylingKept() {
|
||||
FakeHerdr herdr = new FakeHerdr().detectionText(EMPTY);
|
||||
|
||||
assertTrue(new PromptBox(new AgentControl(herdr)).clearToSubmit("term_a"));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
var params = (java.util.Map<String, Object>) herdr.lastCall("agent.read").params();
|
||||
assertEquals("detection", params.get("source"),
|
||||
"the input box is drawn in the detection region, not in transcript scrollback");
|
||||
assertEquals("visible", params.get("source"),
|
||||
"the input box is drawn in the visible region, not in transcript scrollback");
|
||||
assertEquals(false, params.get("strip_ansi"),
|
||||
"styling must survive the read, or a faint placeholder hint reads as plain typed text");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user