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..b042066b 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/herdr/PromptBox.java +++ b/fleetd/src/main/java/dev/ltms/fleet/herdr/PromptBox.java @@ -18,11 +18,13 @@ import java.util.concurrent.ConcurrentHashMap; * *
Only a box that is positively empty clears the gate. A box with content, a pane this cannot * recognise, and a failed read all hold the delivery, because a held delivery is recoverable and a - * submitted half-line is not. Every caller must therefore be a path that retries. + * submitted half-line is not. A hold is bounded, though: after {@link #HOLD_GIVE_UP_STREAK} + * consecutive holds on one target, the gate gives up and lets the delivery through anyway, because a + * channel that never delivers again is worse than one clobbered line. * *
A pane that holds for {@link #HOLD_WARN_STREAK} consecutive checks gets one warning, so a box * that never clears is visible instead of silent. The warning repeats only after the box has cleared - * again. + * again, and giving up logs its own warning the same way. */ public final class PromptBox { @@ -39,6 +41,9 @@ public final class PromptBox { /** Consecutive holds for one target before one warning is logged. */ static final int HOLD_WARN_STREAK = 20; + /** Consecutive holds for one target before the gate stops holding and lets the delivery through. */ + static final int HOLD_GIVE_UP_STREAK = 1200; + /** * 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, @@ -70,7 +75,9 @@ public final class PromptBox { /** * Whether {@code target}'s input box is empty, so a delivery would submit only its own text. - * {@code false} means hold and come back; it never means the delivery failed. + * {@code false} means hold and come back; it never means the delivery failed. After {@link + * #HOLD_GIVE_UP_STREAK} consecutive holds on the same target, this returns {@code true} instead, + * so the delivery goes through rather than holding the channel shut forever. */ public boolean clearToSubmit(String target) { Reading reading = inspect(target); @@ -79,6 +86,13 @@ public final class PromptBox { return true; } int streak = holdStreaks.merge(target, 1, Integer::sum); + if (streak >= HOLD_GIVE_UP_STREAK) { + holdStreaks.remove(target); + log.warn("prompt box of {} never cleared after {} holds ({}, {} character(s) in the box)" + + " — giving up and sending this delivery anyway, which may submit whatever is in the box", + target, streak, reading.state(), reading.characters()); + return true; + } if (streak == HOLD_WARN_STREAK) { log.warn("prompt box of {} has held a delivery {} times in a row ({}, {} character(s) in the box)" + " — nothing is lost, delivery resumes once the box is empty", @@ -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 — including non-breaking variants such as U+00A0 — a trailing box border and a + * cursor block all count as nothing. Any other character counts as the operator's unsubmitted + * text, including a placeholder hint a TUI might draw there. */ static Reading classify(String pane) { if (pane == null || pane.isBlank()) return new Reading(State.UNREADABLE, 0); @@ -161,7 +174,7 @@ public final class PromptBox { if (line.endsWith("│")) line = line.substring(0, line.length() - 1); StringBuilder content = new StringBuilder(); for (char c : line.toCharArray()) { - if (Character.isWhitespace(c) || CURSOR_GLYPHS.indexOf(c) >= 0) continue; + if (Character.isWhitespace(c) || Character.isSpaceChar(c) || CURSOR_GLYPHS.indexOf(c) >= 0) continue; content.append(c); } return content.toString(); 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..571f3791 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/herdr/PromptBoxTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/herdr/PromptBoxTest.java @@ -90,6 +90,25 @@ class PromptBoxTest { "that marker survives in scrollback, and holding on it would hold every delivery forever"); } + @Test + void aNoBreakSpacePaddedBoxIsEmpty() { + assertEquals(new PromptBox.Reading(PromptBox.State.EMPTY, 0), + PromptBox.classify("❯ ")); + } + + @Test + void anOrdinarySpacePaddedBoxIsStillEmpty() { + assertEquals(new PromptBox.Reading(PromptBox.State.EMPTY, 0), PromptBox.classify("❯ ")); + } + + @Test + void aNoBreakSpaceLeadInDoesNotCountTowardTheDraftsCharacters() { + PromptBox.Reading reading = PromptBox.classify("❯ hello"); + assertEquals(PromptBox.State.DRAFT, reading.state()); + assertEquals("hello".length(), reading.characters(), + "the no-break space before the typed text does not count, only the text itself"); + } + // --- the gate ------------------------------------------------------------ @Test @@ -129,4 +148,41 @@ class PromptBoxTest { herdr.detectionText(EMPTY); assertTrue(box.clearToSubmit("term_a")); } + + @Test + void aBoxThatNeverClearsIsLetThroughOnceTheGiveUpStreakIsReached() { + FakeHerdr herdr = new FakeHerdr().detectionText(DRAFTED); + PromptBox box = new PromptBox(new AgentControl(herdr)); + + for (int i = 1; i < PromptBox.HOLD_GIVE_UP_STREAK; i++) { + assertFalse(box.clearToSubmit("term_a"), "still holding before the give-up streak is reached"); + } + assertTrue(box.clearToSubmit("term_a"), "the gate gives up and lets the delivery through"); + } + + @Test + void givingUpResetsTheStreakSoTheNextHoldStartsOver() { + FakeHerdr herdr = new FakeHerdr().detectionText(DRAFTED); + PromptBox box = new PromptBox(new AgentControl(herdr)); + + for (int i = 0; i < PromptBox.HOLD_GIVE_UP_STREAK; i++) { + box.clearToSubmit("term_a"); + } + assertFalse(box.clearToSubmit("term_a"), "the streak started over, so one more hold is not another give-up"); + } + + @Test + void theStreakResetsWhenTheBoxEmptiesBeforeTheGiveUpStreak() { + FakeHerdr herdr = new FakeHerdr().detectionText(DRAFTED); + PromptBox box = new PromptBox(new AgentControl(herdr)); + + for (int i = 0; i < PromptBox.HOLD_GIVE_UP_STREAK - 1; i++) { + box.clearToSubmit("term_a"); + } + herdr.detectionText(EMPTY); + assertTrue(box.clearToSubmit("term_a")); + + herdr.detectionText(DRAFTED); + assertFalse(box.clearToSubmit("term_a"), "the streak started over, so one more hold is not a give-up"); + } }