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.
This commit is contained in:
@@ -137,6 +137,18 @@ public final class AgentControl {
|
|||||||
return result.path("read").path("text").asText("");
|
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). */
|
/** Current agent record (status, session UUID, pane). */
|
||||||
public Agent get(String target) {
|
public Agent get(String target) {
|
||||||
return Agent.from(agentCall("agent.get", target, Map.of()).get("agent"));
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
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.
|
* 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);
|
private static final Logger log = LoggerFactory.getLogger(PromptBox.class);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* herdr {@code agent.read} source. {@code detection} is the region herdr itself uses for status
|
* herdr {@code agent.read} source, read with its ANSI styling kept. The input box is always
|
||||||
* detection, so the input box is always drawn in it. It is not a short tail: it carries transcript
|
* drawn here, carrying transcript scrollback above it, which is why only the last box line is
|
||||||
* scrollback above the box, including earlier prompts the operator has already submitted, which is
|
* the live one. Styling must survive the read because the pane draws a placeholder hint — the
|
||||||
* why only the last box line on it is the live one.
|
* 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. */
|
/** Consecutive holds for one target before one warning is logged. */
|
||||||
static final int HOLD_WARN_STREAK = 20;
|
static final int HOLD_WARN_STREAK = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Input box markers, each matched only as a line's first characters: the caret the current TUI
|
* Input box markers, each matched only as a line's first characters once any leading ANSI
|
||||||
* draws, and the bordered box an older one drew. A marker further along a line is transcript text,
|
* escape codes are skipped: the caret the current TUI draws, and the bordered box an older one
|
||||||
* such as a caret inside something the operator quoted.
|
* 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("❯", "│ >");
|
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. */
|
/** Block glyphs a terminal capture can leave in an otherwise empty box for the cursor cell. */
|
||||||
private static final String CURSOR_GLYPHS = "█▉▊▋▌▍▎▏";
|
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. */
|
/** What a box holds: nothing, unsubmitted characters, or a pane this cannot read as a box. */
|
||||||
public enum State { EMPTY, DRAFT, UNREADABLE }
|
public enum State { EMPTY, DRAFT, UNREADABLE }
|
||||||
|
|
||||||
@@ -94,7 +108,7 @@ public final class PromptBox {
|
|||||||
private Reading inspect(String target) {
|
private Reading inspect(String target) {
|
||||||
String pane;
|
String pane;
|
||||||
try {
|
try {
|
||||||
pane = agents.read(target, PROBE_SOURCE);
|
pane = agents.readWithStyling(target, PROBE_SOURCE);
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
log.debug("prompt box read for {} failed, holding delivery: {}", target, e.getMessage());
|
log.debug("prompt box read for {} failed, holding delivery: {}", target, e.getMessage());
|
||||||
return new Reading(State.UNREADABLE, 0);
|
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}
|
* an earlier turn's marker survives; treating that as a live turn would make {@link State#EMPTY}
|
||||||
* unreachable and hold every delivery forever.
|
* unreachable and hold every delivery forever.
|
||||||
*
|
*
|
||||||
* <p>Whitespace, a trailing box border and a cursor block count as nothing. Any other character
|
* <p>Whitespace, a trailing box border and a cursor block count as nothing. A placeholder hint —
|
||||||
* counts as the operator's unsubmitted text, including a placeholder hint a future TUI might draw
|
* text the pane draws faint, in the same spot as unsubmitted text — also counts as nothing: only
|
||||||
* there; that direction holds a delivery it could have sent, which {@link #HOLD_WARN_STREAK} makes
|
* a character drawn outside a faint span is the operator's own typing.
|
||||||
* visible.
|
|
||||||
*/
|
*/
|
||||||
static Reading classify(String pane) {
|
static Reading classify(String pane) {
|
||||||
if (pane == null || pane.isBlank()) return new Reading(State.UNREADABLE, 0);
|
if (pane == null || pane.isBlank()) return new Reading(State.UNREADABLE, 0);
|
||||||
@@ -142,28 +155,77 @@ public final class PromptBox {
|
|||||||
return found;
|
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) {
|
private static int markerLength(String line) {
|
||||||
|
int skip = leadingEscapeLength(line);
|
||||||
for (String marker : BOX_MARKERS) {
|
for (String marker : BOX_MARKERS) {
|
||||||
if (line.startsWith(marker)) return marker.length();
|
if (line.startsWith(marker, skip)) return skip + marker.length();
|
||||||
}
|
}
|
||||||
return 0;
|
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) {
|
private static String firstLine(String text) {
|
||||||
int newline = text.indexOf('\n');
|
int newline = text.indexOf('\n');
|
||||||
return newline < 0 ? text : text.substring(0, newline);
|
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) {
|
private static String boxContent(String boxLine) {
|
||||||
String line = boxLine.substring(markerLength(boxLine)).stripTrailing();
|
List<Glyph> glyphs = renderedGlyphs(boxLine.substring(markerLength(boxLine)));
|
||||||
if (line.endsWith("│")) line = line.substring(0, line.length() - 1);
|
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();
|
StringBuilder content = new StringBuilder();
|
||||||
for (char c : line.toCharArray()) {
|
for (int i = 0; i < end; i++) {
|
||||||
if (Character.isWhitespace(c) || CURSOR_GLYPHS.indexOf(c) >= 0) continue;
|
Glyph glyph = glyphs.get(i);
|
||||||
content.append(c);
|
if (glyph.faint() || isBoxPadding(glyph.c())) continue;
|
||||||
|
content.append(glyph.c());
|
||||||
}
|
}
|
||||||
return content.toString();
|
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
|
* Override the text {@code agent.read} returns for the {@code detection} and {@code visible}
|
||||||
* prompt/footer tail herdr uses for status detection, a different region from the transcript the
|
* sources only — the prompt/footer tail herdr uses for status detection and input-box probing, a
|
||||||
* other sources carry. Needed by a test whose subject reads the input box, since one
|
* different region from the transcript the other sources carry. Needed by a test whose subject
|
||||||
* {@link #readText} cannot be both a worker's transcript and a lead's empty prompt.
|
* 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) {
|
public FakeHerdr detectionText(String text) {
|
||||||
this.detectionText = text;
|
this.detectionText = text;
|
||||||
@@ -413,8 +414,8 @@ public final class FakeHerdr implements HerdrClient {
|
|||||||
}
|
}
|
||||||
case "agent.read" -> {
|
case "agent.read" -> {
|
||||||
Object source = params instanceof Map<?, ?> m ? m.get("source") : null;
|
Object source = params instanceof Map<?, ?> m ? m.get("source") : null;
|
||||||
String text = "detection".equals(source) && detectionText != null
|
boolean probeSource = "detection".equals(source) || "visible".equals(source);
|
||||||
? detectionText : readText;
|
String text = probeSource && detectionText != null ? detectionText : readText;
|
||||||
yield mapper.readTree(mapper.writeValueAsString(
|
yield mapper.readTree(mapper.writeValueAsString(
|
||||||
java.util.Map.of("type", "agent_read", "read", java.util.Map.of("text", text))));
|
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 EMPTY = FakeHerdr.IDLE_PROMPT_CARET;
|
||||||
private static final String DRAFTED = FakeHerdr.DRAFTED_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 -------------------------------------------------
|
// --- pure classification -------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -57,7 +69,7 @@ class PromptBoxTest {
|
|||||||
void theLastBoxLineOnThePaneIsTheLiveOne() {
|
void theLastBoxLineOnThePaneIsTheLiveOne() {
|
||||||
assertEquals(PromptBox.State.DRAFT,
|
assertEquals(PromptBox.State.DRAFT,
|
||||||
PromptBox.classify("❯ an earlier prompt\n⏺ its answer\n❯ typing now").state(),
|
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,
|
assertEquals(PromptBox.State.EMPTY,
|
||||||
PromptBox.classify("❯ an earlier prompt\n⏺ its answer\n❯").state());
|
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");
|
"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 ------------------------------------------------------------
|
// --- the gate ------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void anEmptyBoxClearsTheGateAndReadsTheDetectionRegion() {
|
void anEmptyBoxClearsTheGateAndReadsTheVisibleRegionWithStylingKept() {
|
||||||
FakeHerdr herdr = new FakeHerdr().detectionText(EMPTY);
|
FakeHerdr herdr = new FakeHerdr().detectionText(EMPTY);
|
||||||
|
|
||||||
assertTrue(new PromptBox(new AgentControl(herdr)).clearToSubmit("term_a"));
|
assertTrue(new PromptBox(new AgentControl(herdr)).clearToSubmit("term_a"));
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
var params = (java.util.Map<String, Object>) herdr.lastCall("agent.read").params();
|
var params = (java.util.Map<String, Object>) herdr.lastCall("agent.read").params();
|
||||||
assertEquals("detection", params.get("source"),
|
assertEquals("visible", params.get("source"),
|
||||||
"the input box is drawn in the detection region, not in transcript scrollback");
|
"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
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user