From 4ef815682b04f18ea11c60189b79981a1507517e Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Tue, 6 Oct 2026 04:22:59 +0200 Subject: [PATCH] fleetd #782: tell a placeholder hint apart from real typed text in the prompt box PromptBox probed the detection region, which carries no ANSI styling, so a dim placeholder hint (the pane's last submitted prompt) read as the operator's draft and held the delivery forever. Move the probe to the visible region, read with strip_ansi:false (AgentControl.readWithStyling), skip leading SGR escapes before matching the box marker, use Character.isSpaceChar so a non-breaking space in an empty box counts as padding, and exclude any character drawn inside a faint (SGR 2) span from the box content. An all-faint box now classifies as EMPTY instead of UNREADABLE. --- .../dev/ltms/fleet/herdr/AgentControl.java | 12 ++ .../java/dev/ltms/fleet/herdr/PromptBox.java | 104 ++++++++++++++---- .../java/dev/ltms/fleet/herdr/FakeHerdr.java | 13 ++- .../dev/ltms/fleet/herdr/PromptBoxTest.java | 56 +++++++++- 4 files changed, 154 insertions(+), 31 deletions(-) diff --git a/fleetd/src/main/java/dev/ltms/fleet/herdr/AgentControl.java b/fleetd/src/main/java/dev/ltms/fleet/herdr/AgentControl.java index f98675af..fdc80b1a 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/herdr/AgentControl.java +++ b/fleetd/src/main/java/dev/ltms/fleet/herdr/AgentControl.java @@ -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")); diff --git a/fleetd/src/main/java/dev/ltms/fleet/herdr/PromptBox.java b/fleetd/src/main/java/dev/ltms/fleet/herdr/PromptBox.java index 707d1419..5f08fa40 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/herdr/PromptBox.java +++ b/fleetd/src/main/java/dev/ltms/fleet/herdr/PromptBox.java @@ -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 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 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. * - *

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. + *

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 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 renderedGlyphs(String text) { + List 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; + } } diff --git a/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java b/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java index 32014346..e73a2e45 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java +++ b/fleetd/src/test/java/dev/ltms/fleet/herdr/FakeHerdr.java @@ -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)))); } diff --git a/fleetd/src/test/java/dev/ltms/fleet/herdr/PromptBoxTest.java b/fleetd/src/test/java/dev/ltms/fleet/herdr/PromptBoxTest.java index 7de301f9..c9524371 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/herdr/PromptBoxTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/herdr/PromptBoxTest.java @@ -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) 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