From 633f08e2a566bc4e7d969d2bf7a3edbce8cc166b Mon Sep 17 00:00:00 2001 From: lyfmt Date: Tue, 14 Jul 2026 20:26:33 +0800 Subject: [PATCH 01/58] =?UTF-8?q?chore:=20=E5=90=AF=E5=8A=A8=20TUI=20viewp?= =?UTF-8?q?ort=20=E5=B8=83=E5=B1=80=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 53800e30c8d2dc2d1faad0dec89d105f82d32bd5 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Tue, 14 Jul 2026 20:29:45 +0800 Subject: [PATCH 02/58] =?UTF-8?q?test(tui):=20=E5=9B=BA=E5=AE=9A=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=BE=93=E5=85=A5=E5=8C=BA=E5=9F=9F=E9=AB=98=E5=BA=A6?= =?UTF-8?q?=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cn/lypi/transport/tui/TuiLayoutTest.java | 31 +++++++ .../lypi/transport/tui/TuiRendererTest.java | 80 ++++++++++++++----- 2 files changed, 89 insertions(+), 22 deletions(-) create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java new file mode 100644 index 00000000..a13b1487 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java @@ -0,0 +1,31 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class TuiLayoutTest { + @Test + void allocatesAllRegionsWithinTerminalHeight() { + for (int height : new int[] {2, 3, 6, 24}) { + for (int desiredInputHeight : new int[] {1, 4, 100}) { + for (int desiredOverlayHeight : new int[] {0, 3, 100}) { + TuiLayout layout = new TuiLayout(80, height); + + var regions = layout.allocate(desiredInputHeight, desiredOverlayHeight, true); + + String scenario = "height=" + height + + ", input=" + desiredInputHeight + + ", overlay=" + desiredOverlayHeight; + assertTrue(regions.totalHeight() <= height, scenario); + assertEquals(1, regions.statusHeight(), scenario); + assertTrue(regions.inputHeight() >= 1, scenario); + if (height >= 3) { + assertTrue(regions.transcriptHeight() >= 1, scenario); + } + } + } + } + } +} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java index e9c4f223..51b48dfc 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java @@ -331,7 +331,7 @@ void visibleCursorDoesNotPushInputContentPastLayoutWidth() { } @Test - void longInputSoftWrapsInsideBottomInputBlockWithFullTranscript() { + void longInputIsWindowedWithoutRemovingLatestTranscriptLine() { TuiRenderer renderer = new TuiRenderer(); TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( @@ -349,16 +349,12 @@ void longInputSoftWrapsInsideBottomInputBlockWithFullTranscript() { List lines = renderer.render(view, screen, new TuiLayout(8, 6), "abcdefghij", 10); - assertEquals(9, lines.size()); - assertEquals("line1", lines.get(0)); - assertEquals("line2", lines.get(1)); - assertEquals("line3", lines.get(2)); - assertEquals("line4", lines.get(3)); - assertInputBorder(lines.get(lines.size() - 5), 8); - assertEquals("\033[48;5;236m> abcde\033[0m", lines.get(lines.size() - 4)); - assertEquals("\033[48;5;236mfghij|CURSOR|" + INPUT_CURSOR + "\033[0m", lines.get(lines.size() - 3)); - assertInputBorder(lines.get(lines.size() - 2), 8); - assertTrue(lines.getLast().contains("ses_1")); + assertTrue(lines.size() <= 6); + assertTrue(lines.contains("line4")); + assertFalse(lines.contains("line1")); + assertFalse(lines.contains("line2")); + assertFalse(lines.contains("line3")); + assertTrue(lines.stream().anyMatch(line -> line.contains(TerminalFrameRenderer.CURSOR_MARKER))); } @Test @@ -384,7 +380,7 @@ void explicitNewlineStartsNewInputRowWithoutSplittingDraftSemantics() { } @Test - void inputViewportShowsLatestRowsWhileKeepingFullTranscript() { + void inputViewportShowsCursorRowsWhileKeepingOneTranscriptLine() { TuiRenderer renderer = new TuiRenderer(); TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( @@ -397,17 +393,16 @@ void inputViewportShowsLatestRowsWhileKeepingFullTranscript() { List lines = renderer.render(view, screen, new TuiLayout(10, 6), "one\ntwo\nthree\nfour", 18); - assertEquals(7, lines.size()); + assertTrue(lines.size() <= 6); assertTrue(lines.contains("history")); - assertInputBorder(lines.get(lines.size() - 6), 10); - assertEquals("\033[48;5;236mtwo\033[0m", lines.get(lines.size() - 5)); - assertEquals("\033[48;5;236mthree\033[0m", lines.get(lines.size() - 4)); - assertEquals("\033[48;5;236mfour|CURSOR|" + INPUT_CURSOR + "\033[0m", lines.get(lines.size() - 3)); - assertInputBorder(lines.get(lines.size() - 2), 10); + assertFalse(lines.stream().anyMatch(line -> line.contains("two"))); + assertTrue(lines.stream().anyMatch(line -> line.contains("three"))); + assertTrue(lines.stream().anyMatch(line -> line.contains("four") + && line.contains(TerminalFrameRenderer.CURSOR_MARKER))); } @Test - void inputBlockCanUseFullTerminalHeightAfterFullTranscript() { + void minimumInputAndTranscriptFitInThreeLineTerminal() { TuiRenderer renderer = new TuiRenderer(); TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( @@ -420,10 +415,10 @@ void inputBlockCanUseFullTerminalHeightAfterFullTranscript() { List lines = renderer.render(view, screen, new TuiLayout(10, 3), "one\ntwo\nthree\nfour", 18); - assertEquals(4, lines.size()); + assertTrue(lines.size() <= 3); assertTrue(lines.contains("history")); - assertInputBorder(lines.get(lines.size() - 3), 10); - assertEquals("\033[48;5;236mfour|CURSOR|" + INPUT_CURSOR + "\033[0m", lines.get(lines.size() - 2)); + assertTrue(lines.stream().anyMatch(line -> line.contains("four") + && line.contains(TerminalFrameRenderer.CURSOR_MARKER))); assertTrue(lines.getLast().contains("ses_1")); } @@ -513,6 +508,47 @@ void permissionPromptStaysVisibleWhenWorkingLineIsActiveInShortViewport() { assertTrue(lines.stream().anyMatch(line -> line.contains("working (12s)"))); } + @Test + void tallPermissionPromptKeepsSelectedOptionInputCursorAndStatusVisible() { + TuiRenderer renderer = new TuiRenderer(); + TuiScreen screen = new TuiScreen(2); + List options = java.util.stream.IntStream.rangeClosed(1, 8) + .mapToObj(index -> new PermissionOption( + "option_" + index, + PermissionOptionKind.ALLOW_ONCE, + "Option " + index, + "Description " + index, + Optional.empty(), + Map.of() + )) + .toList(); + PermissionPromptView prompt = new PermissionPromptView( + "perm_toolu_1", + "toolu_1", + "Need approval\nwith a long reason\nthat occupies several rows", + "bash:long-running-command", + "option_6", + "option_8", + options, + "option_6" + ); + TuiViewModel view = new TuiViewModel( + List.of(new TuiMessageBlock("b1", "m1", "assistant", "latest message", false)), + new StatusBarState("ses_1", "gpt-5.4", "running", "default"), + List.of(), + Optional.of(prompt), + Optional.empty() + ); + + List lines = renderer.render(view, screen, new TuiLayout(40, 6), "draft", 5); + + assertTrue(lines.size() <= 6); + assertTrue(lines.stream().anyMatch(line -> line.contains("> Option 6"))); + assertTrue(lines.stream().anyMatch(line -> line.contains(TerminalFrameRenderer.CURSOR_MARKER))); + assertTrue(lines.getLast().contains("ses_1")); + assertFalse(lines.stream().anyMatch(line -> line.contains("Option 1"))); + } + @Test void multilinePermissionPromptIsSplitIntoFrameLines() { TuiRenderer renderer = new TuiRenderer(); From fd21984cf16d56689a3c5377ae53c5005b9530b7 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Tue, 14 Jul 2026 20:32:09 +0800 Subject: [PATCH 03/58] =?UTF-8?q?refactor(tui):=20=E9=9B=86=E4=B8=AD?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E5=B8=A7=E5=8C=BA=E5=9F=9F=E9=AB=98=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/cn/lypi/transport/tui/TuiLayout.java | 31 ++++++++++++++++--- .../lypi/transport/tui/TuiRegionLayout.java | 18 +++++++++++ .../cn/lypi/transport/tui/TuiLayoutTest.java | 9 ++++++ 3 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRegionLayout.java diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java index ef528e86..90fb6558 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java @@ -15,16 +15,15 @@ record TuiLayout(int width, int height) { } int transcriptHeight() { - return transcriptHeight(MIN_INPUT_CONTENT_HEIGHT + INPUT_BORDER_HEIGHT); + return allocate(MIN_INPUT_CONTENT_HEIGHT + INPUT_BORDER_HEIGHT, 0, true).transcriptHeight(); } int transcriptHeight(int inputBlockHeight) { - int boundedInputBlockHeight = Math.min(maxInputBlockHeight(), Math.max(1, inputBlockHeight)); - return Math.max(0, height - STATUS_BAR_HEIGHT - boundedInputBlockHeight); + return allocate(inputBlockHeight, 0, true).transcriptHeight(); } int maxInputBlockHeight() { - return Math.max(1, height - STATUS_BAR_HEIGHT); + return allocate(Integer.MAX_VALUE, 0, false).inputHeight(); } int maxInputContentHeight() { @@ -34,4 +33,28 @@ int maxInputContentHeight() { } return Math.max(MIN_INPUT_CONTENT_HEIGHT, maxInputBlockHeight - INPUT_BORDER_HEIGHT); } + + TuiRegionLayout allocate(int desiredInputHeight, int desiredOverlayHeight, boolean hasTranscript) { + int inputHeight = 1; + int transcriptHeight = 0; + int overlayHeight = 0; + int remainingHeight = height - STATUS_BAR_HEIGHT - inputHeight; + + if (hasTranscript && remainingHeight > 0) { + transcriptHeight = 1; + remainingHeight--; + } + + int boundedOverlayHeight = Math.max(0, desiredOverlayHeight); + overlayHeight = Math.min(boundedOverlayHeight, remainingHeight); + remainingHeight -= overlayHeight; + + int boundedInputHeight = Math.max(1, desiredInputHeight); + int additionalInputHeight = Math.min(boundedInputHeight - inputHeight, remainingHeight); + inputHeight += additionalInputHeight; + remainingHeight -= additionalInputHeight; + + transcriptHeight += remainingHeight; + return new TuiRegionLayout(transcriptHeight, inputHeight, overlayHeight, STATUS_BAR_HEIGHT); + } } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRegionLayout.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRegionLayout.java new file mode 100644 index 00000000..2a62b415 --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRegionLayout.java @@ -0,0 +1,18 @@ +package cn.lypi.transport.tui; + +record TuiRegionLayout( + int transcriptHeight, + int inputHeight, + int overlayHeight, + int statusHeight +) { + TuiRegionLayout { + if (transcriptHeight < 0 || inputHeight < 0 || overlayHeight < 0 || statusHeight < 0) { + throw new IllegalArgumentException("region heights must be non-negative"); + } + } + + int totalHeight() { + return transcriptHeight + inputHeight + overlayHeight + statusHeight; + } +} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java index a13b1487..7473702a 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java @@ -1,6 +1,7 @@ package cn.lypi.transport.tui; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -28,4 +29,12 @@ void allocatesAllRegionsWithinTerminalHeight() { } } } + + @Test + void regionLayoutRejectsNegativeHeights() { + assertThrows(IllegalArgumentException.class, () -> new TuiRegionLayout(-1, 1, 0, 1)); + assertThrows(IllegalArgumentException.class, () -> new TuiRegionLayout(1, -1, 0, 1)); + assertThrows(IllegalArgumentException.class, () -> new TuiRegionLayout(1, 1, -1, 1)); + assertThrows(IllegalArgumentException.class, () -> new TuiRegionLayout(1, 1, 0, -1)); + } } From e659a9dbc127ddc024675dbd12eccd625871a920 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Tue, 14 Jul 2026 20:46:05 +0800 Subject: [PATCH 04/58] =?UTF-8?q?fix(tui):=20=E7=94=A8=E6=B6=88=E6=81=AF?= =?UTF-8?q?=20viewport=20=E7=BA=A6=E6=9D=9F=E6=9C=80=E7=BB=88=E5=B8=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cn/lypi/transport/tui/TuiRenderer.java | 177 ++++++++++++++---- .../java/cn/lypi/transport/tui/TuiScreen.java | 9 +- .../lypi/transport/tui/TuiRendererTest.java | 8 +- .../cn/lypi/transport/tui/TuiScreenTest.java | 6 +- 4 files changed, 157 insertions(+), 43 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java index 8aa81ded..66948993 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java @@ -70,21 +70,37 @@ TuiRenderFrame renderFrame( List overlayLines, boolean toolOutputExpanded ) { - InputBlock inputBlock = compactRunning(view) - ? readonlyRuntimeInputBlock("compact 正在进行...", layout) - : layoutInput(input, cursor, layout); - List permissionOverlay = permissionOverlayLines(view, layout.width()); - List externalOverlay = overlayLines == null ? List.of() : overlayLines.stream() - .flatMap(line -> wrap(line, layout.width()).stream()) - .toList(); - List overlay = new ArrayList<>(permissionOverlay.size() + externalOverlay.size()); - overlay.addAll(permissionOverlay); - overlay.addAll(externalOverlay); - int chromeLineCount = inputBlock.lines().size() + overlay.size() + 1; - int transcriptLineBudget = Math.max(0, layout.height() - chromeLineCount); - int effectiveTranscriptBudget = toolOutputExpanded ? transcriptLineBudget : Integer.MAX_VALUE; - List transcript = transcriptLines(view, layout.width(), toolOutputExpanded, effectiveTranscriptBudget); - screen.setTranscript(transcript); + List fullTranscript = transcriptLines(view, layout.width(), toolOutputExpanded, Integer.MAX_VALUE); + InputCandidate inputCandidate = compactRunning(view) + ? readonlyRuntimeInputCandidate("compact 正在进行...", layout.width()) + : measureInput(input, cursor, layout.width()); + OverlayBlock fullOverlay = combineOverlays( + permissionOverlay(view, layout.width()), + externalOverlay(overlayLines, layout.width()) + ); + TuiRegionLayout regions = layout.allocate( + inputCandidate.desiredHeight(), + fullOverlay.lines().size(), + !fullTranscript.isEmpty() + ); + if (toolOutputExpanded) { + fullTranscript = transcriptLines( + view, + layout.width(), + true, + regions.transcriptHeight() + ); + } + InputBlock inputBlock = inputCandidate.render(regions.inputHeight()); + List overlay = windowOverlay( + fullOverlay.lines(), + regions.overlayHeight(), + fullOverlay.selectedRow() + ); + screen.updateViewportHeight(regions.transcriptHeight()); + screen.setTranscript(fullTranscript); + List transcript = screen.visibleTranscript(); + int chromeLineCount = inputBlock.lines().size() + overlay.size() + regions.statusHeight(); List lines = new ArrayList<>(); lines.addAll(transcript); @@ -170,11 +186,12 @@ private List transcriptLines(TuiViewModel view, int width, boolean toolO return lines; } - private List permissionOverlayLines(TuiViewModel view, int width) { + private OverlayBlock permissionOverlay(TuiViewModel view, int width) { if (view.permissionPrompt().isEmpty()) { - return List.of(); + return OverlayBlock.empty(); } List lines = new ArrayList<>(); + int selectedRow = -1; PermissionPromptView prompt = view.permissionPrompt().orElseThrow(); appendPrefixedMultiline(lines, "permission " + prompt.toolUseId() + ": ", prompt.reason(), width, Integer.MAX_VALUE); if (!prompt.rule().isBlank()) { @@ -182,9 +199,55 @@ private List permissionOverlayLines(TuiViewModel view, int width) { } for (PermissionOption option : prompt.options()) { String prefix = option.optionId().equals(prompt.selectedOptionId()) ? "> " : " "; + if (option.optionId().equals(prompt.selectedOptionId())) { + selectedRow = lines.size(); + } appendWithinBudget(lines, wrap(prefix + optionLabel(option), width), Integer.MAX_VALUE); } - return lines; + return new OverlayBlock(lines, selectedRow); + } + + private OverlayBlock externalOverlay(List overlayLines, int width) { + if (overlayLines == null || overlayLines.isEmpty()) { + return OverlayBlock.empty(); + } + List lines = new ArrayList<>(); + int selectedRow = -1; + for (String line : overlayLines) { + int row = lines.size(); + if (selectedRow < 0 && nullToEmpty(line).startsWith("> ")) { + selectedRow = row; + } + lines.addAll(wrap(line, width)); + } + return new OverlayBlock(lines, selectedRow); + } + + private OverlayBlock combineOverlays(OverlayBlock first, OverlayBlock second) { + List lines = new ArrayList<>(first.lines().size() + second.lines().size()); + lines.addAll(first.lines()); + lines.addAll(second.lines()); + int selectedRow = first.selectedRow() >= 0 + ? first.selectedRow() + : shiftedRow(second.selectedRow(), first.lines().size()); + return new OverlayBlock(lines, selectedRow); + } + + private int shiftedRow(int row, int offset) { + return row < 0 ? -1 : row + offset; + } + + private List windowOverlay(List lines, int height, int selectedRow) { + if (height <= 0 || lines.isEmpty()) { + return List.of(); + } + if (lines.size() <= height) { + return List.copyOf(lines); + } + int boundedSelectedRow = Math.max(0, Math.min(selectedRow, lines.size() - 1)); + int start = selectedRow < 0 ? 0 : Math.max(0, boundedSelectedRow - height + 1); + start = Math.min(start, lines.size() - height); + return List.copyOf(lines.subList(start, start + height)); } private void appendPrefixedMultiline( @@ -303,6 +366,19 @@ private List wrapLogicalLine(String text, int width) { } private String statusLine(StatusBarState status, TuiScreen screen, int width) { + if (screen.linesBelow() <= 0) { + return ordinaryStatusLine(status, width); + } + String unread = "↑ " + screen.linesBelow() + " lines"; + int unreadWidth = AnsiWidth.displayWidth(unread); + if (unreadWidth >= width) { + return AnsiWidth.truncate(unread, width); + } + String ordinary = ordinaryStatusLine(status, width - unreadWidth - 1); + return ordinary.isBlank() ? unread : ordinary + " " + unread; + } + + private String ordinaryStatusLine(StatusBarState status, int width) { String permissionMode = singleLine(status.permissionMode()); String full = String.join( " ", @@ -331,16 +407,23 @@ private String singleLine(String value) { .trim(); } - private InputBlock layoutInput(String input, int cursor, TuiLayout layout) { + private InputCandidate measureInput(String input, int cursor, int width) { String value = input == null ? "" : input; - int width = layout.width(); int boundedCursor = Math.max(0, Math.min(cursor, value.length())); boolean showCursor = cursor >= 0; List visualLines = visualInputLines(value, boundedCursor, showCursor, width); - int maxBlockRows = layout.maxInputBlockHeight(); - int maxContentRows = Math.min(maxVisibleInputContentRows(layout), visualLines.size()); + return new InputCandidate(visualLines, width, null); + } + + private InputCandidate readonlyRuntimeInputCandidate(String text, int width) { + String content = AnsiWidth.truncate(text == null ? "" : text, width); + return new InputCandidate(List.of(), width, INPUT_BACKGROUND + content + ANSI_RESET); + } + + private InputBlock renderInput(List visualLines, int width, int maxBlockRows) { + int maxContentRows = Math.min(maxVisibleInputContentRows(maxBlockRows), visualLines.size()); int start = Math.max(0, visualLines.size() - maxContentRows); - if (showCursor) { + if (visualLines.stream().anyMatch(InputVisualLine::hasCursor)) { int cursorLine = cursorLine(visualLines); if (cursorLine < start) { start = cursorLine; @@ -373,12 +456,6 @@ private boolean compactRunning(TuiViewModel view) { return view != null && view.runtimeLine() != null && view.runtimeLine().startsWith("compacting"); } - private InputBlock readonlyRuntimeInputBlock(String text, TuiLayout layout) { - int width = layout.width(); - String content = AnsiWidth.truncate(text == null ? "" : text, width); - return new InputBlock(List.of(INPUT_BACKGROUND + content + ANSI_RESET)); - } - private List visualInputLines(String value, int cursor, boolean showCursor, int width) { List lines = new ArrayList<>(); StringBuilder current = new StringBuilder(); @@ -442,12 +519,11 @@ private List visualInputLines(String value, int cursor, boolean return lines; } - private int maxVisibleInputContentRows(TuiLayout layout) { - int maxContentRows = layout.maxInputContentHeight(); - if (layout.maxInputBlockHeight() <= 2) { - return Math.max(1, layout.maxInputBlockHeight() - 1); + private int maxVisibleInputContentRows(int inputHeight) { + if (inputHeight <= 2) { + return 1; } - return maxContentRows; + return inputHeight - 2; } private int cursorLine(List lines) { @@ -511,6 +587,39 @@ int height() { } } + private record OverlayBlock(List lines, int selectedRow) { + private OverlayBlock { + lines = List.copyOf(lines); + } + + private static OverlayBlock empty() { + return new OverlayBlock(List.of(), -1); + } + } + + private final class InputCandidate { + private final List visualLines; + private final int width; + private final String readonlyLine; + + private InputCandidate(List visualLines, int width, String readonlyLine) { + this.visualLines = List.copyOf(visualLines); + this.width = width; + this.readonlyLine = readonlyLine; + } + + private int desiredHeight() { + return readonlyLine == null ? visualLines.size() + 2 : 1; + } + + private InputBlock render(int height) { + if (readonlyLine != null) { + return new InputBlock(List.of(readonlyLine)); + } + return renderInput(visualLines, width, height); + } + } + private record InputVisualLine(String content, boolean hasCursor, int cursorColumn) { } } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java index 98598a16..5d5f1618 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java @@ -22,12 +22,15 @@ void updateViewportHeight(int viewportHeight) { } void setTranscript(List transcript) { - int previousMaxLinesBelow = maxLinesBelow(); + int previousTranscriptSize = this.transcript.size(); + boolean followingTail = linesBelow == 0; this.transcript = List.copyOf(transcript); - linesBelow = Math.min(linesBelow, maxLinesBelow()); - if (linesBelow == 0 && previousMaxLinesBelow == 0) { + if (followingTail) { linesBelow = 0; + return; } + int appendedLines = Math.max(0, this.transcript.size() - previousTranscriptSize); + linesBelow = Math.min(maxLinesBelow(), linesBelow + appendedLines); } void scrollUp(int lines) { diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java index 51b48dfc..b1ab0978 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java @@ -101,10 +101,11 @@ void statusBarWithApprovalProjectionTruncatesOnNarrowWidth() { } @Test - void statusBarDoesNotRenderApplicationScrollbackCounter() { + void statusBarKeepsUnreadLineCountWhenOrdinaryFieldsAreTruncated() { TuiRenderer renderer = new TuiRenderer(); TuiScreen screen = new TuiScreen(1); screen.setTranscript(List.of("old", "current")); + screen.scrollUp(1); TuiViewModel view = new TuiViewModel( List.of( new TuiMessageBlock("b1", "m1", "assistant", "old", false), @@ -116,9 +117,10 @@ void statusBarDoesNotRenderApplicationScrollbackCounter() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(80, 3), ""); + List lines = renderer.render(view, screen, new TuiLayout(12, 3), ""); - assertFalse(lines.getLast().contains("scroll +")); + assertTrue(lines.getLast().endsWith("↑ 1 lines")); + assertTrue(AnsiWidth.displayWidth(lines.getLast()) <= 12); } @Test diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java index 81a147c5..93de4142 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java @@ -28,10 +28,10 @@ void scrollUpShowsOlderTranscriptAndNewTranscriptPreservesOffset() { screen.setTranscript(List.of("a", "b", "c", "d", "e")); - assertEquals(List.of("c", "d"), screen.visibleTranscript()); - assertEquals(1, screen.linesBelow()); + assertEquals(List.of("b", "c"), screen.visibleTranscript()); + assertEquals(2, screen.linesBelow()); - screen.scrollDown(1); + screen.scrollDown(2); assertEquals(List.of("d", "e"), screen.visibleTranscript()); assertEquals(0, screen.linesBelow()); From a0666a4f6204d679c3208c76f574463ee61cd5a2 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Tue, 14 Jul 2026 20:49:56 +0800 Subject: [PATCH 05/58] =?UTF-8?q?feat(tui):=20=E6=94=AF=E6=8C=81=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=A7=86=E5=8F=A3=E6=8C=89=E9=A1=B5=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/tui/KeyBindingRegistry.java | 2 + .../java/cn/lypi/transport/tui/KeyMapper.java | 2 + .../transport/tui/TerminalInputAction.java | 2 + .../cn/lypi/transport/tui/TerminalKey.java | 2 + .../cn/lypi/transport/tui/TuiInputLoop.java | 14 ++++++ .../java/cn/lypi/transport/tui/TuiScreen.java | 12 +++++ .../transport/tui/KeyBindingRegistryTest.java | 2 + .../cn/lypi/transport/tui/KeyMapperTest.java | 2 + .../lypi/transport/tui/TuiInputLoopTest.java | 49 +++++++++++++++++++ .../cn/lypi/transport/tui/TuiScreenTest.java | 16 ++++++ 10 files changed, 103 insertions(+) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyBindingRegistry.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyBindingRegistry.java index 5874b79a..4a348e15 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyBindingRegistry.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyBindingRegistry.java @@ -26,6 +26,8 @@ static KeyBindingRegistry defaults() { bindings.put(TerminalKey.WORD_RIGHT, TerminalInputAction.MOVE_WORD_RIGHT); bindings.put(TerminalKey.UP, TerminalInputAction.PREVIOUS_HISTORY); bindings.put(TerminalKey.DOWN, TerminalInputAction.NEXT_HISTORY); + bindings.put(TerminalKey.PAGE_UP, TerminalInputAction.SCROLL_TRANSCRIPT_UP); + bindings.put(TerminalKey.PAGE_DOWN, TerminalInputAction.SCROLL_TRANSCRIPT_DOWN); bindings.put(TerminalKey.CTRL_O, TerminalInputAction.TOGGLE_TOOL_OUTPUT_EXPANDED); bindings.put(TerminalKey.EXPAND_TOOLS, TerminalInputAction.EXPAND_TOOLS); return new KeyBindingRegistry(bindings); diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyMapper.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyMapper.java index d2fc830b..d7792c5f 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyMapper.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyMapper.java @@ -24,6 +24,8 @@ Optional map(String sequence) { case "\033[1;5C" -> Optional.of(TerminalKey.WORD_RIGHT); case "\033[A", "\033OA" -> Optional.of(TerminalKey.UP); case "\033[B", "\033OB" -> Optional.of(TerminalKey.DOWN); + case "\033[5~" -> Optional.of(TerminalKey.PAGE_UP); + case "\033[6~" -> Optional.of(TerminalKey.PAGE_DOWN); case "\033[?u", "\033[65;129u", "\033[27;7;65u" -> Optional.empty(); default -> filterKittyReleaseOrRepeat(sequence); }; diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalInputAction.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalInputAction.java index db6c8a08..316f7423 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalInputAction.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalInputAction.java @@ -19,6 +19,8 @@ public enum TerminalInputAction { MOVE_WORD_RIGHT, PREVIOUS_HISTORY, NEXT_HISTORY, + SCROLL_TRANSCRIPT_UP, + SCROLL_TRANSCRIPT_DOWN, TOGGLE_THINKING, TOGGLE_TOOL_OUTPUT_EXPANDED, EXPAND_TOOLS, diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalKey.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalKey.java index 05a986d6..4eb04d07 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalKey.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalKey.java @@ -21,6 +21,8 @@ public enum TerminalKey { WORD_RIGHT, UP, DOWN, + PAGE_UP, + PAGE_DOWN, EXPAND_TOOLS, OTHER } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java index 7d94bdfa..276335e9 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java @@ -291,6 +291,16 @@ void acceptKey(TerminalKey key) { case MOVE_WORD_RIGHT -> editor.moveWordRight(); case PREVIOUS_HISTORY -> editor.previousHistory(); case NEXT_HISTORY -> editor.nextHistory(); + case SCROLL_TRANSCRIPT_UP -> { + if (transcriptScrollEnabled(prompt)) { + screen.scrollPageUp(); + } + } + case SCROLL_TRANSCRIPT_DOWN -> { + if (transcriptScrollEnabled(prompt)) { + screen.scrollPageDown(); + } + } case TOGGLE_TOOL_OUTPUT_EXPANDED, EXPAND_TOOLS -> toolOutputExpanded = !toolOutputExpanded; default -> { } @@ -377,6 +387,10 @@ private TerminalInputContext inputContext(Optional prompt) ); } + private boolean transcriptScrollEnabled(Optional prompt) { + return prompt.isEmpty() && !resumeOverlayOpen() && !slashOverlayOpen() && !skillOverlayOpen(); + } + private void submitPermissionOption(PermissionPromptView prompt, String optionId) { submitHandler.submitPermissionOption(prompt.requestId(), prompt.toolUseId(), optionId); render(); diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java index 5d5f1618..b1d3a16d 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java @@ -41,6 +41,14 @@ void scrollDown(int lines) { linesBelow = Math.max(0, linesBelow - Math.max(0, lines)); } + void scrollPageUp() { + scrollUp(pageSize()); + } + + void scrollPageDown() { + scrollDown(pageSize()); + } + List visibleTranscript() { if (transcript.isEmpty() || viewportHeight == 0) { return List.of(); @@ -57,4 +65,8 @@ int linesBelow() { private int maxLinesBelow() { return Math.max(0, transcript.size() - viewportHeight); } + + private int pageSize() { + return Math.max(1, viewportHeight - 1); + } } diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java index b80d2303..13de97e4 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java @@ -14,6 +14,8 @@ void defaultBindingsCoverPiStyleEditingAndThinkingShortcuts() { assertEquals(TerminalInputAction.DELETE_NEXT_WORD, registry.actionFor(TerminalKey.ALT_DELETE)); assertEquals(TerminalInputAction.TOGGLE_TOOL_OUTPUT_EXPANDED, registry.actionFor(TerminalKey.CTRL_O)); assertEquals(TerminalInputAction.INSERT_NEWLINE, registry.actionFor(TerminalKey.MODIFIED_ENTER)); + assertEquals(TerminalInputAction.SCROLL_TRANSCRIPT_UP, registry.actionFor(TerminalKey.PAGE_UP)); + assertEquals(TerminalInputAction.SCROLL_TRANSCRIPT_DOWN, registry.actionFor(TerminalKey.PAGE_DOWN)); } @Test diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyMapperTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyMapperTest.java index ffd0f5e1..0adb3620 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyMapperTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyMapperTest.java @@ -29,6 +29,8 @@ void mapsModifiedEnterAndWordMovementSequences() { assertEquals(TerminalKey.RIGHT, mapper.map("\033[C").orElseThrow()); assertEquals(TerminalKey.UP, mapper.map("\033[A").orElseThrow()); assertEquals(TerminalKey.DOWN, mapper.map("\033OB").orElseThrow()); + assertEquals(TerminalKey.PAGE_UP, mapper.map("\033[5~").orElseThrow()); + assertEquals(TerminalKey.PAGE_DOWN, mapper.map("\033[6~").orElseThrow()); } @Test diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java index 9c0a281d..2ef052b0 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java @@ -14,6 +14,8 @@ import cn.lypi.contracts.tui.SessionRuntimeState; import cn.lypi.contracts.tui.SessionTreeNodeView; import cn.lypi.contracts.tui.StatusBarState; +import cn.lypi.contracts.tui.TuiBlock; +import cn.lypi.contracts.tui.TuiMessageBlock; import cn.lypi.contracts.tui.TuiToolBlock; import cn.lypi.contracts.tui.TuiToolState; import cn.lypi.contracts.tui.TuiViewModel; @@ -131,6 +133,53 @@ void rendersCursorAtCurrentEditorPosition() { assertEquals(inputContent("> alpha be|CURSOR|" + INPUT_CURSOR + "ta"), inputLine(frames.getLast())); } + @Test + void pageKeysScrollTranscriptWithoutChangingDraftOrCursor() { + RecordingSubmitHandler submit = new RecordingSubmitHandler(); + List> frames = new ArrayList<>(); + List blocks = java.util.stream.IntStream.rangeClosed(1, 10) + .mapToObj(index -> (TuiBlock) new TuiMessageBlock( + "b" + index, + "m" + index, + "assistant", + "line" + index, + false + )) + .toList(); + TuiViewModel view = new TuiViewModel( + blocks, + new StatusBarState("ses_1", "gpt-5.4", "ready", "default"), + List.of(), + Optional.empty(), + Optional.empty() + ); + TuiInputLoop loop = new TuiInputLoop( + submit, + lines -> frames.add(List.copyOf(lines)), + new TuiRenderer(), + new TuiScreen(2), + new TuiLayout(40, 6), + () -> view + ); + loop.acceptText("draft"); + int cursor = loop.cursor(); + + loop.acceptKey(TerminalKey.PAGE_UP); + + assertEquals("draft", loop.draft()); + assertEquals(cursor, loop.cursor()); + assertTrue(frames.getLast().contains("line8")); + assertTrue(frames.getLast().contains("line9")); + assertFalse(frames.getLast().contains("line10")); + + loop.acceptKey(TerminalKey.PAGE_DOWN); + + assertEquals("draft", loop.draft()); + assertEquals(cursor, loop.cursor()); + assertTrue(frames.getLast().contains("line9")); + assertTrue(frames.getLast().contains("line10")); + } + @Test void backspaceDeletesPreviousCharacterAndRerendersInput() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java index 93de4142..32e9f548 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java @@ -36,4 +36,20 @@ void scrollUpShowsOlderTranscriptAndNewTranscriptPreservesOffset() { assertEquals(List.of("d", "e"), screen.visibleTranscript()); assertEquals(0, screen.linesBelow()); } + + @Test + void pageScrollUsesViewportHeightMinusOne() { + TuiScreen screen = new TuiScreen(4); + screen.setTranscript(List.of("a", "b", "c", "d", "e", "f", "g", "h")); + + screen.scrollPageUp(); + + assertEquals(List.of("b", "c", "d", "e"), screen.visibleTranscript()); + assertEquals(3, screen.linesBelow()); + + screen.scrollPageDown(); + + assertEquals(List.of("e", "f", "g", "h"), screen.visibleTranscript()); + assertEquals(0, screen.linesBelow()); + } } From 8708d356db067410c4b50823e781f8d3a425abe5 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Tue, 14 Jul 2026 20:56:06 +0800 Subject: [PATCH 06/58] =?UTF-8?q?fix(tui):=20resize=20=E6=97=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E6=B6=88=E6=81=AF=E6=BB=9A=E5=8A=A8=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lypi/transport/tui/JLineTuiTransport.java | 2 +- .../JLineTuiTransportRenderPipelineTest.java | 64 +++++++++++++++++-- .../transport/tui/JLineTuiTransportTest.java | 5 +- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java index ccf752d9..9c9cae13 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java @@ -910,8 +910,8 @@ private void resize(int width, int height) { } int safeWidth = safeWidth(width); int safeHeight = safeHeight(height); - screen = new TuiScreen(Math.max(1, safeHeight - 2)); layout = new TuiLayout(safeWidth, safeHeight); + screen.updateViewportHeight(Math.max(0, safeHeight - 2)); if (inputLoop != null) { inputLoop.updateViewport(screen, layout); } diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java index 3d331ce5..25113aab 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java @@ -62,7 +62,7 @@ void eventCallbackReducesAndRendersViewModelUnderUiLock() { } @Test - void rendererFrameKeepsInputBlockAfterFullTranscriptForTerminalScrollback() { + void eventRenderingUsesTranscriptViewportInsteadOfReturningAnOversizedFrame() { RecordingEventBus events = new RecordingEventBus(); List> frames = new ArrayList<>(); JLineTuiTransport transport = JLineTuiTransport.withRenderer(frames::add, 40, 7); @@ -84,9 +84,10 @@ void rendererFrameKeepsInputBlockAfterFullTranscriptForTerminalScrollback() { } List latest = frames.getLast(); - assertEquals(10, latest.size()); - assertTrue(latest.contains("line 0")); - assertTrue(latest.contains("line 2")); + assertTrue(latest.size() <= 7); + assertFalse(latest.contains("line 0")); + assertFalse(latest.contains("line 2")); + assertTrue(latest.contains("line 3")); assertTrue(latest.contains("line 5")); assertEquals(inputContent("> "), inputLine(latest)); assertTrue(latest.getLast().contains("ses_1")); @@ -229,6 +230,53 @@ void resizeRerendersCurrentViewWithUpdatedDimensionsUnderUiLock() { assertEquals(2, transport.uiLockEntryCountForTest()); } + @Test + void resizePreservesScrolledTranscriptUntilPageDownReturnsToTail() throws Exception { + RecordingEventBus events = new RecordingEventBus(); + List> frames = new ArrayList<>(); + QueueInputSource input = new QueueInputSource(); + JLineTuiTransport transport = JLineTuiTransport.withInput( + frames::add, + 40, + 6, + input, + new RecordingSubmitHandler() + ); + + transport.attach(events, TestRuntimeStates.basic("ses_1")); + for (int index = 1; index <= 10; index++) { + events.emit(new MessageDeltaEvent( + "ses_1", + "msg_" + index, + MessageRole.ASSISTANT, + MessageKind.TEXT, + "block_" + index, + ContentBlockKind.TEXT, + "line " + index, + true, + java.util.Map.of(), + Instant.parse("2026-06-09T00:00:00Z") + )); + } + + input.add("\033[5~"); + transport.drainInputForTest(); + long rowsBeforeResize = transcriptRows(frames.getLast()); + assertTrue(frames.getLast().contains("line 9")); + assertFalse(frames.getLast().contains("line 10")); + + transport.resizeForTest(40, 8); + + assertTrue(transcriptRows(frames.getLast()) > rowsBeforeResize); + assertTrue(frames.getLast().contains("line 9")); + assertFalse(frames.getLast().contains("line 10")); + + input.add("\033[6~"); + transport.drainInputForTest(); + + assertTrue(frames.getLast().contains("line 10")); + } + @Test void inputRerenderPreservesCurrentTranscriptView() throws Exception { RecordingEventBus events = new RecordingEventBus(); @@ -680,6 +728,10 @@ private QueueInputSource(String... chunks) { this.chunks = new ArrayDeque<>(List.of(chunks)); } + private void add(String chunk) { + chunks.addLast(chunk); + } + @Override public Optional read() { return Optional.ofNullable(chunks.pollFirst()); @@ -719,6 +771,10 @@ private static String inputContent(String content) { return INPUT_BACKGROUND + content + ANSI_RESET; } + private static long transcriptRows(List frame) { + return frame.stream().filter(line -> line.startsWith("line ")).count(); + } + private static final class RecordingSubmitHandler implements TuiSubmitHandler { private final List interruptReasons = new ArrayList<>(); private int interrupts; diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java index ed8d2536..40a3926a 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java @@ -184,7 +184,7 @@ void openPipelineKeepsLongTranscriptInOutputStreamWithoutAlternateScreenOrRepeat } @Test - void openPipelineDoesNotAppendBottomChromeToScrollbackDuringOverflow() throws Exception { + void openPipelineKeepsBottomChromeInViewportWithoutScrollbackOverflow() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); io.height = 4; RecordingEventBus events = new RecordingEventBus(); @@ -216,10 +216,9 @@ void openPipelineDoesNotAppendBottomChromeToScrollbackDuringOverflow() throws Ex } String output = io.output.toString(); - assertTrue(output.contains("\r\n")); + assertFalse(output.contains("\r\n")); assertFalse(output.matches("(?s).*\\033\\[1;\\d+r.*")); assertTrue(output.contains("\033[2K\033[48;5;236m> ")); - assertTrue(output.contains("\033[2Kses_1 gpt-5.4")); transport.close(); } From 19dfbd2248d18392dfba8cbfe60e60575441268b Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 14:49:30 +0800 Subject: [PATCH 07/58] =?UTF-8?q?test(tui):=20=E5=9B=BA=E5=AE=9A=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=BF=9B=E5=BA=A6=E6=9C=89=E7=95=8C=E5=B0=BE=E9=83=A8?= =?UTF-8?q?=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/tui/TuiEventReducerTest.java | 48 ++++++++++ .../tui/TuiToolProgressBufferTest.java | 92 +++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiToolProgressBufferTest.java diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java index 8aea0d55..53ae7c37 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java @@ -281,6 +281,54 @@ void toolProgressAndEndPopulateRenderableDetails() { assertTrue(tool.details().contains("BUILD SUCCESS")); } + @Test + void toolProgressRetainsBoundedTailAndFinalSummary() { + TuiEventReducer reducer = new TuiEventReducer(); + reducer.reduce(new ToolStartEvent( + "ses_1", + "toolu_1", + "msg_1", + "turn_1", + "bash", + "Bash", + "large output", + Map.of(), + NOW, + NOW + )); + for (int index = 0; index < 300; index++) { + String line = "line-%03d %s\n".formatted(index, "x".repeat(1014)); + reducer.reduce(new ToolProgressEvent( + "ses_1", + "toolu_1", + ToolProgress.output("stdout", line), + NOW.plusMillis(index) + )); + } + reducer.reduce(new ToolEndEvent( + "ses_1", + "toolu_1", + ToolExecutionStatus.SUCCEEDED, + 0, + new ToolResultSummary("bash succeeded", "all output captured", false, 0, false, 307_200L, Map.of()), + null, + NOW, + NOW.plusSeconds(1), + 1_000L, + Map.of(), + NOW.plusSeconds(1) + )); + + TuiToolBlock tool = assertInstanceOf(TuiToolBlock.class, reducer.view().blocks().getFirst()); + assertTrue(tool.details().length() <= 17 * 1024); + assertTrue(tool.details().contains("line-299")); + assertFalse(tool.details().contains("line-000")); + assertTrue(tool.details().contains("earlier output omitted")); + assertTrue(tool.details().contains("exit 0")); + assertTrue(tool.details().contains("all output captured")); + assertFalse(tool.active()); + } + @Test void toolLifecyclePreservesInputSummaryMetadataPreviewAndResultSummary() { TuiEventReducer reducer = new TuiEventReducer(); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiToolProgressBufferTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiToolProgressBufferTest.java new file mode 100644 index 00000000..1f8d011c --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiToolProgressBufferTest.java @@ -0,0 +1,92 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import cn.lypi.contracts.common.ToolProgress; +import cn.lypi.contracts.event.ToolEndEvent; +import cn.lypi.contracts.tool.ToolExecutionStatus; +import cn.lypi.contracts.tool.ToolResultSummary; +import java.time.Instant; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class TuiToolProgressBufferTest { + private static final Instant NOW = Instant.parse("2026-07-10T09:00:00Z"); + + @Test + void retainsOnlyTheBoundedOutputTail() { + TuiToolProgressBuffer buffer = new TuiToolProgressBuffer(); + + for (int index = 0; index < 300; index++) { + String line = "line-%03d %s\n".formatted(index, "x".repeat(1014)); + buffer.append(ToolProgress.output("stdout", line)); + } + + assertTrue(buffer.retainedCharacters() <= 16 * 1024); + assertTrue(buffer.retainedLineCount() <= 200); + assertTrue(buffer.render().contains("line-299")); + assertFalse(buffer.render().contains("line-000")); + assertTrue(buffer.render().contains("earlier output omitted")); + } + + @Test + void preservesPhysicalLinesAcrossCrLfAndPartialChunks() { + TuiToolProgressBuffer buffer = new TuiToolProgressBuffer(); + + buffer.append(ToolProgress.output("stdout", "first\r")); + buffer.append(ToolProgress.output("stdout", "\nsecond\r")); + buffer.append(ToolProgress.output("stdout", "third")); + + assertEquals("stdout: first\nstdout: second\nstdout: third", buffer.render()); + assertEquals(3, buffer.retainedLineCount()); + } + + @Test + void replacesStateProgressAndFreezesAfterCompletion() { + TuiToolProgressBuffer buffer = new TuiToolProgressBuffer(); + + for (int index = 0; index < 100; index++) { + buffer.append(ToolProgress.phase("phase-" + index, "phase title " + index)); + buffer.append(ToolProgress.status("status", "detail-" + index)); + buffer.append(ToolProgress.counter("items", index, 100)); + buffer.append(ToolProgress.percent("percent", index)); + } + buffer.append(ToolProgress.output("stdout", "latest output\n")); + + String running = buffer.render(); + assertTrue(running.contains("phase-99")); + assertTrue(running.contains("status detail-99")); + assertTrue(running.contains("items 99/100")); + assertTrue(running.contains("percent 99%")); + assertTrue(running.contains("stdout: latest output")); + assertFalse(running.contains("phase-0\n")); + assertFalse(running.contains("status detail-0\n")); + + buffer.complete(successfulEnd()); + + assertFalse(buffer.active()); + assertTrue(buffer.render().contains("exit 0")); + assertTrue(buffer.render().contains("complete")); + String completed = buffer.render(); + buffer.append(ToolProgress.status("status", "too late")); + assertEquals(completed, buffer.render()); + } + + private static ToolEndEvent successfulEnd() { + return new ToolEndEvent( + "ses_1", + "toolu_1", + ToolExecutionStatus.SUCCEEDED, + 0, + new ToolResultSummary("bash succeeded", "complete", false, 0, false, 42L, Map.of()), + null, + NOW, + NOW.plusMillis(20), + 20L, + Map.of(), + NOW.plusMillis(20) + ); + } +} From d06b504c177b10eb9a7f1f0a6e7b7086efbd7544 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 14:57:30 +0800 Subject: [PATCH 08/58] =?UTF-8?q?fix(tui):=20=E9=99=90=E5=88=B6=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E4=B8=AD=E5=B7=A5=E5=85=B7=E8=BE=93=E5=87=BA=E5=B0=BE?= =?UTF-8?q?=E9=83=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lypi/transport/tui/TuiEventReducer.java | 89 +---- .../cn/lypi/transport/tui/TuiRenderState.java | 33 +- .../transport/tui/TuiToolProgressBuffer.java | 348 ++++++++++++++++++ 3 files changed, 397 insertions(+), 73 deletions(-) create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiToolProgressBuffer.java diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java index 3a22f38d..af29de14 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java @@ -2,7 +2,6 @@ import cn.lypi.contracts.context.ContentBlockKind; import cn.lypi.contracts.context.MessageRole; -import cn.lypi.contracts.common.ToolProgress; import cn.lypi.contracts.event.AgentEvent; import cn.lypi.contracts.event.CompactEndEvent; import cn.lypi.contracts.event.CompactStartEvent; @@ -28,9 +27,7 @@ import cn.lypi.contracts.security.FileSystemPermissionPolicy; import cn.lypi.contracts.security.NetworkPermissionPolicy; import cn.lypi.contracts.session.SessionView; -import cn.lypi.contracts.tool.ToolResultSummary; import cn.lypi.contracts.tool.ToolExecutionStatus; -import cn.lypi.contracts.tool.ToolOutputRef; import cn.lypi.contracts.tui.DiffView; import cn.lypi.contracts.tui.PermissionPromptView; import cn.lypi.contracts.tui.SessionRuntimeState; @@ -386,6 +383,10 @@ private void putOrAdd(int index, TuiBlock block) { private void reduceToolStart(ToolStartEvent event) { String label = firstNonBlank(event.inputSummary(), event.displayTitle(), event.toolName()); + String details = state.startToolProgress( + event.toolUseId(), + metadataString(event.inputMetadata(), "preview", "") + ); TuiToolBlock block = new TuiToolBlock( "tool:" + event.toolUseId(), event.parentMessageId(), @@ -393,7 +394,7 @@ private void reduceToolStart(ToolStartEvent event) { event.toolName(), TuiToolState.RUNNING, label, - metadataString(event.inputMetadata(), "preview", ""), + details, true ); int index = state.toolIndex(event.toolUseId()).orElse(-1); @@ -409,6 +410,11 @@ private void reduceToolStart(ToolStartEvent event) { private void reduceToolProgress(ToolProgressEvent event) { state.toolIndex(event.toolUseId()).ifPresent(index -> { TuiToolBlock current = (TuiToolBlock) state.blocks().get(index); + String details = state.appendToolProgress( + event.toolUseId(), + current.details(), + event.progress() + ); state.putBlock(index, new TuiToolBlock( current.blockId(), current.messageId(), @@ -416,7 +422,7 @@ private void reduceToolProgress(ToolProgressEvent event) { current.toolName(), TuiToolState.RUNNING, current.label(), - appendDetail(current.details(), progressDetail(event.progress())), + details, true )); }); @@ -426,6 +432,11 @@ private void reduceToolEnd(ToolEndEvent event) { state.toolIndex(event.toolUseId()).ifPresent(index -> { TuiToolBlock current = (TuiToolBlock) state.blocks().get(index); TuiToolState toolState = toolState(event.status()); + String details = state.completeToolProgress( + event.toolUseId(), + current.details(), + event + ); state.putBlock(index, new TuiToolBlock( current.blockId(), current.messageId(), @@ -433,66 +444,13 @@ private void reduceToolEnd(ToolEndEvent event) { current.toolName(), toolState, current.label(), - appendDetail(current.details(), endDetail(event)), + details, false )); }); state.toolEnded(event.toolUseId()); } - private String progressDetail(ToolProgress progress) { - if (progress == null) { - return ""; - } - return switch (progress.kind()) { - case OUTPUT -> firstNonBlank(progress.stream(), "output") + ": " + firstNonBlank(progress.delta(), ""); - case PHASE -> firstNonBlank(progress.phase(), progress.title(), progress.detail()); - case STATUS -> firstNonBlank(progress.title(), "") + suffix(progress.detail()); - case COUNTER -> firstNonBlank(progress.title(), "progress") + " " + progress.current() + "/" + progress.total(); - case PERCENT -> firstNonBlank(progress.title(), "progress") + " " + percentLabel(progress.percent()); - case CUSTOM -> firstNonBlank(progress.title(), progress.metadata().toString()); - }; - } - - private String endDetail(ToolEndEvent event) { - StringBuilder detail = new StringBuilder(); - if (event.exitCode() != null) { - detail.append("exit ").append(event.exitCode()); - } - ToolResultSummary summary = event.resultSummary(); - if (summary != null) { - appendLine(detail, firstNonBlank(summary.summary(), summary.title())); - } - String preview = preview(event.resultRef(), summary); - if (!preview.isBlank()) { - appendLine(detail, preview); - } - return detail.toString(); - } - - private String preview(ToolOutputRef resultRef, ToolResultSummary summary) { - String refPreview = metadataString(resultRef == null ? null : resultRef.metadata(), "preview", ""); - if (!refPreview.isBlank()) { - return refPreview; - } - if (summary == null) { - return ""; - } - return metadataString(summary.metadata(), "preview", ""); - } - - private String appendDetail(String current, String addition) { - String safeAddition = addition == null ? "" : addition.strip(); - if (safeAddition.isBlank()) { - return current == null ? "" : current; - } - String safeCurrent = current == null ? "" : current.strip(); - if (safeCurrent.isBlank()) { - return safeAddition; - } - return safeCurrent + "\n" + safeAddition; - } - private void appendLine(StringBuilder builder, String line) { if (line == null || line.isBlank()) { return; @@ -641,17 +599,4 @@ private String metadataString(java.util.Map metadata, String key return text.isBlank() ? fallback : text; } - private String suffix(String value) { - return value == null || value.isBlank() ? "" : " " + value; - } - - private String percentLabel(Double percent) { - if (percent == null) { - return ""; - } - if (percent % 1 == 0) { - return percent.intValue() + "%"; - } - return String.format(java.util.Locale.ROOT, "%.1f%%", percent); - } } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java index e04a27c6..c07f3592 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java @@ -1,10 +1,12 @@ package cn.lypi.transport.tui; -import cn.lypi.contracts.event.SessionStateEvent; +import cn.lypi.contracts.common.ToolProgress; import cn.lypi.contracts.context.AgentMessage; import cn.lypi.contracts.context.ContentBlock; import cn.lypi.contracts.context.MessageRole; import cn.lypi.contracts.context.ToolCallContentBlock; +import cn.lypi.contracts.event.SessionStateEvent; +import cn.lypi.contracts.event.ToolEndEvent; import cn.lypi.contracts.tui.DiffView; import cn.lypi.contracts.tui.PermissionPromptView; import cn.lypi.contracts.tui.SessionFileView; @@ -33,6 +35,7 @@ final class TuiRenderState { private final List files = new ArrayList<>(); private final Map blockIndexes = new HashMap<>(); private final Map toolIndexes = new HashMap<>(); + private final Map toolProgressBuffers = new HashMap<>(); private PermissionPromptView permissionPrompt; private DiffView diffView; private StatusBarState statusBar = new StatusBarState("", "", "ready", ""); @@ -123,6 +126,7 @@ void clearDiffView() { } void configure(SessionRuntimeState runtimeState) { + toolProgressBuffers.clear(); if (runtimeState == null) { statusBar = new StatusBarState("", "", "ready", ""); agentMode = "ready"; @@ -151,6 +155,7 @@ void configure(SessionRuntimeState runtimeState) { } private void replaceBlocks(List nextBlocks) { + toolProgressBuffers.clear(); blocks.clear(); blocks.addAll(nextBlocks); rebuildIndexes(); @@ -218,7 +223,32 @@ void toolStarted(String toolUseId) { statusBar = withMode(currentMode()); } + String startToolProgress(String toolUseId, String initialDetail) { + TuiToolProgressBuffer buffer = new TuiToolProgressBuffer(initialDetail); + toolProgressBuffers.put(toolUseId, buffer); + return buffer.render(); + } + + String appendToolProgress(String toolUseId, String initialDetail, ToolProgress progress) { + TuiToolProgressBuffer buffer = toolProgressBuffers.computeIfAbsent( + toolUseId, + ignored -> new TuiToolProgressBuffer(initialDetail) + ); + buffer.append(progress); + return buffer.render(); + } + + String completeToolProgress(String toolUseId, String initialDetail, ToolEndEvent event) { + TuiToolProgressBuffer buffer = toolProgressBuffers.remove(toolUseId); + if (buffer == null) { + buffer = new TuiToolProgressBuffer(initialDetail); + } + buffer.complete(event); + return buffer.render(); + } + void toolEnded(String toolUseId) { + toolProgressBuffers.remove(toolUseId); if (toolUseId != null && !toolUseId.isBlank()) { runningToolUseIds.remove(toolUseId); } @@ -311,6 +341,7 @@ void compactEnded() { } void interrupted(String reason) { + toolProgressBuffers.clear(); runningToolUseIds.clear(); runtimeInterruptibleTool = false; retryLine = ""; diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiToolProgressBuffer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiToolProgressBuffer.java new file mode 100644 index 00000000..1aa7ef13 --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiToolProgressBuffer.java @@ -0,0 +1,348 @@ +package cn.lypi.transport.tui; + +import cn.lypi.contracts.common.ToolProgress; +import cn.lypi.contracts.common.ToolProgressKind; +import cn.lypi.contracts.event.ToolEndEvent; +import cn.lypi.contracts.tool.ToolOutputRef; +import cn.lypi.contracts.tool.ToolResultSummary; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +final class TuiToolProgressBuffer { + static final int MAX_RETAINED_CHARACTERS = 16 * 1024; + static final int MAX_RETAINED_LINES = 200; + + private static final int MAX_INITIAL_LINES = 7; + private static final int MAX_STATE_LINES = 5; + private static final int MAX_FINAL_LINES = 4; + private static final int MAX_OUTPUT_LINES = MAX_RETAINED_LINES + - MAX_INITIAL_LINES + - MAX_STATE_LINES + - MAX_FINAL_LINES; + private static final int MAX_INITIAL_CHARACTERS = 200; + private static final int MAX_STATE_CHARACTERS = 350; + private static final int MAX_FINAL_CHARACTERS = 250; + private static final int MAX_STREAM_LABEL_CHARACTERS = 48; + + private final ArrayDeque completedOutputLines = new ArrayDeque<>(); + private final StringBuilder currentOutputLine = new StringBuilder(); + private final EnumMap latestStates = new EnumMap<>(ToolProgressKind.class); + private final List initialLines; + private final List finalLines = new ArrayList<>(); + private String currentStream; + private String pendingCarriageReturnStream; + private int completedOutputCharacters; + private long omittedCharacters; + private long omittedLines; + private boolean active = true; + + TuiToolProgressBuffer() { + this(""); + } + + TuiToolProgressBuffer(String initialDetail) { + initialLines = boundedLines(initialDetail, MAX_INITIAL_LINES, MAX_INITIAL_CHARACTERS); + } + + void append(ToolProgress progress) { + if (!active || progress == null) { + return; + } + if (progress.kind() == ToolProgressKind.OUTPUT) { + appendOutput(progress.stream(), progress.delta()); + return; + } + String detail = formatState(progress); + if (detail.isBlank()) { + latestStates.remove(progress.kind()); + } else { + latestStates.put(progress.kind(), detail); + } + } + + void complete(ToolEndEvent event) { + if (!active) { + return; + } + if (event != null) { + if (event.exitCode() != null) { + finalLines.add("exit " + event.exitCode()); + } + if (event.status() != null) { + finalLines.add("status " + event.status().name().toLowerCase(Locale.ROOT)); + } + ToolResultSummary summary = event.resultSummary(); + if (summary != null) { + addFinalLine(firstNonBlank(summary.summary(), summary.title())); + } + addFinalLine(preview(event.resultRef(), summary)); + } + while (finalLines.size() > MAX_FINAL_LINES) { + finalLines.remove(MAX_FINAL_LINES); + } + active = false; + } + + boolean active() { + return active; + } + + int retainedCharacters() { + int lines = retainedLineCount(); + int currentCharacters = currentStream == null + ? 0 + : outputPrefix(currentStream).length() + currentOutputLine.length(); + return completedOutputCharacters + currentCharacters + Math.max(0, lines - 1); + } + + int retainedLineCount() { + return completedOutputLines.size() + (currentStream == null ? 0 : 1); + } + + String render() { + List groups = new ArrayList<>(5); + addGroup(groups, boundedGroup(initialLines, MAX_INITIAL_CHARACTERS)); + if (omittedCharacters > 0 || omittedLines > 0) { + groups.add("... earlier output omitted (" + omittedCharacters + " characters, " + + omittedLines + " lines) ..."); + } + addGroup(groups, renderOutput()); + addGroup(groups, boundedGroup(stateLines(), MAX_STATE_CHARACTERS)); + addGroup(groups, boundedGroup(finalLines, MAX_FINAL_CHARACTERS)); + return String.join("\n", groups); + } + + private void appendOutput(String stream, String delta) { + if (delta == null || delta.isEmpty()) { + return; + } + String safeStream = streamLabel(stream); + for (int index = 0; index < delta.length(); index++) { + char character = delta.charAt(index); + if (pendingCarriageReturnStream != null) { + boolean completesCrLf = character == '\n' + && pendingCarriageReturnStream.equals(safeStream); + pendingCarriageReturnStream = null; + if (completesCrLf) { + continue; + } + } + ensureCurrentStream(safeStream); + if (character == '\r') { + completeCurrentLine(); + pendingCarriageReturnStream = safeStream; + } else if (character == '\n') { + completeCurrentLine(); + } else { + currentOutputLine.append(character); + } + } + trimOutput(); + } + + private void ensureCurrentStream(String stream) { + if (currentStream == null) { + currentStream = stream; + return; + } + if (!currentStream.equals(stream)) { + completeCurrentLine(); + currentStream = stream; + } + } + + private void completeCurrentLine() { + if (currentStream == null) { + return; + } + String line = outputPrefix(currentStream) + currentOutputLine; + completedOutputLines.addLast(line); + completedOutputCharacters += line.length(); + currentOutputLine.setLength(0); + currentStream = null; + trimOutput(); + } + + private void trimOutput() { + while (retainedLineCount() > outputLineLimit() || retainedCharacters() > MAX_RETAINED_CHARACTERS) { + if (!completedOutputLines.isEmpty()) { + String omitted = completedOutputLines.removeFirst(); + completedOutputCharacters -= omitted.length(); + omittedCharacters += omitted.length() + 1L; + omittedLines++; + continue; + } + int excess = retainedCharacters() - MAX_RETAINED_CHARACTERS; + if (excess <= 0 || currentOutputLine.isEmpty()) { + break; + } + int charactersToOmit = Math.min(excess, currentOutputLine.length()); + currentOutputLine.delete(0, charactersToOmit); + omittedCharacters += charactersToOmit; + } + } + + private int outputLineLimit() { + return omittedCharacters > 0 || omittedLines > 0 ? MAX_OUTPUT_LINES - 1 : MAX_OUTPUT_LINES; + } + + private String renderOutput() { + if (completedOutputLines.isEmpty() && currentStream == null) { + return ""; + } + StringBuilder output = new StringBuilder(retainedCharacters()); + for (String line : completedOutputLines) { + appendLine(output, line); + } + if (currentStream != null) { + appendLine(output, outputPrefix(currentStream) + currentOutputLine); + } + return output.toString(); + } + + private List stateLines() { + List lines = new ArrayList<>(MAX_STATE_LINES); + addStateLine(lines, ToolProgressKind.PHASE); + addStateLine(lines, ToolProgressKind.STATUS); + addStateLine(lines, ToolProgressKind.COUNTER); + addStateLine(lines, ToolProgressKind.PERCENT); + addStateLine(lines, ToolProgressKind.CUSTOM); + return lines; + } + + private void addStateLine(List lines, ToolProgressKind kind) { + String line = latestStates.get(kind); + if (line != null && !line.isBlank()) { + lines.add(line); + } + } + + private String formatState(ToolProgress progress) { + return switch (progress.kind()) { + case PHASE -> firstNonBlank(progress.phase(), progress.title(), progress.detail()); + case STATUS -> firstNonBlank(progress.title(), "") + suffix(progress.detail()); + case COUNTER -> firstNonBlank(progress.title(), "progress") + " " + + valueOrEmpty(progress.current()) + "/" + valueOrEmpty(progress.total()); + case PERCENT -> firstNonBlank(progress.title(), "progress") + " " + percentLabel(progress.percent()); + case CUSTOM -> firstNonBlank(progress.title(), progress.metadata().toString()); + case OUTPUT -> ""; + }; + } + + private void addFinalLine(String line) { + String safeLine = singleLine(line); + if (!safeLine.isBlank() && finalLines.size() < MAX_FINAL_LINES) { + finalLines.add(safeLine); + } + } + + private String preview(ToolOutputRef resultRef, ToolResultSummary summary) { + String refPreview = metadataString(resultRef == null ? null : resultRef.metadata(), "preview"); + if (!refPreview.isBlank()) { + return refPreview; + } + return summary == null ? "" : metadataString(summary.metadata(), "preview"); + } + + private String streamLabel(String stream) { + String label = singleLine(firstNonBlank(stream, "output")); + if (label.length() <= MAX_STREAM_LABEL_CHARACTERS) { + return label; + } + return label.substring(0, MAX_STREAM_LABEL_CHARACTERS); + } + + private String outputPrefix(String stream) { + return stream + ": "; + } + + private String percentLabel(Double percent) { + if (percent == null) { + return ""; + } + if (percent % 1 == 0) { + return percent.intValue() + "%"; + } + return String.format(Locale.ROOT, "%.1f%%", percent); + } + + private List boundedLines(String detail, int maxLines, int maxCharacters) { + if (detail == null || detail.isBlank()) { + return List.of(); + } + String normalized = detail.replace("\r\n", "\n").replace('\r', '\n'); + String[] candidates = normalized.split("\n", -1); + List lines = new ArrayList<>(Math.min(candidates.length, maxLines)); + int characters = 0; + for (String candidate : candidates) { + if (lines.size() >= maxLines || characters >= maxCharacters) { + break; + } + int separator = lines.isEmpty() ? 0 : 1; + int remaining = maxCharacters - characters - separator; + if (remaining <= 0) { + break; + } + String line = candidate.length() <= remaining ? candidate : candidate.substring(0, remaining); + lines.add(line); + characters += separator + line.length(); + } + return List.copyOf(lines); + } + + private String boundedGroup(List lines, int maxCharacters) { + if (lines.isEmpty()) { + return ""; + } + String group = String.join("\n", lines); + return group.length() <= maxCharacters ? group : group.substring(0, maxCharacters); + } + + private String metadataString(Map metadata, String key) { + if (metadata == null || metadata.get(key) == null) { + return ""; + } + return metadata.get(key).toString(); + } + + private String singleLine(String value) { + if (value == null) { + return ""; + } + return value.replace('\r', ' ').replace('\n', ' ').strip(); + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return ""; + } + + private String suffix(String value) { + return value == null || value.isBlank() ? "" : " " + value; + } + + private String valueOrEmpty(Long value) { + return value == null ? "" : value.toString(); + } + + private void addGroup(List groups, String group) { + if (group != null && !group.isBlank()) { + groups.add(group); + } + } + + private void appendLine(StringBuilder builder, String line) { + if (!builder.isEmpty()) { + builder.append('\n'); + } + builder.append(line); + } +} From f9d058e55ca33ecccd4d867e7d51855178bbe656 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 15:03:34 +0800 Subject: [PATCH 09/58] =?UTF-8?q?feat(tui):=20=E5=A2=9E=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E9=87=8D=E7=BB=98=E8=B0=83=E5=BA=A6=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/tui/TuiRedrawScheduler.java | 74 +++++++++++++++ .../transport/tui/TuiRedrawSchedulerTest.java | 92 +++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRedrawScheduler.java create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRedrawSchedulerTest.java diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRedrawScheduler.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRedrawScheduler.java new file mode 100644 index 00000000..3d12656c --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRedrawScheduler.java @@ -0,0 +1,74 @@ +package cn.lypi.transport.tui; + +import java.util.Objects; +import java.util.function.LongSupplier; + +final class TuiRedrawScheduler { + static final long DEFAULT_FRAME_INTERVAL_NANOS = 16_000_000L; + + private final LongSupplier nanoTime; + private final long frameIntervalNanos; + private boolean pending; + private boolean rendered; + private long lastRenderNanos; + + TuiRedrawScheduler() { + this(System::nanoTime, DEFAULT_FRAME_INTERVAL_NANOS); + } + + TuiRedrawScheduler(LongSupplier nanoTime) { + this(nanoTime, DEFAULT_FRAME_INTERVAL_NANOS); + } + + TuiRedrawScheduler(LongSupplier nanoTime, long frameIntervalNanos) { + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime"); + if (frameIntervalNanos < 0L) { + throw new IllegalArgumentException("frameIntervalNanos must not be negative"); + } + this.frameIntervalNanos = frameIntervalNanos; + } + + void request() { + pending = true; + } + + boolean renderIfDue(Runnable render) { + if (!pending) { + return false; + } + long now = nanoTime.getAsLong(); + if (rendered && !frameIsDue(now)) { + return false; + } + runRender(render); + return true; + } + + void renderNow(Runnable render) { + runRender(render); + } + + boolean pending() { + return pending; + } + + private boolean frameIsDue(long now) { + if (frameIntervalNanos == 0L || now < lastRenderNanos) { + return true; + } + return now - lastRenderNanos >= frameIntervalNanos; + } + + private void runRender(Runnable render) { + Objects.requireNonNull(render, "render"); + pending = false; + try { + render.run(); + rendered = true; + lastRenderNanos = nanoTime.getAsLong(); + } catch (RuntimeException | Error failure) { + pending = true; + throw failure; + } + } +} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRedrawSchedulerTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRedrawSchedulerTest.java new file mode 100644 index 00000000..a2f17cb2 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRedrawSchedulerTest.java @@ -0,0 +1,92 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class TuiRedrawSchedulerTest { + @Test + void firstRequestRendersImmediatelyWhenIdle() { + AtomicLong now = new AtomicLong(); + AtomicInteger renders = new AtomicInteger(); + TuiRedrawScheduler scheduler = new TuiRedrawScheduler(now::get); + + scheduler.request(); + + assertTrue(scheduler.renderIfDue(renders::incrementAndGet)); + assertEquals(1, renders.get()); + assertFalse(scheduler.pending()); + } + + @Test + void coalescesRequestsWithinOneFrameWindow() { + AtomicLong now = new AtomicLong(); + AtomicInteger renders = new AtomicInteger(); + TuiRedrawScheduler scheduler = new TuiRedrawScheduler(now::get); + + scheduler.request(); + assertTrue(scheduler.renderIfDue(renders::incrementAndGet)); + for (int index = 0; index < 100; index++) { + scheduler.request(); + assertFalse(scheduler.renderIfDue(renders::incrementAndGet)); + } + + assertEquals(1, renders.get()); + assertTrue(scheduler.pending()); + } + + @Test + void rendersLatestPendingStateOnceWhenFrameBecomesDue() { + AtomicLong now = new AtomicLong(); + AtomicInteger renders = new AtomicInteger(); + TuiRedrawScheduler scheduler = new TuiRedrawScheduler(now::get); + + scheduler.request(); + scheduler.renderIfDue(renders::incrementAndGet); + scheduler.request(); + scheduler.request(); + now.set(TuiRedrawScheduler.DEFAULT_FRAME_INTERVAL_NANOS); + + assertTrue(scheduler.renderIfDue(renders::incrementAndGet)); + assertEquals(2, renders.get()); + assertFalse(scheduler.renderIfDue(renders::incrementAndGet)); + } + + @Test + void renderNowBypassesWindowAndClearsPendingRequest() { + AtomicLong now = new AtomicLong(); + AtomicInteger renders = new AtomicInteger(); + TuiRedrawScheduler scheduler = new TuiRedrawScheduler(now::get); + + scheduler.request(); + scheduler.renderIfDue(renders::incrementAndGet); + scheduler.request(); + + scheduler.renderNow(renders::incrementAndGet); + + assertEquals(2, renders.get()); + assertFalse(scheduler.pending()); + assertFalse(scheduler.renderIfDue(renders::incrementAndGet)); + } + + @Test + void failedRenderKeepsRequestPendingForImmediateRetry() { + AtomicLong now = new AtomicLong(); + AtomicInteger renders = new AtomicInteger(); + TuiRedrawScheduler scheduler = new TuiRedrawScheduler(now::get); + scheduler.request(); + + assertThrows(IllegalStateException.class, () -> scheduler.renderIfDue(() -> { + throw new IllegalStateException("render failed"); + })); + + assertTrue(scheduler.pending()); + assertTrue(scheduler.renderIfDue(renders::incrementAndGet)); + assertEquals(1, renders.get()); + } +} From 514ca56266f59a502109d3744bd0226838f7da8a Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 15:26:35 +0800 Subject: [PATCH 10/58] =?UTF-8?q?fix(tui):=20=E5=90=88=E5=B9=B6=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E8=A7=A6=E5=8F=91=E7=9A=84=E7=BB=88=E7=AB=AF=E9=87=8D?= =?UTF-8?q?=E7=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lypi/transport/tui/JLineTuiTransport.java | 169 +++++++++++++++--- .../cn/lypi/transport/tui/TuiInputLoop.java | 40 ++++- .../tui/JLineTuiTransportConcurrencyTest.java | 68 ++++++- .../JLineTuiTransportRenderPipelineTest.java | 60 ++++++- .../transport/tui/JLineTuiTransportTest.java | 7 +- 5 files changed, 313 insertions(+), 31 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java index 9c9cae13..e585d05c 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java @@ -21,6 +21,7 @@ import java.time.Clock; import java.util.List; import java.util.Optional; +import java.util.function.LongSupplier; import java.util.function.Supplier; import org.jline.terminal.Terminal; @@ -45,10 +46,12 @@ public final class JLineTuiTransport implements TuiTransport, AutoCloseable { private final DiffViewProvider diffViewProvider; private final Clock clock; private final TuiRuntimeTicker runtimeTicker; + private final TuiRedrawScheduler redrawScheduler; private SessionRuntimeState runtimeState; private EventSubscription subscription; private EventBus attachedEvents; private boolean lastRenderHeldUiLock; + private volatile boolean terminalIoFailed; private int uiLockEntries; public JLineTuiTransport(Runnable renderer) { @@ -64,6 +67,7 @@ public JLineTuiTransport(Runnable renderer) { this.diffViewProvider = NOOP_DIFF_VIEW_PROVIDER; this.clock = Clock.systemUTC(); this.runtimeTicker = new TuiRuntimeTicker(RUNTIME_TICK_INTERVAL_MILLIS, MAX_DIFF_PATCH_BYTES); + this.redrawScheduler = new TuiRedrawScheduler(); this.runtimeState = null; } @@ -72,6 +76,16 @@ private JLineTuiTransport(FrameSink frameSink, int width, int height) { } private JLineTuiTransport(FrameSink frameSink, int width, int height, TerminalSession terminalSession) { + this(frameSink, width, height, terminalSession, new TuiRedrawScheduler()); + } + + private JLineTuiTransport( + FrameSink frameSink, + int width, + int height, + TerminalSession terminalSession, + TuiRedrawScheduler redrawScheduler + ) { this.renderer = null; this.reducer = new TuiEventReducer(); this.tuiRenderer = new TuiRenderer(); @@ -86,6 +100,7 @@ private JLineTuiTransport(FrameSink frameSink, int width, int height, TerminalSe this.diffViewProvider = NOOP_DIFF_VIEW_PROVIDER; this.clock = Clock.systemUTC(); this.runtimeTicker = new TuiRuntimeTicker(RUNTIME_TICK_INTERVAL_MILLIS, MAX_DIFF_PATCH_BYTES); + this.redrawScheduler = redrawScheduler; this.runtimeState = null; } @@ -133,6 +148,7 @@ private JLineTuiTransport( Clock clock ) { this.renderer = null; + this.redrawScheduler = new TuiRedrawScheduler(); this.reducer = TuiEventReducer.fromRuntimeState(state); this.tuiRenderer = new TuiRenderer(); int safeWidth = safeWidth(width); @@ -150,7 +166,8 @@ private JLineTuiTransport( slashPickerSupplier, resumeController, this::resumeRuntimeState, - skillIndexSupplier + skillIndexSupplier, + this::renderImmediateFrame ); this.inputPump = new TerminalInputPump(inputSource, new KeyMapper(), inputLoop); this.terminalSession = terminalSession; @@ -549,6 +566,22 @@ static JLineTuiTransport withRenderer(FrameSink frameSink, int width, int height return new JLineTuiTransport(frameSink, width, height); } + static JLineTuiTransport withRenderer( + FrameSink frameSink, + int width, + int height, + LongSupplier nanoTime, + long frameIntervalNanos + ) { + return new JLineTuiTransport( + frameSink, + width, + height, + null, + new TuiRedrawScheduler(nanoTime, frameIntervalNanos) + ); + } + static JLineTuiTransport withInput( FrameSink frameSink, int width, @@ -742,12 +775,18 @@ public String name() { * 运行终端输入循环,直到用户请求退出。 */ public void runUntilExit() throws IOException { - while (!exitRequested() && !Thread.currentThread().isInterrupted()) { - drainInput(); - if (!exitRequested()) { - renderRuntimeTickIfDue(); - sleepAfterEmptyPoll(); + try { + while (!exitRequested() && !Thread.currentThread().isInterrupted()) { + drainInput(); + if (!exitRequested()) { + renderRuntimeTickIfDue(); + renderPendingFrameIfDue(); + sleepAfterEmptyPoll(); + } } + } catch (IOException exception) { + terminalIoFailed = true; + throw exception; } } @@ -765,9 +804,9 @@ public void attach(EventBus events, SessionRuntimeState state) { new EventFilter(Optional.ofNullable(state).map(SessionRuntimeState::sessionId), Optional.empty()), envelope -> { if (reducer != null) { - reduceAndRenderUnderUiLock(envelope.event()); + reduceAndRequestRenderUnderUiLock(envelope.event()); } else { - renderUnderUiLock(); + requestRenderUnderUiLock(); } } ); @@ -780,29 +819,34 @@ private void resumeRuntimeState(SessionRuntimeState state) { } } - void reduceAndRenderUnderUiLock(AgentEvent event) { + void reduceAndRequestRenderUnderUiLock(AgentEvent event) { synchronized (uiMonitor) { uiLockEntries++; reducer.reduce(event); runtimeTicker.refreshDiffAfterToolEnd(event, runtimeState, reducer, diffViewProvider); - renderCurrentFrame(); + syncInputLoopToolState(reducer.view()); + redrawScheduler.request(); + } + } + + private void requestRenderUnderUiLock() { + synchronized (uiMonitor) { + uiLockEntries++; + redrawScheduler.request(); } } void renderCurrentFrameUnderUiLock() { synchronized (uiMonitor) { uiLockEntries++; - renderCurrentFrame(); + redrawScheduler.renderNow(this::renderCurrentFrame); } } void renderUnderUiLock() { synchronized (uiMonitor) { uiLockEntries++; - lastRenderHeldUiLock = Thread.holdsLock(uiMonitor); - if (renderer != null) { - renderer.run(); - } + redrawScheduler.renderNow(this::renderCurrentFrame); } } @@ -826,6 +870,26 @@ void renderRuntimeTickForTest() { renderRuntimeTickUnderUiLock(); } + boolean renderPendingFrameIfDueForTest() { + return renderPendingFrameIfDue(); + } + + void flushPendingFrameForTest() { + synchronized (uiMonitor) { + if (!redrawScheduler.pending()) { + return; + } + uiLockEntries++; + redrawScheduler.renderNow(this::renderCurrentFrame); + } + } + + TuiViewModel viewForTest() { + synchronized (uiMonitor) { + return reducer.view(); + } + } + int uiLockEntryCountForTest() { return uiLockEntries; } @@ -844,12 +908,31 @@ int currentDraftLengthForTest() { @Override public void close() throws Exception { + Exception failure = null; synchronized (uiMonitor) { closeSubscription(); + if (!terminalIoFailed && redrawScheduler.pending() && reducer != null) { + try { + redrawScheduler.renderNow(this::renderCurrentFrame); + } catch (RuntimeException exception) { + failure = exception; + } + } if (terminalSession != null) { - terminalSession.close(); + try { + terminalSession.close(); + } catch (Exception exception) { + if (failure == null) { + failure = exception; + } else { + failure.addSuppressed(exception); + } + } } } + if (failure != null) { + throw failure; + } } private void closeSubscription() { @@ -890,6 +973,7 @@ private void drainInput() throws IOException { if (inputPump.hasBufferedIncompleteKeySequence()) { synchronized (uiMonitor) { uiLockEntries++; + flushPendingFrameNow(); inputPump.flushBufferedInput(); } } @@ -897,6 +981,7 @@ private void drainInput() throws IOException { } synchronized (uiMonitor) { uiLockEntries++; + flushPendingFrameNow(); inputPump.dispatchChunk(chunk.orElseThrow()); } } @@ -915,7 +1000,7 @@ private void resize(int width, int height) { if (inputLoop != null) { inputLoop.updateViewport(screen, layout); } - renderCurrentFrame(); + redrawScheduler.renderNow(this::renderCurrentFrame); } } @@ -923,20 +1008,54 @@ private void handleInterruptSignal() { synchronized (uiMonitor) { uiLockEntries++; if (inputLoop != null) { + flushPendingFrameNow(); inputLoop.acceptKey(TerminalKey.CTRL_C); } } } private void renderCurrentFrame() { - reducer.observeRuntimeAt(clock.instant()); - TuiViewModel view = reducer.view(); - syncInputLoopToolState(view); - if (inputLoop != null) { - inputLoop.renderCurrentFrame(); - return; + lastRenderHeldUiLock = Thread.holdsLock(uiMonitor); + try { + if (renderer != null) { + renderer.run(); + return; + } + if (reducer == null) { + return; + } + reducer.observeRuntimeAt(clock.instant()); + TuiViewModel view = reducer.view(); + syncInputLoopToolState(view); + if (inputLoop != null) { + inputLoop.renderCurrentFrame(); + return; + } + frameSink.render(tuiRenderer.renderFrame(view, screen, layout, currentDraft(), currentCursor())); + } catch (UncheckedIOException exception) { + terminalIoFailed = true; + throw exception; + } + } + + private void renderImmediateFrame() { + redrawScheduler.renderNow(this::renderCurrentFrame); + } + + private void flushPendingFrameNow() { + if (redrawScheduler.pending()) { + redrawScheduler.renderNow(this::renderCurrentFrame); + } + } + + private boolean renderPendingFrameIfDue() { + synchronized (uiMonitor) { + if (!redrawScheduler.pending()) { + return false; + } + uiLockEntries++; + return redrawScheduler.renderIfDue(this::renderCurrentFrame); } - frameSink.render(tuiRenderer.renderFrame(view, screen, layout, currentDraft(), currentCursor())); } private void renderRuntimeTickIfDue() { @@ -953,7 +1072,7 @@ private void renderRuntimeTickUnderUiLock() { return; } uiLockEntries++; - renderCurrentFrame(); + redrawScheduler.renderNow(this::renderCurrentFrame); } } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java index 276335e9..cc207377 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java @@ -25,6 +25,7 @@ final class TuiInputLoop { private final TerminalInputPolicy inputPolicy = new TerminalInputPolicy(); private final Supplier slashPickerSupplier; private final Supplier skillIndexSupplier; + private final Runnable immediateRender; private final ResumeSessionController resumeController; private final ResumeOverlayController resumeOverlayController; private SlashCommandPicker slashPicker; @@ -110,6 +111,34 @@ final class TuiInputLoop { ResumeSessionController resumeController, Consumer resumeStateConsumer, Supplier skillIndexSupplier + ) { + this( + submitHandler, + frameSink, + renderer, + screen, + layout, + viewSupplier, + slashPickerSupplier, + resumeController, + resumeStateConsumer, + skillIndexSupplier, + null + ); + } + + TuiInputLoop( + TuiSubmitHandler submitHandler, + FrameSink frameSink, + TuiRenderer renderer, + TuiScreen screen, + TuiLayout layout, + Supplier viewSupplier, + Supplier slashPickerSupplier, + ResumeSessionController resumeController, + Consumer resumeStateConsumer, + Supplier skillIndexSupplier, + Runnable immediateRender ) { this.submitHandler = submitHandler; this.frameSink = frameSink; @@ -121,6 +150,7 @@ final class TuiInputLoop { ? () -> SlashCommandPicker.withTemplates(List.of()) : slashPickerSupplier; this.skillIndexSupplier = skillIndexSupplier == null ? () -> new SkillIndex(List.of(), List.of()) : skillIndexSupplier; + this.immediateRender = immediateRender; this.resumeController = resumeController; this.resumeOverlayController = resumeController == null ? null : new ResumeOverlayController( resumeController, @@ -397,10 +427,18 @@ private void submitPermissionOption(PermissionPromptView prompt, String optionId } void renderCurrentFrame() { - render(); + renderFrame(); } private void render() { + if (immediateRender != null) { + immediateRender.run(); + return; + } + renderFrame(); + } + + private void renderFrame() { frameSink.render(renderer.renderFrame( currentView(), screen, diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportConcurrencyTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportConcurrencyTest.java index ffc983e3..7fd3f96a 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportConcurrencyTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportConcurrencyTest.java @@ -1,9 +1,19 @@ package cn.lypi.transport.tui; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import cn.lypi.contracts.common.ToolProgress; +import cn.lypi.contracts.event.AgentEvent; import cn.lypi.contracts.event.ErrorEvent; +import cn.lypi.contracts.event.EventBus; +import cn.lypi.contracts.event.EventConsumer; +import cn.lypi.contracts.event.EventEnvelope; +import cn.lypi.contracts.event.EventFilter; +import cn.lypi.contracts.event.EventSubscription; +import cn.lypi.contracts.event.ToolProgressEvent; +import cn.lypi.contracts.event.ToolStartEvent; import java.io.IOException; import java.time.Instant; import java.util.ArrayDeque; @@ -15,6 +25,43 @@ import org.junit.jupiter.api.Test; class JLineTuiTransportConcurrencyTest { + @Test + void publishingProgressDoesNotEnterBlockingFrameSink() throws Exception { + CountDownLatch renderEntered = new CountDownLatch(1); + CountDownLatch releaseRender = new CountDownLatch(1); + RecordingEventBus events = new RecordingEventBus(); + JLineTuiTransport transport = JLineTuiTransport.withRenderer(lines -> { + renderEntered.countDown(); + try { + releaseRender.await(2, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + }, 40, 5); + transport.attach(events, TestRuntimeStates.basic("ses_1")); + events.emit(new ToolStartEvent("ses_1", "toolu_1", "bash", Instant.parse("2026-06-10T00:00:00Z"))); + Thread publisher = new Thread(() -> events.emit(new ToolProgressEvent( + "ses_1", + "toolu_1", + ToolProgress.output("stdout", "progress\n"), + Instant.parse("2026-06-10T00:00:00Z") + ))); + + publisher.start(); + publisher.join(100L); + + assertFalse(publisher.isAlive()); + assertEquals(1L, renderEntered.getCount()); + + Thread uiFlush = new Thread(transport::flushPendingFrameForTest); + uiFlush.start(); + assertTrue(renderEntered.await(1, TimeUnit.SECONDS)); + releaseRender.countDown(); + uiFlush.join(1_000L); + assertFalse(uiFlush.isAlive()); + } + @Test void eventInputAndResizeRenderPathsShareUiMonitor() { StringBuilder order = new StringBuilder(); @@ -83,7 +130,7 @@ void submitDoesNotHoldUiLockWhileWaitingForMoreInput() throws Exception { @Override public void submitUserInput(String input) { new Thread(() -> { - holder[0].reduceAndRenderUnderUiLock(new ErrorEvent( + holder[0].reduceAndRequestRenderUnderUiLock(new ErrorEvent( "ses_1", "err_1", "boom", @@ -130,6 +177,25 @@ public Optional read() { } } + private static final class RecordingEventBus implements EventBus { + private EventConsumer consumer; + + @Override + public void publish(AgentEvent event) { + } + + @Override + public EventSubscription subscribe(EventFilter filter, EventConsumer consumer) { + this.consumer = consumer; + return () -> { + }; + } + + void emit(AgentEvent event) { + consumer.accept(new EventEnvelope("evt_1", "ses_1", 1, event)); + } + } + private static final class BlockingAfterChunksInputSource implements TerminalInputSource { private final CountDownLatch waitStarted; private final CountDownLatch release; diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java index 25113aab..93533a0d 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import cn.lypi.contracts.common.ToolProgress; import cn.lypi.contracts.context.ContentBlockKind; import cn.lypi.contracts.context.MessageKind; import cn.lypi.contracts.context.MessageRole; @@ -17,9 +18,12 @@ import cn.lypi.contracts.event.PermissionRequestEvent; import cn.lypi.contracts.event.RetryStartEvent; import cn.lypi.contracts.event.ToolEndEvent; +import cn.lypi.contracts.event.ToolProgressEvent; import cn.lypi.contracts.event.ToolStartEvent; import cn.lypi.contracts.event.TurnStartEvent; import cn.lypi.contracts.tui.SessionRuntimeState; +import cn.lypi.contracts.tui.TuiToolBlock; +import cn.lypi.contracts.tui.TuiToolState; import java.time.Clock; import java.time.Instant; import java.time.ZoneId; @@ -29,6 +33,7 @@ import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import org.junit.jupiter.api.Test; class JLineTuiTransportRenderPipelineTest { @@ -37,7 +42,45 @@ class JLineTuiTransportRenderPipelineTest { private static final String ANSI_RESET = "\033[0m"; @Test - void eventCallbackReducesAndRendersViewModelUnderUiLock() { + void toolProgressBurstReducesImmediatelyAndCoalescesTerminalFrames() { + RecordingEventBus events = new RecordingEventBus(); + AtomicLong now = new AtomicLong(); + List> frames = new ArrayList<>(); + JLineTuiTransport transport = JLineTuiTransport.withRenderer( + frames::add, + 80, + 8, + now::get, + TuiRedrawScheduler.DEFAULT_FRAME_INTERVAL_NANOS + ); + transport.attach(events, TestRuntimeStates.basic("ses_1")); + + events.emit(new ToolStartEvent("ses_1", "toolu_1", "bash", Instant.parse("2026-06-09T00:00:00Z"))); + for (int index = 0; index < 256; index++) { + events.emit(new ToolProgressEvent( + "ses_1", + "toolu_1", + ToolProgress.output("stdout", "chunk-" + index + "\n"), + Instant.parse("2026-06-09T00:00:00Z") + )); + } + events.emit(new ToolEndEvent("ses_1", "toolu_1", false, Instant.parse("2026-06-09T00:00:01Z"))); + + TuiToolBlock tool = (TuiToolBlock) transport.viewForTest().blocks().getFirst(); + assertEquals(TuiToolState.DONE, tool.state()); + assertFalse(tool.active()); + assertTrue(frames.isEmpty()); + + now.addAndGet(TuiRedrawScheduler.DEFAULT_FRAME_INTERVAL_NANOS); + assertTrue(transport.renderPendingFrameIfDueForTest()); + + assertEquals(1, frames.size()); + String finalFrame = String.join("\n", frames.getFirst()); + assertTrue(finalFrame.contains("status succeeded"), finalFrame); + } + + @Test + void eventCallbackReducesBeforeUiLoopRendersUnderUiLock() { RecordingEventBus events = new RecordingEventBus(); List frames = new ArrayList<>(); JLineTuiTransport transport = JLineTuiTransport.withRenderer(lines -> frames.add(String.join("\n", lines)), 40, 5); @@ -55,10 +98,12 @@ void eventCallbackReducesAndRendersViewModelUnderUiLock() { java.util.Map.of(), Instant.parse("2026-06-09T00:00:00Z") )); + assertTrue(frames.isEmpty()); + transport.flushPendingFrameForTest(); assertEquals(1, frames.size()); assertEquals("Done", frames.getFirst().lines().findFirst().orElseThrow()); - assertEquals(1, transport.uiLockEntryCountForTest()); + assertEquals(2, transport.uiLockEntryCountForTest()); } @Test @@ -82,6 +127,7 @@ void eventRenderingUsesTranscriptViewportInsteadOfReturningAnOversizedFrame() { Instant.parse("2026-06-09T00:00:00Z") )); } + transport.flushPendingFrameForTest(); List latest = frames.getLast(); assertTrue(latest.size() <= 7); @@ -112,6 +158,7 @@ void eventRenderProjectsRuntimeStateIntoStatusBar() { java.util.Map.of(), Instant.parse("2026-06-09T00:00:00Z") )); + transport.flushPendingFrameForTest(); assertEquals( "ses_1 gpt-5.4 EXECUTE DEFAULT_EXECUTE ON_REQUEST :workspace", @@ -162,6 +209,7 @@ void eventPipelineRendersUserAndThinkingAsDistinctLines() { java.util.Map.of(), Instant.parse("2026-06-09T00:00:02Z") )); + transport.flushPendingFrameForTest(); List latest = frames.getLast(); assertTrue(latest.contains("\033[38;5;81muser: 请修复 TUI\033[0m")); @@ -222,12 +270,13 @@ void resizeRerendersCurrentViewWithUpdatedDimensionsUnderUiLock() { java.util.Map.of(), Instant.parse("2026-06-09T00:00:00Z") )); + transport.flushPendingFrameForTest(); transport.resizeForTest(8, 4); assertEquals(2, frames.size()); frames.getLast().forEach(line -> assertEquals(line, AnsiWidth.truncate(line, 8))); - assertEquals(2, transport.uiLockEntryCountForTest()); + assertEquals(3, transport.uiLockEntryCountForTest()); } @Test @@ -334,6 +383,7 @@ void eventRerenderPreservesCurrentDraftInput() throws Exception { java.util.Map.of(), Instant.parse("2026-06-09T00:00:00Z") )); + transport.flushPendingFrameForTest(); assertEquals("Done", frames.getLast().getFirst()); assertEquals(inputContent("> draft|CURSOR|" + INPUT_CURSOR), inputLine(frames.getLast())); @@ -365,6 +415,7 @@ void eventRerenderPreservesCurrentDraftCursor() throws Exception { java.util.Map.of(), Instant.parse("2026-06-09T00:00:00Z") )); + transport.flushPendingFrameForTest(); assertEquals(inputContent("> dra|CURSOR|" + INPUT_CURSOR + "ft"), inputLine(frames.getLast())); } @@ -389,6 +440,7 @@ void retryStatusRendersAsTransientTranscriptLineWithoutMovingStatusBar() { Instant.parse("2026-06-09T00:00:00Z") )); events.emit(new RetryStartEvent("ses_1", 2, "rate limit", Instant.parse("2026-06-09T00:00:01Z"))); + transport.flushPendingFrameForTest(); List latest = frames.getLast(); assertEquals("hello", latest.get(0)); @@ -572,6 +624,7 @@ void idleTickRefreshesWorkingTurnElapsedTime() throws Exception { transport.attach(events, TestRuntimeStates.basic("ses_1")); events.emit(new TurnStartEvent("ses_1", "turn_1", clock.instant())); + transport.flushPendingFrameForTest(); assertTrue(frames.getLast().contains("· working (0s)")); @@ -601,6 +654,7 @@ void permissionRequestEventRendersPromptImmediatelyWithoutInputKey() throws Exce "Need approval", Instant.parse("2026-06-09T00:00:01Z") )); + transport.flushPendingFrameForTest(); List latest = frames.getLast(); assertTrue(latest.stream().anyMatch(line -> line.contains("permission toolu_1: Need approval"))); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java index 40a3926a..01bafa9f 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java @@ -63,13 +63,14 @@ class JLineTuiTransportTest { private static final DiffViewProvider NOOP_DIFF_PROVIDER = (cwd, maxPatchBytes) -> Optional.empty(); @Test - void attachSubscribesToSessionEventsAndRendersUnderUiLock() { + void attachSubscribesToSessionEventsAndUiFlushRendersUnderUiLock() { RecordingScreen screen = new RecordingScreen(); RecordingEventBus events = new RecordingEventBus(); JLineTuiTransport transport = new JLineTuiTransport(screen::render); transport.attach(events, runtimeState()); events.emit(new ErrorEvent("ses_1", "err_1", "boom", Instant.parse("2026-06-09T00:00:00Z"))); + transport.flushPendingFrameForTest(); assertTrue(events.subscribed); assertEquals(1, screen.renderCount); @@ -105,6 +106,7 @@ void openAssemblesTerminalSessionRendererInputAndEventSubscription() throws Exce ); events.emit(new ErrorEvent("ses_1", "err_1", "boom", Instant.parse("2026-06-09T00:00:00Z"))); + transport.flushPendingFrameForTest(); assertTrue(io.rawModeEntered); assertFalse(io.output.toString().contains("\033[?1049h")); @@ -171,6 +173,7 @@ void openPipelineKeepsLongTranscriptInOutputStreamWithoutAlternateScreenOrRepeat Map.of(), Instant.parse("2026-06-09T00:00:00Z") )); + transport.flushPendingFrameForTest(); } String output = io.output.toString(); @@ -214,6 +217,7 @@ void openPipelineKeepsBottomChromeInViewportWithoutScrollbackOverflow() throws E Instant.parse("2026-06-09T00:00:00Z") )); } + transport.flushPendingFrameForTest(); String output = io.output.toString(); assertFalse(output.contains("\r\n")); @@ -291,6 +295,7 @@ void toolEndRefreshesDiffViewFromProvider() throws Exception { false, Instant.parse("2026-06-09T00:00:00Z") )); + transport.flushPendingFrameForTest(); assertEquals(1, diffProvider.calls); assertEquals(Path.of("."), diffProvider.cwd); From 98fffc31ec6775be0669b1e3d78c75a5a3f25e00 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 15:29:46 +0800 Subject: [PATCH 11/58] =?UTF-8?q?test(tui):=20=E5=A2=9E=E5=8A=A0=E5=A4=A7?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=BE=93=E5=87=BA=E6=80=A7=E8=83=BD=E9=97=A8?= =?UTF-8?q?=E6=A7=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tui/TuiToolProgressPerformanceTest.java | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiToolProgressPerformanceTest.java diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiToolProgressPerformanceTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiToolProgressPerformanceTest.java new file mode 100644 index 00000000..250b81ed --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiToolProgressPerformanceTest.java @@ -0,0 +1,121 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import cn.lypi.contracts.common.ToolProgress; +import cn.lypi.contracts.event.ToolEndEvent; +import cn.lypi.contracts.event.ToolProgressEvent; +import cn.lypi.contracts.event.ToolStartEvent; +import cn.lypi.contracts.tui.TuiToolBlock; +import cn.lypi.contracts.tui.TuiToolState; +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.Test; + +class TuiToolProgressPerformanceTest { + private static final int CHUNK_SIZE = 4 * 1024; + private static final int ONE_MIB = 1024 * 1024; + private static final long DEFAULT_MAX_FOUR_MIB_MILLIS = 1_500L; + private static final Instant NOW = Instant.parse("2026-07-10T09:00:00Z"); + + @Test + void largeToolOutputRemainsBoundedAndScalesLinearly() { + runWorkload(ONE_MIB); + runWorkload(2 * ONE_MIB); + runWorkload(4 * ONE_MIB); + + TimedWorkload oneMiB = measure(ONE_MIB); + TimedWorkload twoMiB = measure(2 * ONE_MIB); + TimedWorkload fourMiB = measure(4 * ONE_MIB); + + assertBounded(oneMiB, 255); + assertBounded(twoMiB, 511); + assertBounded(fourMiB, 1023); + + long maxFourMiBMillis = Long.getLong( + "lypi.tui.progress.max4MiBMillis", + DEFAULT_MAX_FOUR_MIB_MILLIS + ); + assertTrue( + fourMiB.duration().compareTo(Duration.ofMillis(maxFourMiBMillis)) < 0, + () -> "4 MiB took " + formatMillis(fourMiB.duration()) + " ms" + ); + assertTrue( + fourMiB.duration().toNanos() <= twoMiB.duration().toNanos() * 3L, + () -> "2 MiB took " + formatMillis(twoMiB.duration()) + + " ms, 4 MiB took " + formatMillis(fourMiB.duration()) + " ms" + ); + + System.out.printf( + Locale.ROOT, + "TUI tool progress: 1 MiB=%s ms, 2 MiB=%s ms, 4 MiB=%s ms%n", + formatMillis(oneMiB.duration()), + formatMillis(twoMiB.duration()), + formatMillis(fourMiB.duration()) + ); + } + + private static TimedWorkload measure(int outputBytes) { + long startedAt = System.nanoTime(); + WorkloadResult result = runWorkload(outputBytes); + return new TimedWorkload(Duration.ofNanos(System.nanoTime() - startedAt), result); + } + + private static WorkloadResult runWorkload(int outputBytes) { + AtomicLong now = new AtomicLong(); + AtomicInteger renders = new AtomicInteger(); + JLineTuiTransport transport = JLineTuiTransport.withRenderer( + ignored -> renders.incrementAndGet(), + 80, + 8, + now::get, + TuiRedrawScheduler.DEFAULT_FRAME_INTERVAL_NANOS + ); + transport.reduceAndRequestRenderUnderUiLock(new ToolStartEvent("ses_1", "toolu_1", "bash", NOW)); + int chunks = outputBytes / CHUNK_SIZE; + for (int index = 0; index < chunks; index++) { + transport.reduceAndRequestRenderUnderUiLock(new ToolProgressEvent( + "ses_1", + "toolu_1", + ToolProgress.output("stdout", chunk(index)), + NOW + )); + } + transport.reduceAndRequestRenderUnderUiLock(new ToolEndEvent("ses_1", "toolu_1", false, NOW)); + transport.flushPendingFrameForTest(); + + TuiToolBlock tool = (TuiToolBlock) transport.viewForTest().blocks().getFirst(); + return new WorkloadResult(tool, renders.get()); + } + + private static String chunk(int index) { + String label = "chunk-%04d ".formatted(index); + return label + "x".repeat(CHUNK_SIZE - label.length() - 1) + "\n"; + } + + private static void assertBounded(TimedWorkload workload, int lastChunk) { + TuiToolBlock tool = workload.result().tool(); + assertEquals(TuiToolState.DONE, tool.state()); + assertFalse(tool.active()); + assertTrue(tool.details().length() <= 17 * 1024); + assertTrue(tool.details().contains("earlier output omitted")); + assertTrue(tool.details().contains("chunk-%04d".formatted(lastChunk))); + assertTrue(tool.details().contains("status succeeded")); + assertTrue(workload.result().renderCount() <= 1); + } + + private static String formatMillis(Duration duration) { + return String.format(Locale.ROOT, "%.3f", duration.toNanos() / 1_000_000.0); + } + + private record WorkloadResult(TuiToolBlock tool, int renderCount) { + } + + private record TimedWorkload(Duration duration, WorkloadResult result) { + } +} From e7becb522de03c239dc0f2d5a05a83de79ca0ec8 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 15:54:23 +0800 Subject: [PATCH 12/58] =?UTF-8?q?test(tool):=20=E5=9B=BA=E5=AE=9A=E6=9C=89?= =?UTF-8?q?=E9=99=90=E5=8D=95=E8=A1=8C=E4=BA=8B=E4=BB=B6=E6=91=98=E8=A6=81?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cn/lypi/tool/DefaultToolRuntimeTest.java | 99 ++++++++++++++++++- .../tool/ToolEventSummaryFormatterTest.java | 74 ++++++++++++++ .../lypi/tool/ToolLifecycleReporterTest.java | 52 ++++++++++ 3 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 lypi-tool/src/test/java/cn/lypi/tool/ToolEventSummaryFormatterTest.java diff --git a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java index b9facd48..fb632236 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java @@ -24,6 +24,7 @@ import cn.lypi.contracts.event.ToolEndEvent; import cn.lypi.contracts.event.ToolProgressEvent; import cn.lypi.contracts.event.ToolStartEvent; +import cn.lypi.contracts.mcp.McpToolSchema; import cn.lypi.contracts.runtime.ExecutionRequest; import cn.lypi.contracts.runtime.ExecutionResult; import cn.lypi.contracts.runtime.Executor; @@ -61,6 +62,7 @@ import cn.lypi.tool.builtin.ReadTool; import cn.lypi.tool.builtin.RequestPermissionsTool; import cn.lypi.tool.builtin.WriteTool; +import cn.lypi.tool.mcp.McpToolAdapter; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; @@ -135,9 +137,18 @@ void returnsErrorResultForUnknownTool() { void publishesLifecycleForUnknownTool() { RecordingEventBus events = new RecordingEventBus(); DefaultToolRuntime runtime = runtimeWithEvents(events, allowAllSecurity()); + String content = "TOP-SECRET" + "x".repeat(1_048_576 - "TOP-SECRET".length()); + Map input = Map.of( + "zzMode", "safe", + "nested", Map.of("first", 1, "second", 2), + "content", content, + "path", "none", + "zzItems", List.of("a", "b", "c"), + "zzEnabled", true + ); ToolResult result = runtime.execute( - List.of(new ToolUseRequest("toolu_1", "missing", Map.of("path", "none"), "msg_1")), + List.of(new ToolUseRequest("toolu_1", "missing", input, "msg_1")), TestTools.context(PermissionMode.DEFAULT_EXECUTE) ).getFirst(); @@ -150,8 +161,10 @@ void publishesLifecycleForUnknownTool() { assertEquals("msg_1", start.parentMessageId()); assertEquals("turn_1", start.turnId()); assertEquals("missing", start.toolName()); - assertEquals("missing {path=none}", start.inputSummary()); + assertEquals("missing content=<1048576 chars> nested=<2 fields> path=none", start.inputSummary()); + assertFalse(start.inputSummary().contains("TOP-SECRET")); assertEquals("none", start.inputMetadata().get("path")); + assertEquals(content, start.inputMetadata().get("content")); ToolEndEvent end = assertInstanceOf(ToolEndEvent.class, lifecycle.get(1)); assertEquals("toolu_1", end.toolUseId()); assertEquals(ToolExecutionStatus.FAILED, end.status()); @@ -160,6 +173,77 @@ void publishesLifecycleForUnknownTool() { assertTrue(end.durationMillis() >= 0); } + @Test + void publishesWriteSummaryWithoutContentBody() { + RecordingEventBus events = new RecordingEventBus(); + DefaultToolRuntime runtime = runtimeWithEvents(events, allowAllSecurity()); + runtime.register(new WriteTool()); + String path = tempDir.resolve("notes.txt").toString(); + String content = "PRIVATE-CONTENT\n".repeat(10_000); + + runtime.execute( + List.of(new ToolUseRequest( + "toolu_1", + "write", + Map.of("path", path, "content", content), + "msg_1" + )), + TestTools.context(PermissionMode.DEFAULT_EXECUTE) + ); + + ToolStartEvent start = assertInstanceOf(ToolStartEvent.class, lifecycleEvents(events).getFirst()); + assertEquals("write " + path, start.inputSummary()); + assertFalse(start.inputSummary().contains("PRIVATE-CONTENT")); + assertEquals(content, start.inputMetadata().get("content")); + } + + @Test + void publishesBoundedSingleLineBashSummary() { + RecordingEventBus events = new RecordingEventBus(); + DefaultToolRuntime runtime = runtimeWithEvents(events, allowAllSecurity()); + runtime.register(new BashTool(new RecordingExecutor(new ExecutionResult(0, "", "", false, Optional.empty())))); + String command = "printf 'one\ntwo'\r\n" + "🙂".repeat(200); + + runtime.execute( + List.of(new ToolUseRequest("toolu_1", "bash", Map.of("command", command), "msg_1")), + TestTools.context(PermissionMode.DEFAULT_EXECUTE) + ); + + ToolStartEvent start = assertInstanceOf(ToolStartEvent.class, lifecycleEvents(events).getFirst()); + assertSingleLineInputSummary(start); + assertTrue(start.inputSummary().startsWith("bash printf 'one two'")); + assertEquals(command, start.inputMetadata().get("command")); + } + + @Test + void publishesBoundedSingleLineMcpSummaryForNestedInput() { + RecordingEventBus events = new RecordingEventBus(); + DefaultToolRuntime runtime = runtimeWithEvents(events, allowAllSecurity()); + runtime.register(new McpToolAdapter( + new McpToolSchema("filesystem", "read_file", "", new JsonSchema(Map.of()), ""), + (serverName, toolName, arguments, context, progress) -> "ok" + )); + Map nested = Map.of( + "content", "line-one\nline-two " + "x".repeat(300), + "options", List.of(Map.of("enabled", true)) + ); + + runtime.execute( + List.of(new ToolUseRequest( + "toolu_1", + "mcp__filesystem__read_file", + Map.of("path", "README.md", "nested", nested), + "msg_1" + )), + TestTools.context(PermissionMode.DEFAULT_EXECUTE) + ); + + ToolStartEvent start = assertInstanceOf(ToolStartEvent.class, lifecycleEvents(events).getFirst()); + assertSingleLineInputSummary(start); + assertTrue(start.inputSummary().startsWith("mcp__filesystem__read_file ")); + assertEquals(nested, start.inputMetadata().get("nested")); + } + @Test void passesCanonicalToolNameToSecurityRuntimeWhenCalledByAlias() { AtomicReference securityToolName = new AtomicReference<>(); @@ -1576,7 +1660,7 @@ void publishesEndWithOutputRefForBudgetedLongOutput() { assertEquals("", end.resultRef().location()); assertEquals(16L, end.resultRef().byteLength()); assertTrue(end.resultRef().contentHash().startsWith("sha256:")); - assertEquals("0123456789ab", end.resultRef().metadata().get("preview")); + assertEquals("0123456789abcdef", end.resultRef().metadata().get("preview")); assertEquals("budgeted", end.resultRef().metadata().get("truncationReason")); assertFalse(end.resultRef().metadata().containsKey("replacementPath")); assertFalse(end.resultRef().metadata().containsKey("replacementPreview")); @@ -2469,6 +2553,15 @@ private List lifecycleEvents(RecordingEventBus events) { .toList(); } + private void assertSingleLineInputSummary(ToolStartEvent start) { + assertFalse(start.inputSummary().contains("\r")); + assertFalse(start.inputSummary().contains("\n")); + assertTrue( + start.inputSummary().codePointCount(0, start.inputSummary().length()) + <= ToolEventSummaryFormatter.INPUT_MAX_CODE_POINTS + ); + } + private T assertToolLifecycle(AgentEvent event, Class type, String toolUseId) { T typed = assertInstanceOf(type, event); if (typed instanceof ToolStartEvent start) { diff --git a/lypi-tool/src/test/java/cn/lypi/tool/ToolEventSummaryFormatterTest.java b/lypi-tool/src/test/java/cn/lypi/tool/ToolEventSummaryFormatterTest.java new file mode 100644 index 00000000..b6804b23 --- /dev/null +++ b/lypi-tool/src/test/java/cn/lypi/tool/ToolEventSummaryFormatterTest.java @@ -0,0 +1,74 @@ +package cn.lypi.tool; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; + +class ToolEventSummaryFormatterTest { + private final ToolEventSummaryFormatter formatter = new ToolEventSummaryFormatter(); + + @Test + void normalizesAndTruncatesRenderedInputAtUnicodeCodePointBoundary() { + String rendered = "bash printf 'one\ntwo'\r\nthen-more-content\u0000 " + "🙂".repeat(200); + + String summary = formatter.inputSummary("bash", rendered, Map.of("command", rendered)); + + assertTrue(summary.startsWith("bash printf 'one two' then-more-content")); + assertFalse(summary.contains("\r")); + assertFalse(summary.contains("\n")); + assertFalse(summary.contains("\u0000")); + assertFalse(summary.contains(" ")); + assertTrue(summary.endsWith("🙂…")); + assertTrue(summary.codePointCount(0, summary.length()) <= ToolEventSummaryFormatter.INPUT_MAX_CODE_POINTS); + assertEquals(1L, summary.codePoints().filter(codePoint -> codePoint == '…').count()); + } + + @Test + void summarizesOnlyThreeDeterministicallySortedFieldsForUnknownTools() { + String content = "TOP-SECRET" + "x".repeat(1_048_576 - "TOP-SECRET".length()); + Map input = Map.of( + "zzMode", "safe", + "nested", Map.of("first", 1, "second", 2), + "content", content, + "path", "README.md", + "zzItems", java.util.List.of("a", "b", "c"), + "zzEnabled", true + ); + + String summary = formatter.inputSummary("mystery", null, input); + + assertEquals("mystery content=<1048576 chars> nested=<2 fields> path=README.md", summary); + assertFalse(summary.contains("TOP-SECRET")); + assertFalse(summary.contains("zzEnabled")); + assertFalse(summary.contains("zzItems")); + assertFalse(summary.contains("zzMode")); + assertFalse(summary.contains("{")); + } + + @Test + void boundsMultilineResultSummaryAndPreviewWhileReportingHiddenLines() { + String output = IntStream.rangeClosed(1, 20) + .mapToObj(line -> "line-" + line + " " + "🙂".repeat(20)) + .collect(Collectors.joining("\r\n")) + + "x".repeat(300); + + String summary = formatter.resultSummary(output); + String preview = formatter.preview(output); + + assertSingleLineWithHiddenLineCount(summary, ToolEventSummaryFormatter.RESULT_MAX_CODE_POINTS); + assertSingleLineWithHiddenLineCount(preview, ToolEventSummaryFormatter.PREVIEW_MAX_CODE_POINTS); + } + + private void assertSingleLineWithHiddenLineCount(String value, int maxCodePoints) { + assertTrue(value.startsWith("line-1")); + assertFalse(value.contains("\r")); + assertFalse(value.contains("\n")); + assertTrue(value.endsWith("(+19 lines)")); + assertTrue(value.codePointCount(0, value.length()) <= maxCodePoints); + } +} diff --git a/lypi-tool/src/test/java/cn/lypi/tool/ToolLifecycleReporterTest.java b/lypi-tool/src/test/java/cn/lypi/tool/ToolLifecycleReporterTest.java index 898e3004..ee8842e2 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/ToolLifecycleReporterTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/ToolLifecycleReporterTest.java @@ -1,6 +1,7 @@ package cn.lypi.tool; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -20,6 +21,8 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.junit.jupiter.api.Test; class ToolLifecycleReporterTest { @@ -40,6 +43,7 @@ void publishesStartAndBudgetedEndWithOriginalToolMetadata() { context, "bash", "sh", + "bash echo hello", request.input() ); ToolResult raw = TestTools.result("toolu_1", "0123456789abcdef", false); @@ -49,6 +53,7 @@ void publishesStartAndBudgetedEndWithOriginalToolMetadata() { ToolStartEvent start = (ToolStartEvent) events.events.get(0); assertEquals("bash", start.toolName()); + assertEquals("bash echo hello", start.inputSummary()); assertEquals("sh", start.inputMetadata().get("originalToolName")); ToolEndEvent end = (ToolEndEvent) events.events.get(1); assertEquals(ToolExecutionStatus.SUCCEEDED, end.status()); @@ -67,6 +72,53 @@ void omitsResultRefWhenResultIsNotBudgeted() { assertNull(reporter.resultRef("ses_1", "toolu_1", "read", result, false)); } + @Test + void publishesBoundedSingleLineSummariesAndPreview() { + RecordingEventBus events = new RecordingEventBus(); + ToolLifecycleReporter reporter = new ToolLifecycleReporter(ToolExecutionEventPublisher.eventBus(events)); + String rendered = "bash printf 'one\ntwo'\r\n" + "🙂".repeat(200); + ToolUseRequest request = new ToolUseRequest("toolu_1", "bash", Map.of("command", rendered), "msg_1"); + ToolUseContext context = new ToolUseContext( + "ses_1", + "msg_1", + Path.of("/workspace"), + Map.of("turnId", "turn_1") + ); + String output = IntStream.rangeClosed(1, 20) + .mapToObj(line -> "line-" + line + " " + "🙂".repeat(20)) + .collect(Collectors.joining("\n")); + + ToolExecutionEventPublisher.StartedToolExecution started = reporter.start( + request, + context, + "bash", + "bash", + rendered, + request.input() + ); + ToolResult raw = TestTools.result("toolu_1", output, false); + ToolResult budgeted = new ToolResultBudgeter().apply("toolu_1", "bash", raw, 4); + reporter.end(request, context, "bash", "bash", raw, budgeted, ToolExecutionStatus.SUCCEEDED, started.startedAt()); + + ToolStartEvent start = (ToolStartEvent) events.events.get(0); + assertFalse(start.inputSummary().contains("\r")); + assertFalse(start.inputSummary().contains("\n")); + assertTrue(codePointCount(start.inputSummary()) <= ToolEventSummaryFormatter.INPUT_MAX_CODE_POINTS); + + ToolEndEvent end = (ToolEndEvent) events.events.get(1); + assertFalse(end.resultSummary().summary().contains("\n")); + assertTrue(end.resultSummary().summary().endsWith("(+19 lines)")); + assertTrue(codePointCount(end.resultSummary().summary()) <= ToolEventSummaryFormatter.RESULT_MAX_CODE_POINTS); + String preview = end.resultRef().metadata().get("preview").toString(); + assertFalse(preview.contains("\n")); + assertTrue(preview.endsWith("(+19 lines)")); + assertTrue(codePointCount(preview) <= ToolEventSummaryFormatter.PREVIEW_MAX_CODE_POINTS); + } + + private int codePointCount(String value) { + return value.codePointCount(0, value.length()); + } + private static final class RecordingEventBus implements EventBus { private final List events = new CopyOnWriteArrayList<>(); From 5623b3f9a2742da4c464ebf5290ad120ec8df716 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 15:58:43 +0800 Subject: [PATCH 13/58] =?UTF-8?q?fix(tool):=20=E7=94=9F=E6=88=90=E6=9C=89?= =?UTF-8?q?=E9=99=90=E5=8D=95=E8=A1=8C=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/cn/lypi/tool/DefaultToolRuntime.java | 11 ++ .../lypi/tool/ToolEventSummaryFormatter.java | 156 ++++++++++++++++++ .../cn/lypi/tool/ToolLifecycleReporter.java | 28 +--- 3 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 lypi-tool/src/main/java/cn/lypi/tool/ToolEventSummaryFormatter.java diff --git a/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java b/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java index d3160cfd..b356ee56 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/DefaultToolRuntime.java @@ -546,6 +546,7 @@ private ToolResult executeUnknownCall( toolContext, toolName, toolName, + null, input ); ToolResult finalResult = null; @@ -578,11 +579,13 @@ private ToolResult executeStartedCall( ToolUseContext toolContext, TurnPermissionState turnState ) { + String renderedForUser = safeRenderForUser(tool, input); ToolExecutionEventPublisher.StartedToolExecution started = lifecycleReporter.start( request, toolContext, toolName, originalToolName, + renderedForUser, input ); ToolResult rawResult = null; @@ -648,6 +651,14 @@ private ToolResult executeStartedCall( } } + private String safeRenderForUser(Tool, ?> tool, Map input) { + try { + return tool.renderForUser(input); + } catch (RuntimeException exception) { + return null; + } + } + private ToolExecutionStatus statusForGateResult(PermissionGateResult result) { return result.status() == PermissionGateResult.Status.ABORT ? ToolExecutionStatus.CANCELLED diff --git a/lypi-tool/src/main/java/cn/lypi/tool/ToolEventSummaryFormatter.java b/lypi-tool/src/main/java/cn/lypi/tool/ToolEventSummaryFormatter.java new file mode 100644 index 00000000..203fb9a7 --- /dev/null +++ b/lypi-tool/src/main/java/cn/lypi/tool/ToolEventSummaryFormatter.java @@ -0,0 +1,156 @@ +package cn.lypi.tool; + +import java.lang.reflect.Array; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class ToolEventSummaryFormatter { + static final int INPUT_MAX_CODE_POINTS = 160; + static final int RESULT_MAX_CODE_POINTS = 200; + static final int PREVIEW_MAX_CODE_POINTS = 80; + + private static final int MAX_INPUT_FIELDS = 3; + private static final int MAX_SCALAR_CODE_POINTS = 64; + private static final Pattern LINE_BREAK = Pattern.compile("\\R"); + + String inputSummary(String toolName, String renderedForUser, Map input) { + String rendered = normalizeSingleLine(renderedForUser); + String candidate = rendered.isEmpty() ? genericInputSummary(toolName, input) : rendered; + return truncate(candidate, INPUT_MAX_CODE_POINTS); + } + + String resultSummary(String outputText) { + return outputSummary(outputText, RESULT_MAX_CODE_POINTS); + } + + String preview(String outputText) { + return outputSummary(outputText, PREVIEW_MAX_CODE_POINTS); + } + + private String genericInputSummary(String toolName, Map input) { + String normalizedToolName = normalizeSingleLine(toolName); + StringBuilder summary = new StringBuilder(normalizedToolName.isEmpty() ? "tool" : normalizedToolName); + if (input == null || input.isEmpty()) { + return summary.toString(); + } + List keys = input.keySet().stream() + .filter(Objects::nonNull) + .sorted() + .limit(MAX_INPUT_FIELDS) + .toList(); + for (String key : keys) { + summary.append(' ') + .append(normalizeSingleLine(key)) + .append('=') + .append(displayValue(input.get(key))); + } + return normalizeSingleLine(summary.toString()); + } + + private String displayValue(Object value) { + if (value == null) { + return "null"; + } + if (value instanceof String text) { + int codePoints = codePointCount(text); + return codePoints <= MAX_SCALAR_CODE_POINTS + ? normalizeSingleLine(text) + : "<" + codePoints + " chars>"; + } + if (value instanceof Number || value instanceof Boolean) { + return value.toString(); + } + if (value instanceof Map map) { + return "<" + map.size() + " fields>"; + } + if (value instanceof Collection collection) { + return "<" + collection.size() + " items>"; + } + if (value.getClass().isArray()) { + return "<" + Array.getLength(value) + " items>"; + } + return "<" + value.getClass().getSimpleName() + ">"; + } + + private String outputSummary(String outputText, int maxCodePoints) { + String normalized = normalizeSingleLine(outputText); + int hiddenLines = lineBreakCount(outputText); + if (hiddenLines == 0) { + return truncate(normalized, maxCodePoints); + } + String suffix = " (+" + hiddenLines + " lines)"; + if (codePointCount(normalized) + codePointCount(suffix) <= maxCodePoints) { + return normalized + suffix; + } + int available = maxCodePoints - codePointCount(suffix) - 1; + if (available <= 0) { + return truncate(suffix.strip(), maxCodePoints); + } + return prefix(normalized, available) + "…" + suffix; + } + + private String normalizeSingleLine(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + StringBuilder normalized = new StringBuilder(value.length()); + boolean pendingSpace = false; + for (int offset = 0; offset < value.length();) { + int codePoint = value.codePointAt(offset); + offset += Character.charCount(codePoint); + if (Character.isISOControl(codePoint) + || Character.isWhitespace(codePoint) + || Character.isSpaceChar(codePoint)) { + pendingSpace = normalized.length() > 0; + continue; + } + if (pendingSpace) { + normalized.append(' '); + pendingSpace = false; + } + normalized.appendCodePoint(codePoint); + } + return normalized.toString(); + } + + private String truncate(String value, int maxCodePoints) { + if (value == null || maxCodePoints <= 0) { + return ""; + } + if (codePointCount(value) <= maxCodePoints) { + return value; + } + if (maxCodePoints == 1) { + return "…"; + } + return prefix(value, maxCodePoints - 1) + "…"; + } + + private String prefix(String value, int codePoints) { + if (value == null || value.isEmpty() || codePoints <= 0) { + return ""; + } + int count = Math.min(codePoints, codePointCount(value)); + return value.substring(0, value.offsetByCodePoints(0, count)); + } + + private int lineBreakCount(String value) { + if (value == null || value.isEmpty()) { + return 0; + } + int count = 0; + Matcher matcher = LINE_BREAK.matcher(value); + while (matcher.find()) { + count++; + } + return count; + } + + private int codePointCount(String value) { + return value == null ? 0 : value.codePointCount(0, value.length()); + } +} diff --git a/lypi-tool/src/main/java/cn/lypi/tool/ToolLifecycleReporter.java b/lypi-tool/src/main/java/cn/lypi/tool/ToolLifecycleReporter.java index 9bd58468..7b4e6db8 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/ToolLifecycleReporter.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/ToolLifecycleReporter.java @@ -26,12 +26,13 @@ final class ToolLifecycleReporter { private static final String METADATA_ORIGINAL_TOOL_NAME = "originalToolName"; private static final String METADATA_TURN_ID = "turnId"; - private static final int OUTPUT_REF_PREVIEW_CHARS = 12; private final ToolExecutionEventPublisher eventPublisher; + private final ToolEventSummaryFormatter summaryFormatter; ToolLifecycleReporter(ToolExecutionEventPublisher eventPublisher) { this.eventPublisher = eventPublisher == null ? ToolExecutionEventPublisher.noop() : eventPublisher; + this.summaryFormatter = new ToolEventSummaryFormatter(); } ToolExecutionEventPublisher.StartedToolExecution start( @@ -39,6 +40,7 @@ ToolExecutionEventPublisher.StartedToolExecution start( ToolUseContext context, String toolName, String originalToolName, + String renderedForUser, Map input ) { return eventPublisher.start( @@ -48,7 +50,7 @@ ToolExecutionEventPublisher.StartedToolExecution start( stringMetadata(context, METADATA_TURN_ID), toolName, displayTitle(toolName), - inputSummary(toolName, input), + summaryFormatter.inputSummary(toolName, renderedForUser, input), inputMetadata(input, toolName, originalToolName) ); } @@ -97,7 +99,7 @@ ToolResultSummary resultSummary( boolean error = status != ToolExecutionStatus.SUCCEEDED || result == null || result.isError(); return new ToolResultSummary( toolName + " " + status.name().toLowerCase(), - summarize(outputText), + summaryFormatter.resultSummary(outputText), error, exitCode(result), status == ToolExecutionStatus.TIMED_OUT, @@ -122,7 +124,7 @@ ToolOutputRef resultRef( } Map metadata = new LinkedHashMap<>(); metadata.put("toolName", toolName); - metadata.put("preview", preview(outputText)); + metadata.put("preview", summaryFormatter.preview(outputText)); if (budgeted) { metadata.put("truncated", true); metadata.put("truncationReason", "budgeted"); @@ -193,24 +195,6 @@ private String displayTitle(String toolName) { return Character.toUpperCase(toolName.charAt(0)) + toolName.substring(1); } - private String inputSummary(String toolName, Map input) { - return toolName + " " + input; - } - - private String summarize(String outputText) { - if (outputText == null || outputText.isBlank()) { - return ""; - } - return outputText.length() <= 200 ? outputText : outputText.substring(0, 200); - } - - private String preview(String outputText) { - if (outputText == null || outputText.isEmpty()) { - return ""; - } - return outputText.substring(0, Math.min(OUTPUT_REF_PREVIEW_CHARS, outputText.length())); - } - private long byteLength(String outputText) { return outputText == null ? 0L : outputText.getBytes(StandardCharsets.UTF_8).length; } From faac33e0a3eae20da96b445c0bb5dafd62c7c481 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 16:03:24 +0800 Subject: [PATCH 14/58] =?UTF-8?q?fix(tool):=20=E6=94=B6=E7=B4=A7=E5=86=85?= =?UTF-8?q?=E5=BB=BA=E4=B8=8E=20MCP=20=E8=B0=83=E7=94=A8=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lypi/tool/ToolEventSummaryFormatter.java | 13 +++++++--- .../java/cn/lypi/tool/builtin/GlobTool.java | 14 ++++++++++- .../java/cn/lypi/tool/builtin/ReadTool.java | 21 +++++++++++++++- .../java/cn/lypi/tool/mcp/McpToolAdapter.java | 3 ++- .../cn/lypi/tool/DefaultToolRuntimeTest.java | 2 +- .../cn/lypi/tool/builtin/GlobToolTest.java | 12 ++++++++++ .../cn/lypi/tool/builtin/ReadToolTest.java | 11 +++++++++ .../cn/lypi/tool/mcp/McpToolAdapterTest.java | 24 +++++++++++++++++++ 8 files changed, 93 insertions(+), 7 deletions(-) diff --git a/lypi-tool/src/main/java/cn/lypi/tool/ToolEventSummaryFormatter.java b/lypi-tool/src/main/java/cn/lypi/tool/ToolEventSummaryFormatter.java index 203fb9a7..4fdeb09d 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/ToolEventSummaryFormatter.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/ToolEventSummaryFormatter.java @@ -8,7 +8,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -final class ToolEventSummaryFormatter { +public final class ToolEventSummaryFormatter { static final int INPUT_MAX_CODE_POINTS = 160; static final int RESULT_MAX_CODE_POINTS = 200; static final int PREVIEW_MAX_CODE_POINTS = 80; @@ -17,9 +17,16 @@ final class ToolEventSummaryFormatter { private static final int MAX_SCALAR_CODE_POINTS = 64; private static final Pattern LINE_BREAK = Pattern.compile("\\R"); + ToolEventSummaryFormatter() { + } + + public static String genericInputSummary(String toolName, Map input) { + return new ToolEventSummaryFormatter().inputSummary(toolName, null, input); + } + String inputSummary(String toolName, String renderedForUser, Map input) { String rendered = normalizeSingleLine(renderedForUser); - String candidate = rendered.isEmpty() ? genericInputSummary(toolName, input) : rendered; + String candidate = rendered.isEmpty() ? buildGenericInputSummary(toolName, input) : rendered; return truncate(candidate, INPUT_MAX_CODE_POINTS); } @@ -31,7 +38,7 @@ String preview(String outputText) { return outputSummary(outputText, PREVIEW_MAX_CODE_POINTS); } - private String genericInputSummary(String toolName, Map input) { + private String buildGenericInputSummary(String toolName, Map input) { String normalizedToolName = normalizeSingleLine(toolName); StringBuilder summary = new StringBuilder(normalizedToolName.isEmpty() ? "tool" : normalizedToolName); if (input == null || input.isEmpty()) { diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/GlobTool.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/GlobTool.java index b5949a4d..e8f48a23 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/GlobTool.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/GlobTool.java @@ -106,7 +106,19 @@ public boolean isDestructive(Map input) { @Override public String renderForUser(Map input) { - return "glob " + input; + if (input == null) { + return "glob"; + } + StringBuilder summary = new StringBuilder("glob"); + Object pattern = input.get("pattern"); + if (pattern != null && !pattern.toString().isBlank()) { + summary.append(' ').append(pattern); + } + Object path = input.get("path"); + if (path != null && !path.toString().isBlank()) { + summary.append(" in ").append(path); + } + return summary.toString(); } private boolean ignored(Path path) { diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/ReadTool.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/ReadTool.java index 8d6ea733..ba8e25d7 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/ReadTool.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/ReadTool.java @@ -164,6 +164,25 @@ private ToolResult imageSuccess(String toolUseId, String fileName, byte[ @Override public String renderForUser(Map input) { - return "read " + input; + if (input == null) { + return "read"; + } + StringBuilder summary = new StringBuilder("read"); + Object path = input.get("path"); + if (path != null && !path.toString().isBlank()) { + summary.append(' ').append(path); + } + Object offsetValue = input.get("offset"); + Object limitValue = input.get("limit"); + if (offsetValue instanceof Number offset && limitValue instanceof Number limit) { + long firstLine = offset.longValue(); + long lastLine = firstLine + limit.longValue() - 1; + summary.append(" lines ").append(firstLine).append('-').append(lastLine); + } else if (offsetValue instanceof Number offset) { + summary.append(" from line ").append(offset.longValue()); + } else if (limitValue instanceof Number limit) { + summary.append(" first ").append(limit.longValue()).append(" lines"); + } + return summary.toString(); } } diff --git a/lypi-tool/src/main/java/cn/lypi/tool/mcp/McpToolAdapter.java b/lypi-tool/src/main/java/cn/lypi/tool/mcp/McpToolAdapter.java index bcbd9e49..4f88f5a9 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/mcp/McpToolAdapter.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/mcp/McpToolAdapter.java @@ -17,6 +17,7 @@ import cn.lypi.contracts.tool.Tool; import cn.lypi.contracts.tool.ToolResult; import cn.lypi.contracts.tool.ToolUseContext; +import cn.lypi.tool.ToolEventSummaryFormatter; import java.time.Instant; import java.util.List; import java.util.Map; @@ -114,7 +115,7 @@ public int maxResultSize() { @Override public String renderForUser(Map input) { - return name + " " + (input == null ? Map.of() : input); + return ToolEventSummaryFormatter.genericInputSummary("mcp " + schema.toolName(), input); } @Override diff --git a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java index fb632236..3e587968 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java @@ -240,7 +240,7 @@ void publishesBoundedSingleLineMcpSummaryForNestedInput() { ToolStartEvent start = assertInstanceOf(ToolStartEvent.class, lifecycleEvents(events).getFirst()); assertSingleLineInputSummary(start); - assertTrue(start.inputSummary().startsWith("mcp__filesystem__read_file ")); + assertTrue(start.inputSummary().startsWith("mcp read_file ")); assertEquals(nested, start.inputMetadata().get("nested")); } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/GlobToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/GlobToolTest.java index a4a58e03..0e9cee73 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/GlobToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/GlobToolTest.java @@ -1,5 +1,6 @@ package cn.lypi.tool.builtin; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -149,6 +150,17 @@ void isReadOnlyAndConcurrencySafe() { assertFalse(tool.isDestructive(Map.of())); } + @Test + void rendersOnlyPatternAndOptionalBasePath() { + GlobTool tool = new GlobTool(); + + assertEquals( + "glob **/*.java in src", + tool.renderForUser(Map.of("pattern", "**/*.java", "path", "src", "maxResults", 500)) + ); + assertEquals("glob **/*.java", tool.renderForUser(Map.of("pattern", "**/*.java"))); + } + private ToolUseContext context() { return new ToolUseContext("ses_1", "msg_1", tempDir, Map.of("toolUseId", "toolu_1")); } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/ReadToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/ReadToolTest.java index 9008a570..60ff708e 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/ReadToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/ReadToolTest.java @@ -151,6 +151,17 @@ void exposesReadOnlyConcurrencySafeMetadata() { assertEquals(PermissionBehavior.ALLOW, tool.checkPermissions(Map.of("path", "notes.txt"), context()).behavior()); } + @Test + void rendersOnlyPathAndRequestedLineRange() { + ReadTool tool = new ReadTool(); + + assertEquals( + "read AGENTS.md lines 10-30", + tool.renderForUser(Map.of("path", "AGENTS.md", "offset", 10, "limit", 21, "content", "ignored")) + ); + assertEquals("read AGENTS.md", tool.renderForUser(Map.of("path", "AGENTS.md"))); + } + private ToolUseContext context() { return new ToolUseContext("ses_1", "msg_1", tempDir, Map.of("toolUseId", "toolu_1")); } diff --git a/lypi-tool/src/test/java/cn/lypi/tool/mcp/McpToolAdapterTest.java b/lypi-tool/src/test/java/cn/lypi/tool/mcp/McpToolAdapterTest.java index 7f7148f3..dae1b063 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/mcp/McpToolAdapterTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/mcp/McpToolAdapterTest.java @@ -105,6 +105,30 @@ void alwaysRequiresPermissionConfirmation() { assertFalse(adapter.isConcurrencySafe(Map.of())); } + @Test + void rendersOnlyThreeFiniteInputShapes() { + McpToolAdapter adapter = new McpToolAdapter( + new McpToolSchema("filesystem", "read_file", "", new JsonSchema(Map.of()), ""), + (serverName, toolName, arguments, context, progress) -> "ok" + ); + String content = "SENSITIVE" + "x".repeat(4_096 - "SENSITIVE".length()); + + String rendered = adapter.renderForUser(Map.of( + "zzItems", List.of("one", "two"), + "path", "README.md", + "nested", Map.of("first", 1, "second", 2), + "content", content + )); + + assertEquals( + "mcp read_file content=<4096 chars> nested=<2 fields> path=README.md", + rendered + ); + assertFalse(rendered.contains("SENSITIVE")); + assertFalse(rendered.contains("zzItems")); + assertFalse(rendered.contains("{")); + } + private ToolUseContext context() { return new ToolUseContext("ses_1", "msg_1", Path.of("."), Map.of("toolUseId", "toolu_1")); } From d77561455e6bbe3c992aa6ffa4d7c0f9225de26c Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 16:11:53 +0800 Subject: [PATCH 15/58] =?UTF-8?q?test(tui):=20=E5=9B=BA=E5=AE=9A=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=8A=98=E5=8F=A0=E4=B8=8E=E5=B1=95=E5=BC=80=E8=A1=8C?= =?UTF-8?q?=E9=A2=84=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tui/ToolDisplayRendererRegistryTest.java | 93 +++++++++++++++++++ .../tui/TuiContractEndToEndTest.java | 59 ++++++++++++ .../lypi/transport/tui/TuiRendererTest.java | 57 ++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/ToolDisplayRendererRegistryTest.java diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/ToolDisplayRendererRegistryTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/ToolDisplayRendererRegistryTest.java new file mode 100644 index 00000000..a3ad9871 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/ToolDisplayRendererRegistryTest.java @@ -0,0 +1,93 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import cn.lypi.contracts.tui.TuiToolBlock; +import cn.lypi.contracts.tui.TuiToolState; +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; + +class ToolDisplayRendererRegistryTest { + private static final List TOOL_NAMES = List.of( + "bash", + "read", + "write", + "edit", + "grep", + "glob", + "unknown_tool", + "mcp__filesystem__read_file" + ); + private static final List DETAIL_RENDERING_TOOLS = List.of( + "bash", + "write", + "edit", + "unknown_tool", + "mcp__filesystem__read_file" + ); + + private final ToolDisplayRendererRegistry registry = ToolDisplayRendererRegistry.defaults(); + + @Test + void everyCollapsedRendererFitsFiveLineBudgetIncludingOmissionMarker() { + ToolDisplayBudget budget = ToolDisplayBudget.collapsed(); + + for (String toolName : TOOL_NAMES) { + ToolDisplayModel model = registry.render(block(toolName), false, budget); + + assertTrue(modelLineCount(model) <= 5, toolName + " exceeded collapsed budget"); + if (DETAIL_RENDERING_TOOLS.contains(toolName)) { + assertTrue(hasOmissionMarker(model), toolName + " omitted details without a marker"); + } + } + } + + @Test + void everyExpandedRendererFitsFortyLineBudgetAndPreservesHeadOrTailSemantics() { + ToolDisplayBudget budget = ToolDisplayBudget.expanded(100); + + for (String toolName : TOOL_NAMES) { + ToolDisplayModel model = registry.render(block(toolName), true, budget); + + assertTrue(modelLineCount(model) <= 40, toolName + " exceeded expanded budget"); + if (!DETAIL_RENDERING_TOOLS.contains(toolName)) { + continue; + } + assertTrue(hasOmissionMarker(model), toolName + " omitted details without a marker"); + if ("bash".equals(toolName)) { + assertTrue(model.previewLines().getFirst().contains("earlier lines")); + assertEquals("line 100", model.previewLines().getLast()); + } else { + assertEquals("line 1", model.previewLines().getFirst()); + assertTrue(model.previewLines().getLast().contains("more lines")); + } + } + } + + private TuiToolBlock block(String toolName) { + String details = String.join("\n", IntStream.rangeClosed(1, 100) + .mapToObj(index -> "line " + index) + .toList()); + return new TuiToolBlock( + "tool:" + toolName, + "msg_1", + "toolu_" + toolName, + toolName, + TuiToolState.DONE, + "call " + toolName, + details, + false + ); + } + + private int modelLineCount(ToolDisplayModel model) { + return 1 + model.summaryLines().size() + model.previewLines().size(); + } + + private boolean hasOmissionMarker(ToolDisplayModel model) { + return model.previewLines().stream() + .anyMatch(line -> line.contains("more lines") || line.contains("earlier lines")); + } +} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java index 419f4632..7673aafb 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java @@ -41,6 +41,7 @@ import cn.lypi.contracts.tool.ToolOutputRef; import cn.lypi.contracts.tool.ToolResultSummary; import cn.lypi.contracts.tui.PermissionPromptView; +import cn.lypi.contracts.tui.StatusBarState; import cn.lypi.contracts.tui.TuiBlock; import cn.lypi.contracts.tui.TuiMessageBlock; import cn.lypi.contracts.tui.TuiThinkingBlock; @@ -284,6 +285,64 @@ void restoredSessionPointerDoesNotSmuggleRecentFilesPermissionsOrToolsIntoView() assertTrue(view.diffView().isEmpty()); } + @Test + void toolLifecycleDetailsRespectCollapsedAndExpandedDisplayBudgets() { + TuiEventReducer reducer = new TuiEventReducer(); + reducer.reduce(new ToolStartEvent( + "ses_1", + "toolu_bash", + "msg_1", + "turn_1", + "bash", + "Run shell", + "bash mvn test", + Map.of("command", "mvn test"), + NOW, + NOW + )); + String output = String.join("\n", java.util.stream.IntStream.rangeClosed(1, 100) + .mapToObj(index -> "line " + index) + .toList()); + reducer.reduce(new ToolProgressEvent( + "ses_1", + "toolu_bash", + ToolProgress.output("stdout", output), + NOW.plusMillis(1) + )); + TuiViewModel view = new TuiViewModel( + reducer.view().blocks(), + new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), + List.of(), + Optional.empty(), + Optional.empty() + ); + TuiRenderer renderer = new TuiRenderer(); + + TuiRenderFrame collapsed = renderer.renderFrame( + view, + new TuiScreen(120), + new TuiLayout(80, 120), + "", + -1, + List.of(), + false + ); + TuiRenderFrame expanded = renderer.renderFrame( + view, + new TuiScreen(120), + new TuiLayout(80, 120), + "", + -1, + List.of(), + true + ); + + assertTrue(collapsed.transcriptLineCount() <= 5); + assertTrue(expanded.transcriptLineCount() <= 40); + assertTrue(collapsed.lines().stream().anyMatch(line -> line.contains("earlier lines"))); + assertTrue(expanded.lines().stream().anyMatch(line -> line.contains("earlier lines"))); + } + private static MessageDeltaEvent delta( String messageId, String blockId, diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java index b1ab0978..8b488aad 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java @@ -8,6 +8,7 @@ import cn.lypi.contracts.tui.GitDiffFileView; import cn.lypi.contracts.tui.GitDiffStatus; import cn.lypi.contracts.tui.StatusBarState; +import cn.lypi.contracts.tui.TuiBlock; import cn.lypi.contracts.security.PermissionOption; import cn.lypi.contracts.security.PermissionOptionKind; import cn.lypi.contracts.security.PermissionBehavior; @@ -960,6 +961,62 @@ void expandedToolOutputIsBoundedByAvailableTerminalRows() { assertTrue(lines.getLast().contains("ses_1")); } + @Test + void collapsedAndExpandedToolFramesShareGlobalTranscriptBudget() { + String details = String.join("\n", java.util.stream.IntStream.rangeClosed(1, 100) + .mapToObj(index -> "detail line " + index) + .toList()); + List tools = java.util.stream.IntStream.range(0, 10) + .mapToObj(index -> (TuiBlock) new TuiToolBlock( + "tool:" + index, + "msg_1", + "toolu_" + index, + "custom_tool", + TuiToolState.DONE, + "call " + index, + details, + false + )) + .toList(); + TuiViewModel view = new TuiViewModel( + tools, + new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), + List.of(), + Optional.empty(), + Optional.empty() + ); + TuiRenderer renderer = new TuiRenderer(); + + TuiRenderFrame collapsed = renderer.renderFrame( + view, + new TuiScreen(12), + new TuiLayout(80, 12), + "", + -1, + List.of(), + false + ); + TuiRenderFrame expanded = renderer.renderFrame( + view, + new TuiScreen(12), + new TuiLayout(80, 12), + "", + -1, + List.of(), + true + ); + + assertFrameFitsHeightWithOmissionMarker(collapsed, 12); + assertFrameFitsHeightWithOmissionMarker(expanded, 12); + } + + private void assertFrameFitsHeightWithOmissionMarker(TuiRenderFrame frame, int height) { + assertTrue(frame.lines().size() <= height); + assertTrue(frame.transcriptLineCount() <= height - frame.chromeLineCount()); + assertTrue(frame.lines().stream() + .anyMatch(line -> line.contains("more lines") || line.contains("earlier lines"))); + } + @Test void slashOverlayRendersBelowInputBlock() { TuiRenderer renderer = new TuiRenderer(); From 36ba1e4214897bea16fd7d65f5aee080cf6cefab Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 16:15:45 +0800 Subject: [PATCH 16/58] =?UTF-8?q?fix(tui):=20=E7=BB=9F=E4=B8=80=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=8A=98=E5=8F=A0=E4=B8=8E=E5=B1=95=E5=BC=80=E9=A2=84?= =?UTF-8?q?=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lypi/transport/tui/ToolDisplayBudget.java | 21 ++++++ .../transport/tui/ToolDisplayRenderer.java | 6 +- .../tui/ToolDisplayRendererRegistry.java | 66 ++++++++----------- .../cn/lypi/transport/tui/TuiRenderer.java | 19 ++++-- 4 files changed, 62 insertions(+), 50 deletions(-) create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/ToolDisplayBudget.java diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/ToolDisplayBudget.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/ToolDisplayBudget.java new file mode 100644 index 00000000..ad3846af --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/ToolDisplayBudget.java @@ -0,0 +1,21 @@ +package cn.lypi.transport.tui; + +record ToolDisplayBudget(int totalLines, int detailLines) { + ToolDisplayBudget { + if (totalLines < 1) { + throw new IllegalArgumentException("totalLines must be positive"); + } + if (detailLines < 0 || detailLines >= totalLines) { + throw new IllegalArgumentException("detailLines must fit below the title line"); + } + } + + static ToolDisplayBudget collapsed() { + return new ToolDisplayBudget(5, 4); + } + + static ToolDisplayBudget expanded(int transcriptHeight) { + int total = Math.max(1, Math.min(40, transcriptHeight)); + return new ToolDisplayBudget(total, total - 1); + } +} diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/ToolDisplayRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/ToolDisplayRenderer.java index 21829e9e..a9dc7286 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/ToolDisplayRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/ToolDisplayRenderer.java @@ -3,9 +3,5 @@ import cn.lypi.contracts.tui.TuiToolBlock; interface ToolDisplayRenderer { - ToolDisplayModel render(TuiToolBlock block, boolean expanded); - - default ToolDisplayModel render(TuiToolBlock block, boolean expanded, int detailLineLimit) { - return render(block, expanded); - } + ToolDisplayModel render(TuiToolBlock block, boolean expanded, ToolDisplayBudget budget); } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/ToolDisplayRendererRegistry.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/ToolDisplayRendererRegistry.java index 20af65e6..2cf1f9b3 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/ToolDisplayRendererRegistry.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/ToolDisplayRendererRegistry.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; final class ToolDisplayRendererRegistry { private final Map renderers; @@ -33,12 +34,9 @@ static ToolDisplayRendererRegistry defaults() { return new ToolDisplayRendererRegistry(renderers, fallback); } - ToolDisplayModel render(TuiToolBlock block, boolean expanded) { - return renderers.getOrDefault(normalize(block.toolName()), fallback).render(block, expanded); - } - - ToolDisplayModel render(TuiToolBlock block, boolean expanded, int detailLineLimit) { - return renderers.getOrDefault(normalize(block.toolName()), fallback).render(block, expanded, detailLineLimit); + ToolDisplayModel render(TuiToolBlock block, boolean expanded, ToolDisplayBudget budget) { + Objects.requireNonNull(budget, "budget must not be null"); + return renderers.getOrDefault(normalize(block.toolName()), fallback).render(block, expanded, budget); } boolean isReadLikeTool(TuiToolBlock block) { @@ -72,8 +70,12 @@ private static List firstLines(List lines, int limit) { if (lines.size() <= limit) { return lines; } - List preview = new ArrayList<>(lines.subList(0, limit)); - preview.add("... " + (lines.size() - limit) + " more lines"); + if (limit == 0) { + return List.of(); + } + int visibleLines = limit - 1; + List preview = new ArrayList<>(lines.subList(0, visibleLines)); + preview.add("... " + (lines.size() - visibleLines) + " more lines"); return preview; } @@ -82,31 +84,30 @@ private static List tailLines(List lines, int limit) { if (lines.size() <= limit) { return lines; } + if (limit == 0) { + return List.of(); + } + int visibleLines = limit - 1; List preview = new ArrayList<>(); - preview.add("... " + (lines.size() - limit) + " earlier lines"); - preview.addAll(lines.subList(lines.size() - limit, lines.size())); + preview.add("... " + (lines.size() - visibleLines) + " earlier lines"); + preview.addAll(lines.subList(lines.size() - visibleLines, lines.size())); return preview; } private static final class BashToolDisplayRenderer implements ToolDisplayRenderer { @Override - public ToolDisplayModel render(TuiToolBlock block, boolean expanded) { - return render(block, expanded, expanded ? 80 : 5); - } - - @Override - public ToolDisplayModel render(TuiToolBlock block, boolean expanded, int detailLineLimit) { + public ToolDisplayModel render(TuiToolBlock block, boolean expanded, ToolDisplayBudget budget) { return new ToolDisplayModel( stateLabel(block.state()) + " $ " + label(block), List.of(), - tailLines(detailLines(block), expanded ? detailLineLimit : 5) + tailLines(detailLines(block), budget.detailLines()) ); } } private static final class ReadToolDisplayRenderer implements ToolDisplayRenderer { @Override - public ToolDisplayModel render(TuiToolBlock block, boolean expanded) { + public ToolDisplayModel render(TuiToolBlock block, boolean expanded, ToolDisplayBudget budget) { return new ToolDisplayModel( stateLabel(block.state()) + " " + block.toolName() + " " + label(block), List.of(), @@ -117,28 +118,18 @@ public ToolDisplayModel render(TuiToolBlock block, boolean expanded) { private static final class WriteToolDisplayRenderer implements ToolDisplayRenderer { @Override - public ToolDisplayModel render(TuiToolBlock block, boolean expanded) { - return render(block, expanded, expanded ? 120 : 10); - } - - @Override - public ToolDisplayModel render(TuiToolBlock block, boolean expanded, int detailLineLimit) { + public ToolDisplayModel render(TuiToolBlock block, boolean expanded, ToolDisplayBudget budget) { return new ToolDisplayModel( stateLabel(block.state()) + " " + block.toolName() + " " + label(block), List.of(), - firstLines(detailLines(block), expanded ? detailLineLimit : 10) + firstLines(detailLines(block), budget.detailLines()) ); } } private static final class EditToolDisplayRenderer implements ToolDisplayRenderer { @Override - public ToolDisplayModel render(TuiToolBlock block, boolean expanded) { - return render(block, expanded, expanded ? 120 : 12); - } - - @Override - public ToolDisplayModel render(TuiToolBlock block, boolean expanded, int detailLineLimit) { + public ToolDisplayModel render(TuiToolBlock block, boolean expanded, ToolDisplayBudget budget) { List lines = detailLines(block); int added = 0; int removed = 0; @@ -153,14 +144,14 @@ public ToolDisplayModel render(TuiToolBlock block, boolean expanded, int detailL return new ToolDisplayModel( stateLabel(block.state()) + " edit " + label(block) + summary, List.of(), - firstLines(lines, expanded ? detailLineLimit : 12) + firstLines(lines, budget.detailLines()) ); } } private static final class SearchToolDisplayRenderer implements ToolDisplayRenderer { @Override - public ToolDisplayModel render(TuiToolBlock block, boolean expanded) { + public ToolDisplayModel render(TuiToolBlock block, boolean expanded, ToolDisplayBudget budget) { return new ToolDisplayModel( stateLabel(block.state()) + " " + block.toolName() + " " + label(block), List.of(), @@ -171,16 +162,11 @@ public ToolDisplayModel render(TuiToolBlock block, boolean expanded) { private static final class FallbackToolDisplayRenderer implements ToolDisplayRenderer { @Override - public ToolDisplayModel render(TuiToolBlock block, boolean expanded) { - return render(block, expanded, expanded ? 120 : 10); - } - - @Override - public ToolDisplayModel render(TuiToolBlock block, boolean expanded, int detailLineLimit) { + public ToolDisplayModel render(TuiToolBlock block, boolean expanded, ToolDisplayBudget budget) { return new ToolDisplayModel( stateLabel(block.state()) + " " + block.toolName() + " " + label(block), List.of(), - firstLines(detailLines(block), expanded ? detailLineLimit : 10) + firstLines(detailLines(block), budget.detailLines()) ); } } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java index 66948993..7ebf7c21 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java @@ -281,16 +281,25 @@ private List toolLines(TuiToolBlock tool, int width, boolean toolOutputE private List toolLines(TuiToolBlock tool, int width, boolean toolOutputExpanded, int lineBudget) { List lines = new ArrayList<>(); - int detailLineLimit = Math.max(0, lineBudget - 1); - ToolDisplayModel model = toolDisplayRenderers.render(tool, toolOutputExpanded, detailLineLimit); - appendWithinBudget(lines, wrap(model.title(), width), lineBudget); + int availableLines = Math.max(0, lineBudget); + if (availableLines == 0) { + return lines; + } + ToolDisplayBudget budget = toolOutputExpanded + ? ToolDisplayBudget.expanded(availableLines) + : ToolDisplayBudget.collapsed(); + if (availableLines < budget.totalLines()) { + budget = new ToolDisplayBudget(availableLines, Math.max(0, availableLines - 1)); + } + ToolDisplayModel model = toolDisplayRenderers.render(tool, toolOutputExpanded, budget); + appendWithinBudget(lines, wrap(model.title(), width), budget.totalLines()); for (String summaryLine : model.summaryLines()) { if (!summaryLine.isBlank()) { - appendWithinBudget(lines, wrap(" " + summaryLine, width), lineBudget); + appendWithinBudget(lines, wrap(" " + summaryLine, width), budget.totalLines()); } } for (String detailLine : model.previewLines()) { - appendWithinBudget(lines, wrap(" " + detailLine, width), lineBudget); + appendWithinBudget(lines, wrap(" " + detailLine, width), budget.totalLines()); } return lines; } From f1bdc845774638c762903cad52e35a1d7f515b3b Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 16:21:08 +0800 Subject: [PATCH 17/58] =?UTF-8?q?fix(tool):=20=E6=94=B6=E7=B4=A7=E5=89=A9?= =?UTF-8?q?=E4=BD=99=E9=80=9A=E7=94=A8=E8=BE=93=E5=85=A5=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../subagent/AbstractSubagentTool.java | 3 ++- .../lypi/tool/web/GetSearchContentTool.java | 3 ++- .../builtin/subagent/SubagentToolsTest.java | 20 +++++++++++++++++++ .../tool/web/GetSearchContentToolTest.java | 20 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/AbstractSubagentTool.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/AbstractSubagentTool.java index 1ec9d3f4..57ee432b 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/AbstractSubagentTool.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/AbstractSubagentTool.java @@ -17,6 +17,7 @@ import cn.lypi.contracts.tool.Tool; import cn.lypi.contracts.tool.ToolResult; import cn.lypi.contracts.tool.ToolUseContext; +import cn.lypi.tool.ToolEventSummaryFormatter; import java.nio.file.Path; import java.util.List; import java.util.Map; @@ -83,7 +84,7 @@ public AgentMessage serializeForContext(String output) { @Override public String renderForUser(Map input) { - return name() + " " + input; + return ToolEventSummaryFormatter.genericInputSummary(name(), input); } protected ValidationResult requireAny(Map input, String... names) { diff --git a/lypi-tool/src/main/java/cn/lypi/tool/web/GetSearchContentTool.java b/lypi-tool/src/main/java/cn/lypi/tool/web/GetSearchContentTool.java index 80bdbca3..77702e14 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/web/GetSearchContentTool.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/web/GetSearchContentTool.java @@ -9,6 +9,7 @@ import cn.lypi.contracts.security.PermissionUpdate; import cn.lypi.contracts.tool.ToolResult; import cn.lypi.contracts.tool.ToolUseContext; +import cn.lypi.tool.ToolEventSummaryFormatter; import java.util.List; import java.util.Locale; import java.util.Map; @@ -99,7 +100,7 @@ public ToolResult execute(Map input, ToolUseContext cont @Override public String renderForUser(Map input) { - return "get_search_content " + input; + return ToolEventSummaryFormatter.genericInputSummary(name(), input); } private Optional resolveResult(Map input, ToolUseContext context) { diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/subagent/SubagentToolsTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/subagent/SubagentToolsTest.java index 63e1c3a8..7b644d0c 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/subagent/SubagentToolsTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/subagent/SubagentToolsTest.java @@ -42,6 +42,26 @@ import org.junit.jupiter.api.Test; class SubagentToolsTest { + @Test + void renderForUserDoesNotExposeLongSubagentPrompt() { + SpawnAgentTool tool = new SpawnAgentTool(new RecordingAgentCenter()); + String prompt = "SENSITIVE" + "x".repeat(4_096 - "SENSITIVE".length()); + + String rendered = tool.renderForUser(Map.of( + "tools", List.of("read", "grep"), + "timeoutSeconds", 90, + "prompt", prompt, + "agentName", "reviewer" + )); + + assertEquals( + "spawn_agent agentName=reviewer prompt=<4096 chars> timeoutSeconds=90", + rendered + ); + assertFalse(rendered.contains("SENSITIVE")); + assertFalse(rendered.contains("{")); + } + @Test void spawnAgentStartsSubagentAndReturnsOnlyStartupStatus() { RecordingAgentCenter agentCenter = new RecordingAgentCenter(); diff --git a/lypi-tool/src/test/java/cn/lypi/tool/web/GetSearchContentToolTest.java b/lypi-tool/src/test/java/cn/lypi/tool/web/GetSearchContentToolTest.java index 6bf6d1f0..a329ec1d 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/web/GetSearchContentToolTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/web/GetSearchContentToolTest.java @@ -144,6 +144,26 @@ void returnsErrorWhenResultIsMissing() { assertTrue(result.output().contains("未找到")); } + @Test + void renderForUserUsesFiniteGenericInputShapes() { + GetSearchContentTool tool = new GetSearchContentTool(storeWith(sampleResult())); + String query = "SENSITIVE" + "x".repeat(4_096 - "SENSITIVE".length()); + + String rendered = tool.renderForUser(Map.of( + "url", "https://example.com/a", + "responseId", "web_1", + "query", query, + "maxChars", 1_000 + )); + + assertEquals( + "get_search_content maxChars=1000 query=<4096 chars> responseId=web_1", + rendered + ); + assertFalse(rendered.contains("SENSITIVE")); + assertFalse(rendered.contains("{")); + } + private WebStoredResult sampleResult() { return new WebStoredResult( "session", From d56c0ac69a4ec1e6d3f1381d32602bf616cb17cb Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 16:25:46 +0800 Subject: [PATCH 18/58] =?UTF-8?q?fix(tui):=20=E7=AA=84=E5=AE=BD=E5=BA=A6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=B7=A5=E5=85=B7=E7=9C=81=E7=95=A5=E6=A0=87?= =?UTF-8?q?=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cn/lypi/transport/tui/TuiRenderer.java | 26 ++++++++++++++ .../lypi/transport/tui/TuiRendererTest.java | 36 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java index 7ebf7c21..61642bd3 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java @@ -301,9 +301,35 @@ private List toolLines(TuiToolBlock tool, int width, boolean toolOutputE for (String detailLine : model.previewLines()) { appendWithinBudget(lines, wrap(" " + detailLine, width), budget.totalLines()); } + preserveToolOmissionMarker(lines, model, width, budget.totalLines()); return lines; } + private void preserveToolOmissionMarker( + List lines, + ToolDisplayModel model, + int width, + int lineBudget + ) { + String marker = model.previewLines().stream() + .filter(this::isToolOmissionMarker) + .findFirst() + .orElse(null); + if (marker == null || lines.stream().anyMatch(this::isToolOmissionMarker)) { + return; + } + String renderedMarker = AnsiWidth.truncate(" " + marker, width); + if (lines.size() < lineBudget) { + lines.add(renderedMarker); + } else if (!lines.isEmpty()) { + lines.set(lines.size() - 1, renderedMarker); + } + } + + private boolean isToolOmissionMarker(String line) { + return line != null && (line.contains("more lines") || line.contains("earlier lines")); + } + private int remainingBudget(List lines, int lineBudget) { return Math.max(0, lineBudget - lines.size()); } diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java index 8b488aad..64fb493b 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java @@ -1010,6 +1010,42 @@ void collapsedAndExpandedToolFramesShareGlobalTranscriptBudget() { assertFrameFitsHeightWithOmissionMarker(expanded, 12); } + @Test + void narrowWrappedToolTitleStillLeavesAnOmissionMarkerWithinFiveRows() { + String details = String.join("\n", java.util.stream.IntStream.rangeClosed(1, 100) + .mapToObj(index -> "detail line " + index) + .toList()); + TuiViewModel view = new TuiViewModel( + List.of(new TuiToolBlock( + "tool:custom", + "msg_1", + "toolu_custom", + "custom_tool", + TuiToolState.DONE, + "x".repeat(120), + details, + false + )), + new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), + List.of(), + Optional.empty(), + Optional.empty() + ); + + TuiRenderFrame frame = new TuiRenderer().renderFrame( + view, + new TuiScreen(20), + new TuiLayout(20, 20), + "", + -1, + List.of(), + false + ); + + assertTrue(frame.transcriptLineCount() <= 5); + assertTrue(frame.lines().stream().anyMatch(line -> line.contains("more lines"))); + } + private void assertFrameFitsHeightWithOmissionMarker(TuiRenderFrame frame, int height) { assertTrue(frame.lines().size() <= height); assertTrue(frame.transcriptLineCount() <= height - frame.chromeLineCount()); From c91786b3603f91e4459dd13efc35e80832ed08ce Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 16:59:20 +0800 Subject: [PATCH 19/58] =?UTF-8?q?test(ai):=20=E5=A4=8D=E7=8E=B0=20Assistan?= =?UTF-8?q?tStart=20=E9=98=BB=E6=96=AD=20provider=20=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OpenAiCompatibleProviderAdapterTest.java | 67 +++++++++++++++++-- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/lypi-ai/src/test/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapterTest.java b/lypi-ai/src/test/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapterTest.java index 2f85061b..10fe89d2 100644 --- a/lypi-ai/src/test/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapterTest.java +++ b/lypi-ai/src/test/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapterTest.java @@ -29,6 +29,7 @@ import cn.lypi.contracts.model.TextDelta; import cn.lypi.contracts.model.ThinkingDelta; import cn.lypi.contracts.model.ThinkingLevel; +import cn.lypi.contracts.model.ToolCallDelta; import cn.lypi.contracts.prompt.SystemPrompt; import cn.lypi.contracts.runtime.AiProviderRuntimePort; import cn.lypi.contracts.runtime.AiStreamOptions; @@ -50,6 +51,9 @@ import java.util.stream.StreamSupport; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -360,6 +364,32 @@ void fallsBackWhenAttemptClosesBeforeAnyAssistantDoneOrOutput() { assertThat(events).contains(new TextDelta("hello"), new AssistantDone(Optional.empty(), Optional.of("stop"))); } + @Test + void fallsBackAfterAssistantStartWithoutVisibleOutput() { + RecordingTransport websocket = RecordingTransport.eventsThenFail( + "WebSocket handshake failed after response.created", + "{\"type\":\"response.created\",\"response\":{\"id\":\"resp-first\"}}" + ); + RecordingTransport sse = RecordingTransport.events( + "{\"type\":\"response.created\",\"response\":{\"id\":\"resp-fallback\"}}", + "{\"type\":\"response.output_text.delta\",\"delta\":\"fallback ok\"}", + "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp-fallback\"}}" + ); + OpenAiCompatibleProviderAdapter adapter = new OpenAiCompatibleProviderAdapter( + config(TransportMode.AUTO, "test-key"), + websocket, + sse, + RecordingTransport.events() + ); + + List events = collect(adapter.stream(context(), descriptor(), () -> false)); + + assertThat(websocket.requests).hasSize(1); + assertThat(sse.requests).hasSize(1); + assertThat(events).contains(new TextDelta("fallback ok")); + assertThat(events).noneMatch(cn.lypi.contracts.model.AssistantError.class::isInstance); + } + @Test void reportsErrorWhenAttemptClosesAfterOutputWithoutAssistantDone() { RecordingTransport websocket = RecordingTransport.events( @@ -389,16 +419,21 @@ void reportsErrorWhenAttemptClosesAfterOutputWithoutAssistantDone() { assertThat(chat.requests).isEmpty(); } - @Test - void doesNotFallbackAfterAnyOutputStarted() { + @ParameterizedTest(name = "does not fall back after visible {0}") + @MethodSource("visibleOutputEvents") + void doesNotFallbackAfterAnyOutputStarted( + String ignoredName, + String rawEvent, + Class eventType + ) { RecordingTransport websocket = RecordingTransport.eventsThenFail( "Provider stream failed after output", - "{\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}" + rawEvent ); RecordingTransport sse = RecordingTransport.events(); RecordingTransport chat = RecordingTransport.events(); OpenAiCompatibleProviderAdapter adapter = new OpenAiCompatibleProviderAdapter( - config(TransportMode.WEBSOCKET, "test-key"), + config(TransportMode.AUTO, "test-key"), websocket, sse, chat @@ -408,11 +443,11 @@ void doesNotFallbackAfterAnyOutputStarted() { Iterator iterator = stream.iterator(); assertThat(iterator.hasNext()).isTrue(); - assertThat(iterator.next()).isEqualTo(new TextDelta("hello")); + assertThat(iterator.next()).isInstanceOf(eventType); assertThatThrownBy(iterator::hasNext) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("Provider stream failed after output"); - assertThat(stream.result().events()).containsExactly(new TextDelta("hello")); + assertThat(stream.result().events()).singleElement().isInstanceOf(eventType); assertThat(stream.result().events()).noneMatch(ProviderRetryNotice.class::isInstance); assertThat(stream.result().error()).isPresent(); assertThat(stream.result().completed()).isFalse(); @@ -421,6 +456,26 @@ void doesNotFallbackAfterAnyOutputStarted() { assertThat(chat.requests).isEmpty(); } + private static Stream visibleOutputEvents() { + return Stream.of( + Arguments.of( + "text", + "{\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}", + TextDelta.class + ), + Arguments.of( + "thinking", + "{\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"thinking\"}", + ThinkingDelta.class + ), + Arguments.of( + "tool call", + "{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"item-1\",\"output_index\":0,\"delta\":\"{\\\"path\\\":\\\"pom.xml\\\"}\"}", + ToolCallDelta.class + ) + ); + } + @Test void failsClearlyWhenApiKeyIsMissing() { OpenAiCompatibleProviderAdapter adapter = new OpenAiCompatibleProviderAdapter( From 3e42c217ff7650cdc42b0e659498931a1b414916 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 17:02:45 +0800 Subject: [PATCH 20/58] =?UTF-8?q?fix(ai):=20=E4=BB=85=E7=94=A8=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E8=BE=93=E5=87=BA=E9=98=BB=E6=AD=A2=20provider=20?= =?UTF-8?q?=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/provider/ProviderErrorClassifier.java | 4 +-- .../ai/provider/ProviderFallbackDecider.java | 7 +++-- .../ai/provider/ProviderRetryCoordinator.java | 10 +++---- .../openai/OpenAiAssistantEventStream.java | 28 ++++++++++++++----- .../provider/ProviderFallbackDeciderTest.java | 2 +- 5 files changed, 33 insertions(+), 18 deletions(-) diff --git a/lypi-ai/src/main/java/cn/lypi/ai/provider/ProviderErrorClassifier.java b/lypi-ai/src/main/java/cn/lypi/ai/provider/ProviderErrorClassifier.java index 24c4ccf5..974514fb 100644 --- a/lypi-ai/src/main/java/cn/lypi/ai/provider/ProviderErrorClassifier.java +++ b/lypi-ai/src/main/java/cn/lypi/ai/provider/ProviderErrorClassifier.java @@ -9,7 +9,7 @@ public final class ProviderErrorClassifier { private static final Pattern RETRY_AFTER_PATTERN = Pattern.compile("retry-after[:= ]+(\\d+)", Pattern.CASE_INSENSITIVE); - public ProviderErrorClassification classify(RuntimeException error, boolean outputStarted) { + public ProviderErrorClassification classify(RuntimeException error, boolean visibleOutputStarted) { String message = error.getMessage() == null ? error.getClass().getSimpleName() : error.getMessage(); String normalized = message.toLowerCase(Locale.ROOT); if (containsAny(normalized, "aborted", "abort")) { @@ -37,7 +37,7 @@ public ProviderErrorClassification classify(RuntimeException error, boolean outp "provider.fallback_candidate", "fallback_candidate", true, - !outputStarted, + !visibleOutputStarted, retryAfter(message) ); } diff --git a/lypi-ai/src/main/java/cn/lypi/ai/provider/ProviderFallbackDecider.java b/lypi-ai/src/main/java/cn/lypi/ai/provider/ProviderFallbackDecider.java index 0cffe8d1..1418f525 100644 --- a/lypi-ai/src/main/java/cn/lypi/ai/provider/ProviderFallbackDecider.java +++ b/lypi-ai/src/main/java/cn/lypi/ai/provider/ProviderFallbackDecider.java @@ -6,10 +6,11 @@ public final class ProviderFallbackDecider { /** * 判断一次 provider 失败是否允许进入下一种请求方言。 * - * 已经向上游发出输出后不再静默回退,避免重复执行同一用户请求。 + * 已经向上游发出用户可见内容或工具调用后不再静默回退,避免重复执行同一用户请求。 + * Provider message ID 和 control notice 不属于可见输出。 */ - public boolean shouldFallback(RuntimeException error, boolean outputStarted) { - if (outputStarted) { + public boolean shouldFallback(RuntimeException error, boolean visibleOutputStarted) { + if (visibleOutputStarted) { return false; } String message = error.getMessage() == null ? "" : error.getMessage().toLowerCase(Locale.ROOT); diff --git a/lypi-ai/src/main/java/cn/lypi/ai/provider/ProviderRetryCoordinator.java b/lypi-ai/src/main/java/cn/lypi/ai/provider/ProviderRetryCoordinator.java index 84eae8e0..d6bf24b1 100644 --- a/lypi-ai/src/main/java/cn/lypi/ai/provider/ProviderRetryCoordinator.java +++ b/lypi-ai/src/main/java/cn/lypi/ai/provider/ProviderRetryCoordinator.java @@ -54,13 +54,13 @@ public static ProviderRetryCoordinator defaultSleep(String provider, ProviderRet public Optional planRetry( RuntimeException exception, AbortSignal signal, - boolean outputStarted, + boolean visibleOutputStarted, int retryAttempt ) { - ProviderErrorClassification classification = classifier.classify(exception, outputStarted); + ProviderErrorClassification classification = classifier.classify(exception, visibleOutputStarted); if (signal.aborted() || !classification.retryable() - || outputStarted + || visibleOutputStarted || retryAttempt > policy.maxRetries()) { return Optional.empty(); } @@ -101,14 +101,14 @@ private static void threadSleep(Duration delay) { public T execute( Supplier operation, AbortSignal signal, - boolean outputStarted, + boolean visibleOutputStarted, Consumer retryNoticeConsumer ) { for (int attempt = 1; ; attempt++) { try { return operation.get(); } catch (RuntimeException exception) { - Optional notice = planRetry(exception, signal, outputStarted, attempt); + Optional notice = planRetry(exception, signal, visibleOutputStarted, attempt); if (notice.isEmpty()) { throw exception; } diff --git a/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiAssistantEventStream.java b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiAssistantEventStream.java index adbc42f8..e9ea8cd7 100644 --- a/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiAssistantEventStream.java +++ b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiAssistantEventStream.java @@ -14,7 +14,10 @@ import cn.lypi.contracts.model.AssistantStreamResult; import cn.lypi.contracts.model.ProviderRetryNotice; import cn.lypi.contracts.model.ProviderConversationState; +import cn.lypi.contracts.model.TextDelta; +import cn.lypi.contracts.model.ThinkingDelta; import cn.lypi.contracts.model.TokenUsage; +import cn.lypi.contracts.model.ToolCallDelta; import java.time.Duration; import java.util.ArrayDeque; import java.util.ArrayList; @@ -40,7 +43,7 @@ public final class OpenAiAssistantEventStream implements AssistantEventStream { private boolean closed; private boolean completed; private boolean aborted; - private boolean outputStarted; + private boolean visibleOutputStarted; private boolean retryNoticeAwaitingConsumption; private AssistantError error; private RuntimeException failure; @@ -181,10 +184,10 @@ private AssistantStreamEvent next() { } AssistantStreamEvent event = pendingEvents.removeFirst(); emittedEvents.add(event); - if (!(event instanceof ProviderRetryNotice)) { - outputStarted = true; - } else { + if (event instanceof ProviderRetryNotice) { retryNoticeAwaitingConsumption = false; + } else if (startsVisibleOutput(event)) { + visibleOutputStarted = true; } applyResult(event); return event; @@ -238,7 +241,7 @@ private void handleAttemptFailure(RuntimeException exception) { Optional retryNotice = retryCoordinator.planRetry( exception, signal, - outputStarted, + visibleOutputStarted, nextRetryIndex ); if (retryNotice.isPresent()) { @@ -250,17 +253,28 @@ private void handleAttemptFailure(RuntimeException exception) { } retryIndex = 0; attemptIndex++; - if (fallbackDecider.shouldFallback(exception, outputStarted) && !outputStarted && attemptIndex < attempts.size()) { + if (fallbackDecider.shouldFallback(exception, visibleOutputStarted) + && !visibleOutputStarted + && attemptIndex < attempts.size()) { return; } error = new AssistantError("provider.request_failed", exception.getMessage()); - if (outputStarted) { + if (visibleOutputStarted) { failure = exception; return; } pendingEvents.add(error); } + private boolean startsVisibleOutput(AssistantStreamEvent event) { + return switch (event) { + case TextDelta delta -> delta.text() != null && !delta.text().isEmpty(); + case ThinkingDelta delta -> delta.text() != null && !delta.text().isEmpty(); + case ToolCallDelta ignored -> true; + default -> false; + }; + } + private void sleepBeforeRetry() { if (pendingRetryDelay.isZero() || pendingRetryDelay.isNegative()) { return; diff --git a/lypi-ai/src/test/java/cn/lypi/ai/provider/ProviderFallbackDeciderTest.java b/lypi-ai/src/test/java/cn/lypi/ai/provider/ProviderFallbackDeciderTest.java index c4fdd56f..0c338797 100644 --- a/lypi-ai/src/test/java/cn/lypi/ai/provider/ProviderFallbackDeciderTest.java +++ b/lypi-ai/src/test/java/cn/lypi/ai/provider/ProviderFallbackDeciderTest.java @@ -22,7 +22,7 @@ void fallsBackForUnsupportedEndpointBeforeAnyDelta() { } @Test - void doesNotFallbackAfterOutputOrForAuthRateLimitAndAbort() { + void doesNotFallbackAfterVisibleOutputOrForAuthRateLimitAndAbort() { ProviderFallbackDecider decider = new ProviderFallbackDecider(); assertThat(decider.shouldFallback(new IllegalStateException("Provider HTTP 404: endpoint unsupported"), true)) From 8b33ca43e7adc62bb91f526f78ae0f2748788db5 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 17:05:37 +0800 Subject: [PATCH 21/58] =?UTF-8?q?feat(contracts):=20=E5=A2=9E=E5=8A=A0=20p?= =?UTF-8?q?rovider=20fallback=20=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cn/lypi/contracts/event/AgentEvent.java | 4 + .../event/ProviderFallbackEndEvent.java | 10 +++ .../event/ProviderFallbackStartEvent.java | 11 +++ .../contracts/model/AssistantStreamEvent.java | 2 + .../model/ProviderFallbackNotice.java | 12 +++ .../contracts/ContractSerializationTest.java | 73 +++++++++++++++++++ 6 files changed, 112 insertions(+) create mode 100644 lypi-contracts/src/main/java/cn/lypi/contracts/event/ProviderFallbackEndEvent.java create mode 100644 lypi-contracts/src/main/java/cn/lypi/contracts/event/ProviderFallbackStartEvent.java create mode 100644 lypi-contracts/src/main/java/cn/lypi/contracts/model/ProviderFallbackNotice.java diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/event/AgentEvent.java b/lypi-contracts/src/main/java/cn/lypi/contracts/event/AgentEvent.java index caa8ffe4..c0166fbc 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/event/AgentEvent.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/event/AgentEvent.java @@ -22,6 +22,8 @@ @JsonSubTypes.Type(value = CompactEndEvent.class, name = "compact_end"), @JsonSubTypes.Type(value = RetryStartEvent.class, name = "retry_start"), @JsonSubTypes.Type(value = RetryEndEvent.class, name = "retry_end"), + @JsonSubTypes.Type(value = ProviderFallbackStartEvent.class, name = "provider_fallback_start"), + @JsonSubTypes.Type(value = ProviderFallbackEndEvent.class, name = "provider_fallback_end"), @JsonSubTypes.Type(value = MemoryWriteEvent.class, name = "memory_write"), @JsonSubTypes.Type(value = InterruptEvent.class, name = "interrupt"), @JsonSubTypes.Type(value = ErrorEvent.class, name = "error"), @@ -44,6 +46,8 @@ public sealed interface AgentEvent permits CompactEndEvent, RetryStartEvent, RetryEndEvent, + ProviderFallbackStartEvent, + ProviderFallbackEndEvent, MemoryWriteEvent, InterruptEvent, ErrorEvent, diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/event/ProviderFallbackEndEvent.java b/lypi-contracts/src/main/java/cn/lypi/contracts/event/ProviderFallbackEndEvent.java new file mode 100644 index 00000000..968844db --- /dev/null +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/event/ProviderFallbackEndEvent.java @@ -0,0 +1,10 @@ +package cn.lypi.contracts.event; + +import java.time.Instant; + +public record ProviderFallbackEndEvent( + String sessionId, + String toMode, + boolean success, + Instant timestamp +) implements AgentEvent {} diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/event/ProviderFallbackStartEvent.java b/lypi-contracts/src/main/java/cn/lypi/contracts/event/ProviderFallbackStartEvent.java new file mode 100644 index 00000000..a095e729 --- /dev/null +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/event/ProviderFallbackStartEvent.java @@ -0,0 +1,11 @@ +package cn.lypi.contracts.event; + +import java.time.Instant; + +public record ProviderFallbackStartEvent( + String sessionId, + String fromMode, + String toMode, + String reason, + Instant timestamp +) implements AgentEvent {} diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/model/AssistantStreamEvent.java b/lypi-contracts/src/main/java/cn/lypi/contracts/model/AssistantStreamEvent.java index 8613812c..3e2bd312 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/model/AssistantStreamEvent.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/model/AssistantStreamEvent.java @@ -11,6 +11,7 @@ @JsonSubTypes.Type(value = ToolCallDelta.class, name = "tool_call_delta"), @JsonSubTypes.Type(value = AssistantDone.class, name = "assistant_done"), @JsonSubTypes.Type(value = AssistantError.class, name = "assistant_error"), + @JsonSubTypes.Type(value = ProviderFallbackNotice.class, name = "provider_fallback"), @JsonSubTypes.Type(value = ProviderRetryNotice.class, name = "provider_retry") }) public sealed interface AssistantStreamEvent permits @@ -20,5 +21,6 @@ public sealed interface AssistantStreamEvent permits ToolCallDelta, AssistantDone, AssistantError, + ProviderFallbackNotice, ProviderRetryNotice { } diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/model/ProviderFallbackNotice.java b/lypi-contracts/src/main/java/cn/lypi/contracts/model/ProviderFallbackNotice.java new file mode 100644 index 00000000..55034ddd --- /dev/null +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/model/ProviderFallbackNotice.java @@ -0,0 +1,12 @@ +package cn.lypi.contracts.model; + +public record ProviderFallbackNotice( + String provider, + int fromAttempt, + int toAttempt, + String fromMode, + String toMode, + String reason, + String errorId, + String message +) implements AssistantStreamEvent {} diff --git a/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java b/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java index 01148a53..0bbdea2e 100644 --- a/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java +++ b/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java @@ -29,6 +29,8 @@ import cn.lypi.contracts.event.PermissionDecisionEvent; import cn.lypi.contracts.event.PermissionRequestEvent; import cn.lypi.contracts.event.PermissionResponseEvent; +import cn.lypi.contracts.event.ProviderFallbackEndEvent; +import cn.lypi.contracts.event.ProviderFallbackStartEvent; import cn.lypi.contracts.event.SessionStateEvent; import cn.lypi.contracts.event.ToolEndEvent; import cn.lypi.contracts.event.ToolProgressEvent; @@ -37,6 +39,7 @@ import cn.lypi.contracts.event.TurnStartEvent; import cn.lypi.contracts.model.AssistantStreamEvent; import cn.lypi.contracts.model.ModelSelection; +import cn.lypi.contracts.model.ProviderFallbackNotice; import cn.lypi.contracts.model.ProviderRetryNotice; import cn.lypi.contracts.model.ThinkingLevel; import cn.lypi.contracts.memory.MemoryScope; @@ -912,6 +915,76 @@ void providerRetryNoticeRoundTripUsesTypeDiscriminator() throws Exception { assertEquals("provider.rate_limit", notice.retryableErrorId()); } + @Test + void providerFallbackNoticeRoundTripUsesTypeDiscriminator() throws Exception { + AssistantStreamEvent event = new ProviderFallbackNotice( + "openai", + 1, + 2, + "responses/websocket", + "responses/sse", + "fallback_candidate", + "provider.fallback_candidate", + "WebSocket handshake failed" + ); + + String json = mapper.writeValueAsString(event); + AssistantStreamEvent restored = mapper.readValue(json, AssistantStreamEvent.class); + + assertTrue(json.contains("\"type\":\"provider_fallback\"")); + ProviderFallbackNotice notice = assertInstanceOf(ProviderFallbackNotice.class, restored); + assertEquals("openai", notice.provider()); + assertEquals(1, notice.fromAttempt()); + assertEquals(2, notice.toAttempt()); + assertEquals("responses/websocket", notice.fromMode()); + assertEquals("responses/sse", notice.toMode()); + assertEquals("provider.fallback_candidate", notice.errorId()); + } + + @Test + void providerFallbackStartEventRoundTripUsesTypeDiscriminator() throws Exception { + Instant timestamp = Instant.parse("2026-06-01T12:00:00Z"); + AgentEvent event = new ProviderFallbackStartEvent( + "ses_01", + "responses/websocket", + "responses/sse", + "fallback_candidate", + timestamp + ); + + String json = mapper.writeValueAsString(event); + AgentEvent restored = mapper.readValue(json, AgentEvent.class); + + assertTrue(json.contains("\"type\":\"provider_fallback_start\"")); + ProviderFallbackStartEvent start = assertInstanceOf(ProviderFallbackStartEvent.class, restored); + assertEquals("ses_01", start.sessionId()); + assertEquals("responses/websocket", start.fromMode()); + assertEquals("responses/sse", start.toMode()); + assertEquals("fallback_candidate", start.reason()); + assertEquals(timestamp, start.timestamp()); + } + + @Test + void providerFallbackEndEventRoundTripUsesTypeDiscriminator() throws Exception { + Instant timestamp = Instant.parse("2026-06-01T12:00:00Z"); + AgentEvent event = new ProviderFallbackEndEvent( + "ses_01", + "responses/sse", + true, + timestamp + ); + + String json = mapper.writeValueAsString(event); + AgentEvent restored = mapper.readValue(json, AgentEvent.class); + + assertTrue(json.contains("\"type\":\"provider_fallback_end\"")); + ProviderFallbackEndEvent end = assertInstanceOf(ProviderFallbackEndEvent.class, restored); + assertEquals("ses_01", end.sessionId()); + assertEquals("responses/sse", end.toMode()); + assertTrue(end.success()); + assertEquals(timestamp, end.timestamp()); + } + @Test void toolProgressEventRoundTripKeepsStructuredProgress() throws Exception { AgentEvent event = new ToolProgressEvent( From efd2ba48c8a0752cb8ee0735fb8521bba9e4a3c0 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 17:51:31 +0800 Subject: [PATCH 22/58] =?UTF-8?q?feat(ai):=20=E5=8F=91=E5=B8=83=20provider?= =?UTF-8?q?=20attempt=20=E5=9B=9E=E9=80=80=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openai/OpenAiAssistantEventStream.java | 16 +++++++ .../OpenAiCompatibleProviderAdapter.java | 15 ++++++- .../provider/openai/OpenAiStreamAttempt.java | 12 +++++- .../OpenAiCompatibleProviderAdapterTest.java | 43 +++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiAssistantEventStream.java b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiAssistantEventStream.java index e9ea8cd7..664e1d75 100644 --- a/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiAssistantEventStream.java +++ b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiAssistantEventStream.java @@ -1,6 +1,8 @@ package cn.lypi.ai.provider.openai; import cn.lypi.ai.provider.ProviderEventStream; +import cn.lypi.ai.provider.ProviderErrorClassification; +import cn.lypi.ai.provider.ProviderErrorClassifier; import cn.lypi.ai.provider.ProviderFallbackDecider; import cn.lypi.ai.provider.ProviderRawEvent; import cn.lypi.ai.provider.ProviderRetryCoordinator; @@ -12,6 +14,7 @@ import cn.lypi.contracts.model.AssistantStart; import cn.lypi.contracts.model.AssistantStreamEvent; import cn.lypi.contracts.model.AssistantStreamResult; +import cn.lypi.contracts.model.ProviderFallbackNotice; import cn.lypi.contracts.model.ProviderRetryNotice; import cn.lypi.contracts.model.ProviderConversationState; import cn.lypi.contracts.model.TextDelta; @@ -32,6 +35,7 @@ public final class OpenAiAssistantEventStream implements AssistantEventStream { private final List attempts; private final AbortSignal signal; private final ProviderFallbackDecider fallbackDecider; + private final ProviderErrorClassifier errorClassifier = new ProviderErrorClassifier(); private final ProviderRetryCoordinator retryCoordinator; private final Deque pendingEvents = new ArrayDeque<>(); private final List emittedEvents = new ArrayList<>(); @@ -252,10 +256,22 @@ private void handleAttemptFailure(RuntimeException exception) { return; } retryIndex = 0; + int failedAttemptIndex = attemptIndex; attemptIndex++; if (fallbackDecider.shouldFallback(exception, visibleOutputStarted) && !visibleOutputStarted && attemptIndex < attempts.size()) { + ProviderErrorClassification classification = errorClassifier.classify(exception, visibleOutputStarted); + pendingEvents.add(new ProviderFallbackNotice( + "openai", + failedAttemptIndex + 1, + attemptIndex + 1, + attempts.get(failedAttemptIndex).mode(), + attempts.get(attemptIndex).mode(), + classification.reason(), + classification.errorId(), + exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage() + )); return; } error = new AssistantError("provider.request_failed", exception.getMessage()); diff --git a/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapter.java b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapter.java index 3ed1e95f..fe7eb848 100644 --- a/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapter.java +++ b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapter.java @@ -182,11 +182,17 @@ private void addAttemptsForStyle(List attempts, LypiModelRe if (style == cn.lypi.ai.provider.RequestStyle.RESPONSES) { if (config.transportMode() == TransportMode.AUTO || config.transportMode() == TransportMode.WEBSOCKET) { OpenAiResponsesStreamNormalizer normalizer = new OpenAiResponsesStreamNormalizer(); - attempts.add(new OpenAiStreamAttempt(webSocketTransport, responsesWebSocketRequest(request), normalizer)); + attempts.add(new OpenAiStreamAttempt( + "responses/websocket", + webSocketTransport, + responsesWebSocketRequest(request), + normalizer + )); } if (config.transportMode() == TransportMode.AUTO || config.transportMode() == TransportMode.SSE) { OpenAiResponsesStreamNormalizer normalizer = new OpenAiResponsesStreamNormalizer(); attempts.add(new OpenAiStreamAttempt( + "responses/sse", responsesSseTransport, responsesSseRequest(request, OpenAiResponsesRequestOptions.fallbackWithoutPreviousResponseState()), normalizer @@ -201,7 +207,12 @@ private void addAttemptsForStyle(List attempts, LypiModelRe private void addChatCompletionsSseAttempt(List attempts, LypiModelRequest request) { OpenAiChatCompletionsStreamNormalizer normalizer = new OpenAiChatCompletionsStreamNormalizer(); - attempts.add(new OpenAiStreamAttempt(chatCompletionsSseTransport, chatCompletionsRequest(request), normalizer)); + attempts.add(new OpenAiStreamAttempt( + "chat_completions/sse", + chatCompletionsSseTransport, + chatCompletionsRequest(request), + normalizer + )); } private ProviderRequest responsesWebSocketRequest(LypiModelRequest request) { diff --git a/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiStreamAttempt.java b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiStreamAttempt.java index 277ad3bc..9816f05d 100644 --- a/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiStreamAttempt.java +++ b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiStreamAttempt.java @@ -4,20 +4,30 @@ import cn.lypi.ai.provider.ProviderTransport; import cn.lypi.contracts.model.AssistantStreamEvent; import java.util.List; +import java.util.Objects; import java.util.function.Consumer; record OpenAiStreamAttempt( + String mode, ProviderTransport transport, ProviderRequest request, OpenAiStreamNormalizer normalizer, Consumer failureObserver ) { + OpenAiStreamAttempt { + mode = Objects.requireNonNull(mode, "mode"); + if (mode.isBlank()) { + throw new IllegalArgumentException("mode must not be blank"); + } + } + OpenAiStreamAttempt( + String mode, ProviderTransport transport, ProviderRequest request, OpenAiStreamNormalizer normalizer ) { - this(transport, request, normalizer, ignored -> { + this(mode, transport, request, normalizer, ignored -> { }); } diff --git a/lypi-ai/src/test/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapterTest.java b/lypi-ai/src/test/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapterTest.java index 10fe89d2..16af2b50 100644 --- a/lypi-ai/src/test/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapterTest.java +++ b/lypi-ai/src/test/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapterTest.java @@ -25,6 +25,7 @@ import cn.lypi.contracts.model.AssistantStreamEvent; import cn.lypi.contracts.model.ModelDescriptor; import cn.lypi.contracts.model.ModelSelection; +import cn.lypi.contracts.model.ProviderFallbackNotice; import cn.lypi.contracts.model.ProviderRetryNotice; import cn.lypi.contracts.model.TextDelta; import cn.lypi.contracts.model.ThinkingDelta; @@ -390,6 +391,48 @@ void fallsBackAfterAssistantStartWithoutVisibleOutput() { assertThat(events).noneMatch(cn.lypi.contracts.model.AssistantError.class::isInstance); } + @Test + void emitsFallbackNoticeBeforeOpeningNextProviderAttempt() { + RecordingTransport websocket = RecordingTransport.eventsThenFail( + "WebSocket handshake failed after response.created", + "{\"type\":\"response.created\",\"response\":{\"id\":\"resp-first\"}}" + ); + RecordingTransport sse = RecordingTransport.events( + "{\"type\":\"response.created\",\"response\":{\"id\":\"resp-fallback\"}}", + "{\"type\":\"response.output_text.delta\",\"delta\":\"fallback ok\"}", + "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp-fallback\"}}" + ); + OpenAiCompatibleProviderAdapter adapter = new OpenAiCompatibleProviderAdapter( + config(TransportMode.AUTO, "test-key"), + websocket, + sse, + RecordingTransport.events() + ); + + try (AssistantEventStream stream = adapter.stream(context(), descriptor(), () -> false)) { + Iterator iterator = stream.iterator(); + + assertThat(iterator.next()).isInstanceOf(cn.lypi.contracts.model.AssistantStart.class); + assertThat(iterator.hasNext()).isTrue(); + assertThat(sse.requests).isEmpty(); + assertThat(iterator.next()).isEqualTo(new ProviderFallbackNotice( + "openai", + 1, + 2, + "responses/websocket", + "responses/sse", + "fallback_candidate", + "provider.fallback_candidate", + "WebSocket handshake failed after response.created" + )); + assertThat(sse.requests).isEmpty(); + + assertThat(iterator.hasNext()).isTrue(); + assertThat(sse.requests).hasSize(1); + assertThat(iterator.next()).isInstanceOf(cn.lypi.contracts.model.AssistantStart.class); + } + } + @Test void reportsErrorWhenAttemptClosesAfterOutputWithoutAssistantDone() { RecordingTransport websocket = RecordingTransport.events( From 4347e5cd7caaf267bbb30fb3286d502b4d77a4d0 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 18:02:11 +0800 Subject: [PATCH 23/58] =?UTF-8?q?feat(agent):=20=E6=98=A0=E5=B0=84=20provi?= =?UTF-8?q?der=20fallback=20=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/AssistantStreamAccumulator.java | 3 + .../cn/lypi/agent/DefaultTurnExecutor.java | 79 +++++- .../cn/lypi/agent/TurnEventPublisher.java | 22 ++ .../agent/AssistantStreamAccumulatorTest.java | 29 ++ .../lypi/agent/DefaultTurnExecutorTest.java | 252 ++++++++++++++++++ .../cn/lypi/agent/TurnEventPublisherTest.java | 33 +++ 6 files changed, 404 insertions(+), 14 deletions(-) diff --git a/lypi-agent-core/src/main/java/cn/lypi/agent/AssistantStreamAccumulator.java b/lypi-agent-core/src/main/java/cn/lypi/agent/AssistantStreamAccumulator.java index f947b1fd..7b365f45 100644 --- a/lypi-agent-core/src/main/java/cn/lypi/agent/AssistantStreamAccumulator.java +++ b/lypi-agent-core/src/main/java/cn/lypi/agent/AssistantStreamAccumulator.java @@ -11,6 +11,7 @@ import cn.lypi.contracts.model.AssistantError; import cn.lypi.contracts.model.AssistantStart; import cn.lypi.contracts.model.AssistantStreamEvent; +import cn.lypi.contracts.model.ProviderFallbackNotice; import cn.lypi.contracts.model.ProviderRetryNotice; import cn.lypi.contracts.model.TextDelta; import cn.lypi.contracts.model.ThinkingDelta; @@ -59,6 +60,8 @@ public void accept(AssistantStreamEvent event) { } case ProviderRetryNotice ignored -> { } + case ProviderFallbackNotice ignored -> { + } } } diff --git a/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java b/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java index c15a92c3..8d89ceb1 100644 --- a/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java +++ b/lypi-agent-core/src/main/java/cn/lypi/agent/DefaultTurnExecutor.java @@ -15,10 +15,12 @@ import cn.lypi.contracts.context.ToolCallContentBlock; import cn.lypi.contracts.event.ErrorEvent; import cn.lypi.contracts.event.TurnStartEvent; +import cn.lypi.contracts.model.AssistantDone; import cn.lypi.contracts.model.AssistantEventStream; import cn.lypi.contracts.model.AssistantError; import cn.lypi.contracts.model.AssistantStart; import cn.lypi.contracts.model.AssistantStreamEvent; +import cn.lypi.contracts.model.ProviderFallbackNotice; import cn.lypi.contracts.model.ProviderRetryNotice; import cn.lypi.contracts.model.TextDelta; import cn.lypi.contracts.model.ThinkingDelta; @@ -262,7 +264,7 @@ private AgentMessage runModel(TurnRequest request, ContextSnapshot context) { String sessionId = request.sessionId(); final boolean[] assistantStarted = {false}; final MessageKind[] startedKind = {MessageKind.TEXT}; - Optional pendingRetry = Optional.empty(); + ProviderAttemptLifecycles providerLifecycles = new ProviderAttemptLifecycles(sessionId); ProviderConversationStateHolder providerConversationState = new ProviderConversationStateHolder(); try (AssistantEventStream stream = ports.aiProvider().stream( context, @@ -271,18 +273,21 @@ private AgentMessage runModel(TurnRequest request, ContextSnapshot context) { request.abortSignal() )) { for (AssistantStreamEvent event : stream) { - if (event instanceof ProviderRetryNotice notice) { - pendingRetry.ifPresent(previous -> eventPublisher.publishRetryEnd(request.sessionId(), previous, false)); - eventPublisher.publishRetryStart(request.sessionId(), notice); - pendingRetry = Optional.of(notice); + if (event instanceof ProviderFallbackNotice notice) { + providerLifecycles.startFallback(notice); continue; } - if (pendingRetry.isPresent()) { - ProviderRetryNotice notice = pendingRetry.get(); - eventPublisher.publishRetryEnd(request.sessionId(), notice, !(event instanceof cn.lypi.contracts.model.AssistantError)); - pendingRetry = Optional.empty(); + if (event instanceof ProviderRetryNotice notice) { + providerLifecycles.startRetry(notice); + continue; } accumulator.accept(event); + boolean abortRequested = request.abortSignal().aborted(); + if (abortRequested || event instanceof AssistantError) { + providerLifecycles.close(false); + } else if (completesProviderLifecycleSuccessfully(event)) { + providerLifecycles.close(true); + } if (event instanceof TextDelta delta) { String messageId = currentAssistantId(accumulator); ensureAssistantMessageStart(sessionId, messageId, MessageKind.TEXT, assistantStarted, startedKind); @@ -339,15 +344,13 @@ private AgentMessage runModel(TurnRequest request, ContextSnapshot context) { Map.of("errorId", error.errorId()) )); } - if (request.abortSignal().aborted()) { - pendingRetry.ifPresent(notice -> eventPublisher.publishRetryEnd(request.sessionId(), notice, false)); - pendingRetry = Optional.empty(); + if (abortRequested) { break; } } providerConversationState.value = stream.result().providerConversationState(); } catch (RuntimeException failure) { - pendingRetry.ifPresent(notice -> eventPublisher.publishRetryEnd(sessionId, notice, false)); + providerLifecycles.close(false); accumulator.messageId() .ifPresent(messageId -> { ensureAssistantMessageStart(sessionId, messageId, MessageKind.TEXT, assistantStarted, startedKind); @@ -355,7 +358,7 @@ private AgentMessage runModel(TurnRequest request, ContextSnapshot context) { }); throw failure; } - pendingRetry.ifPresent(notice -> eventPublisher.publishRetryEnd(request.sessionId(), notice, false)); + providerLifecycles.close(false); AgentMessage message = accumulator.toMessage( ids.newMessageId(), @@ -366,6 +369,16 @@ private AgentMessage runModel(TurnRequest request, ContextSnapshot context) { return message; } + private boolean completesProviderLifecycleSuccessfully(AssistantStreamEvent event) { + return switch (event) { + case TextDelta delta -> delta.text() != null && !delta.text().isEmpty(); + case ThinkingDelta delta -> delta.text() != null && !delta.text().isEmpty(); + case ToolCallDelta ignored -> true; + case AssistantDone ignored -> true; + default -> false; + }; + } + private List> executeTools( String sessionId, String turnId, @@ -437,4 +450,42 @@ private void ensureAssistantMessageStart( private static final class ProviderConversationStateHolder { private Optional value = Optional.empty(); } + + private final class ProviderAttemptLifecycles { + private final String sessionId; + private Optional pendingRetry = Optional.empty(); + private Optional pendingFallback = Optional.empty(); + + private ProviderAttemptLifecycles(String sessionId) { + this.sessionId = sessionId; + } + + private void startRetry(ProviderRetryNotice notice) { + closeRetry(false); + eventPublisher.publishRetryStart(sessionId, notice); + pendingRetry = Optional.of(notice); + } + + private void startFallback(ProviderFallbackNotice notice) { + closeRetry(false); + closeFallback(false); + eventPublisher.publishProviderFallbackStart(sessionId, notice); + pendingFallback = Optional.of(notice); + } + + private void close(boolean success) { + closeRetry(success); + closeFallback(success); + } + + private void closeRetry(boolean success) { + pendingRetry.ifPresent(notice -> eventPublisher.publishRetryEnd(sessionId, notice, success)); + pendingRetry = Optional.empty(); + } + + private void closeFallback(boolean success) { + pendingFallback.ifPresent(notice -> eventPublisher.publishProviderFallbackEnd(sessionId, notice, success)); + pendingFallback = Optional.empty(); + } + } } diff --git a/lypi-agent-core/src/main/java/cn/lypi/agent/TurnEventPublisher.java b/lypi-agent-core/src/main/java/cn/lypi/agent/TurnEventPublisher.java index 5f912e0b..05e0a9e7 100644 --- a/lypi-agent-core/src/main/java/cn/lypi/agent/TurnEventPublisher.java +++ b/lypi-agent-core/src/main/java/cn/lypi/agent/TurnEventPublisher.java @@ -11,9 +11,12 @@ import cn.lypi.contracts.event.MessageDeltaEvent; import cn.lypi.contracts.event.MessageEndEvent; import cn.lypi.contracts.event.MessageStartEvent; +import cn.lypi.contracts.event.ProviderFallbackEndEvent; +import cn.lypi.contracts.event.ProviderFallbackStartEvent; import cn.lypi.contracts.event.RetryEndEvent; import cn.lypi.contracts.event.RetryStartEvent; import cn.lypi.contracts.event.TurnEndEvent; +import cn.lypi.contracts.model.ProviderFallbackNotice; import cn.lypi.contracts.model.ProviderRetryNotice; import cn.lypi.contracts.model.ToolCallDelta; import java.time.Clock; @@ -69,6 +72,25 @@ void publishRetryEnd(String sessionId, ProviderRetryNotice notice, boolean succe )); } + void publishProviderFallbackStart(String sessionId, ProviderFallbackNotice notice) { + eventBus.publish(new ProviderFallbackStartEvent( + sessionId, + notice.fromMode(), + notice.toMode(), + notice.reason(), + clock.instant() + )); + } + + void publishProviderFallbackEnd(String sessionId, ProviderFallbackNotice notice, boolean success) { + eventBus.publish(new ProviderFallbackEndEvent( + sessionId, + notice.toMode(), + success, + clock.instant() + )); + } + void publishAssistantMessageStart(String sessionId, String messageId, MessageKind kind) { eventBus.publish(new MessageStartEvent( sessionId, diff --git a/lypi-agent-core/src/test/java/cn/lypi/agent/AssistantStreamAccumulatorTest.java b/lypi-agent-core/src/test/java/cn/lypi/agent/AssistantStreamAccumulatorTest.java index ee411dff..ad3de3a2 100644 --- a/lypi-agent-core/src/test/java/cn/lypi/agent/AssistantStreamAccumulatorTest.java +++ b/lypi-agent-core/src/test/java/cn/lypi/agent/AssistantStreamAccumulatorTest.java @@ -5,6 +5,7 @@ import cn.lypi.contracts.context.MessageKind; import cn.lypi.contracts.model.AssistantDone; import cn.lypi.contracts.model.AssistantStart; +import cn.lypi.contracts.model.ProviderFallbackNotice; import cn.lypi.contracts.model.TextDelta; import cn.lypi.contracts.model.ThinkingDelta; import cn.lypi.contracts.model.TokenUsage; @@ -118,4 +119,32 @@ void returnsPartialAssistantWhenAbortedBeforeDone() { assertThat(message.content().getFirst().text()).isEqualTo("partial"); assertThat(message.stopReason()).contains("aborted"); } + + @Test + void ignoresProviderFallbackNoticeWhenBuildingAssistantMessage() { + AssistantStreamAccumulator accumulator = new AssistantStreamAccumulator(clock); + + accumulator.accept(new AssistantStart("msg-a")); + accumulator.accept(new ProviderFallbackNotice( + "openai", + 1, + 2, + "responses/websocket", + "responses/sse", + "fallback_candidate", + "provider.fallback_candidate", + "WebSocket handshake failed" + )); + accumulator.accept(new TextDelta("fallback ok")); + accumulator.accept(new AssistantDone(Optional.empty(), Optional.of("end_turn"))); + + AgentMessage message = accumulator.toMessage("fallback", false); + + assertThat(message.id()).isEqualTo("msg-a"); + assertThat(message.content()).singleElement().satisfies(block -> { + assertThat(block.kind()).isEqualTo(ContentBlockKind.TEXT); + assertThat(block.text()).isEqualTo("fallback ok"); + }); + assertThat(message.stopReason()).contains("end_turn"); + } } diff --git a/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java b/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java index 1324c8fe..2d6db13c 100644 --- a/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java +++ b/lypi-agent-core/src/test/java/cn/lypi/agent/DefaultTurnExecutorTest.java @@ -20,16 +20,20 @@ import cn.lypi.contracts.event.MessageDeltaEvent; import cn.lypi.contracts.event.MessageEndEvent; import cn.lypi.contracts.event.MessageStartEvent; +import cn.lypi.contracts.event.ProviderFallbackEndEvent; +import cn.lypi.contracts.event.ProviderFallbackStartEvent; import cn.lypi.contracts.event.RetryEndEvent; import cn.lypi.contracts.event.RetryStartEvent; import cn.lypi.contracts.event.TurnEndEvent; import cn.lypi.contracts.event.TurnStartEvent; import cn.lypi.contracts.event.ToolEndEvent; import cn.lypi.contracts.event.ToolStartEvent; +import cn.lypi.contracts.common.AbortSignal; import cn.lypi.contracts.common.JsonSchema; import cn.lypi.contracts.model.AssistantDone; import cn.lypi.contracts.model.AssistantError; import cn.lypi.contracts.model.AssistantStart; +import cn.lypi.contracts.model.ProviderFallbackNotice; import cn.lypi.contracts.model.ProviderRetryNotice; import cn.lypi.contracts.model.ProviderConversationState; import cn.lypi.contracts.model.TextDelta; @@ -352,6 +356,178 @@ void mapsProviderRetryNoticeToRetryEventsWithoutAppendingTranscriptNoise() { assertThat(state.newMessages()).hasSize(2); } + @Test + void mapsProviderFallbackNoticeToLifecycleEventsWithoutAppendingTranscriptNoise() { + AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); + AgentCoreTestFixtures.StubAiProvider provider = new AgentCoreTestFixtures.StubAiProvider(); + AgentCoreTestFixtures.StubToolRuntime tools = new AgentCoreTestFixtures.StubToolRuntime(); + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + MutableTestClock clock = new MutableTestClock(NOW); + provider.enqueueProbe(List.of( + providerFallbackNotice(1, 2, "responses/websocket", "responses/sse"), + new AssistantStart("msg-assistant"), + new TextDelta("fallback ok"), + new AssistantDone(Optional.empty(), Optional.of("end_turn")) + ), event -> { + if (event instanceof AssistantStart || event instanceof TextDelta) { + clock.advance(java.time.Duration.ofSeconds(1)); + } + }); + ContextAssembler assembler = request -> new ContextAssembly( + AgentCoreTestFixtures.minimalContext(session.messages()), + AgentCoreTestFixtures.emptyResources(), + List.of(), + List.of(), + List.of(), + false + ); + DefaultTurnExecutor executor = new DefaultTurnExecutor( + AgentCoreTestFixtures.ports( + session, + provider, + tools, + eventBus, + assembler, + new NoopCompactionCoordinator(), + new NoopMemoryExtractionWorker() + ), + TurnIds.fixed("turn-1", "msg-user", "msg-fallback"), + clock + ); + + TurnState state = executor.execute(new TurnRequest("session-1", "hello", Optional.empty(), () -> false)); + + assertThat(state.status()).isEqualTo(TurnStatus.COMPLETED); + assertThat(session.messages()).extracting(AgentMessage::id) + .containsExactly("msg-user", "msg-assistant"); + assertThat(eventBus.events.stream() + .filter(event -> event instanceof ProviderFallbackStartEvent || event instanceof ProviderFallbackEndEvent)) + .containsExactly( + new ProviderFallbackStartEvent( + "session-1", + "responses/websocket", + "responses/sse", + "fallback_candidate", + NOW + ), + new ProviderFallbackEndEvent("session-1", "responses/sse", true, NOW.plusSeconds(2)) + ); + assertThat(state.newMessages()).hasSize(2); + } + + @Test + void marksProviderFallbackFailedWhenAttemptProducesAssistantError() { + FallbackTurnHarness harness = fallbackTurnHarness(Clock.fixed(NOW, ZoneOffset.UTC)); + harness.provider().enqueue(List.of( + providerFallbackNotice(1, 2, "responses/websocket", "responses/sse"), + new AssistantStart("msg-error"), + new AssistantError("provider.request_failed", "Provider request failed.") + )); + + TurnState state = harness.execute(() -> false); + + assertThat(state.status()).isEqualTo(TurnStatus.FAILED); + assertThat(harness.session().messages()).extracting(AgentMessage::id) + .containsExactly("msg-user", "msg-error"); + assertThat(providerFallbackStarts(harness.eventBus())).hasSize(1); + assertThat(providerFallbackEnds(harness.eventBus())) + .containsExactly(new ProviderFallbackEndEvent("session-1", "responses/sse", false, NOW)); + } + + @Test + void marksProviderFallbackFailedWhenStreamThrows() { + FallbackTurnHarness harness = fallbackTurnHarness(Clock.fixed(NOW, ZoneOffset.UTC)); + harness.provider().enqueueFailingAfter( + List.of( + providerFallbackNotice(1, 2, "responses/websocket", "responses/sse"), + new AssistantStart("msg-assistant") + ), + new RuntimeException("stream interrupted") + ); + + TurnState state = harness.execute(() -> false); + + assertThat(state.status()).isEqualTo(TurnStatus.FAILED); + assertThat(providerFallbackStarts(harness.eventBus())).hasSize(1); + assertThat(providerFallbackEnds(harness.eventBus())) + .containsExactly(new ProviderFallbackEndEvent("session-1", "responses/sse", false, NOW)); + } + + @Test + void marksProviderFallbackFailedWhenTurnIsAborted() { + FallbackTurnHarness harness = fallbackTurnHarness(Clock.fixed(NOW, ZoneOffset.UTC)); + AtomicBoolean aborted = new AtomicBoolean(); + harness.provider().enqueue(List.of( + providerFallbackNotice(1, 2, "responses/websocket", "responses/sse"), + new AssistantStart("msg-partial"), + new TextDelta("partial") + )); + + TurnState state = harness.execute(() -> aborted.getAndSet(true)); + + assertThat(state.status()).isEqualTo(TurnStatus.ABORTED); + assertThat(providerFallbackStarts(harness.eventBus())).hasSize(1); + assertThat(providerFallbackEnds(harness.eventBus())) + .containsExactly(new ProviderFallbackEndEvent("session-1", "responses/sse", false, NOW)); + } + + @Test + void closesRetryAndPreviousFallbackBeforeStartingConsecutiveFallback() { + FallbackTurnHarness harness = fallbackTurnHarness(Clock.fixed(NOW, ZoneOffset.UTC)); + harness.provider().enqueue(List.of( + providerFallbackNotice(1, 2, "responses/websocket", "responses/sse"), + new ProviderRetryNotice( + "openai", + 1, + 1, + java.time.Duration.ofMillis(500), + "transient", + "provider.transient", + "Connection reset" + ), + providerFallbackNotice(2, 3, "responses/sse", "chat_completions/sse"), + new AssistantStart("msg-assistant"), + new TextDelta("fallback ok"), + new AssistantDone(Optional.empty(), Optional.of("end_turn")) + )); + + TurnState state = harness.execute(() -> false); + + assertThat(state.status()).isEqualTo(TurnStatus.COMPLETED); + assertThat(providerFallbackStarts(harness.eventBus())) + .extracting(ProviderFallbackStartEvent::toMode) + .containsExactly("responses/sse", "chat_completions/sse"); + assertThat(providerFallbackEnds(harness.eventBus())) + .extracting(ProviderFallbackEndEvent::toMode, ProviderFallbackEndEvent::success) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("responses/sse", false), + org.assertj.core.groups.Tuple.tuple("chat_completions/sse", true) + ); + assertThat(providerFallbackStarts(harness.eventBus())) + .hasSameSizeAs(providerFallbackEnds(harness.eventBus())); + assertThat(harness.eventBus().events.stream() + .filter(RetryEndEvent.class::isInstance) + .map(RetryEndEvent.class::cast) + .map(RetryEndEvent::success)) + .containsExactly(false); + } + + @Test + void marksProviderFallbackFailedWhenStreamEndsWithoutAnotherEvent() { + FallbackTurnHarness harness = fallbackTurnHarness(Clock.fixed(NOW, ZoneOffset.UTC)); + harness.provider().enqueue(List.of( + providerFallbackNotice(1, 2, "responses/websocket", "responses/sse"), + new AssistantStart("msg-assistant") + )); + + TurnState state = harness.execute(() -> false); + + assertThat(state.status()).isEqualTo(TurnStatus.COMPLETED); + assertThat(providerFallbackStarts(harness.eventBus())).hasSize(1); + assertThat(providerFallbackEnds(harness.eventBus())) + .containsExactly(new ProviderFallbackEndEvent("session-1", "responses/sse", false, NOW)); + } + @Test void marksProviderRetryEndFailedWhenRetryProducesAssistantError() { AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); @@ -2587,6 +2763,82 @@ private List messageDeltas(AgentCoreTestFixtures.RecordingEve .toList(); } + private static FallbackTurnHarness fallbackTurnHarness(Clock clock) { + AgentCoreTestFixtures.InMemorySessionManager session = new AgentCoreTestFixtures.InMemorySessionManager(); + AgentCoreTestFixtures.StubAiProvider provider = new AgentCoreTestFixtures.StubAiProvider(); + AgentCoreTestFixtures.StubToolRuntime tools = new AgentCoreTestFixtures.StubToolRuntime(); + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + ContextAssembler assembler = request -> new ContextAssembly( + AgentCoreTestFixtures.minimalContext(session.messages()), + AgentCoreTestFixtures.emptyResources(), + List.of(), + List.of(), + List.of(), + false + ); + DefaultTurnExecutor executor = new DefaultTurnExecutor( + AgentCoreTestFixtures.ports( + session, + provider, + tools, + eventBus, + assembler, + new NoopCompactionCoordinator(), + new NoopMemoryExtractionWorker() + ), + TurnIds.fixed("turn-1", "msg-user", "msg-fallback"), + clock + ); + return new FallbackTurnHarness(session, provider, eventBus, executor); + } + + private static List providerFallbackStarts( + AgentCoreTestFixtures.RecordingEventBus eventBus + ) { + return eventBus.events.stream() + .filter(ProviderFallbackStartEvent.class::isInstance) + .map(ProviderFallbackStartEvent.class::cast) + .toList(); + } + + private static List providerFallbackEnds( + AgentCoreTestFixtures.RecordingEventBus eventBus + ) { + return eventBus.events.stream() + .filter(ProviderFallbackEndEvent.class::isInstance) + .map(ProviderFallbackEndEvent.class::cast) + .toList(); + } + + private static ProviderFallbackNotice providerFallbackNotice( + int fromAttempt, + int toAttempt, + String fromMode, + String toMode + ) { + return new ProviderFallbackNotice( + "openai", + fromAttempt, + toAttempt, + fromMode, + toMode, + "fallback_candidate", + "provider.fallback_candidate", + "Provider attempt failed" + ); + } + + private record FallbackTurnHarness( + AgentCoreTestFixtures.InMemorySessionManager session, + AgentCoreTestFixtures.StubAiProvider provider, + AgentCoreTestFixtures.RecordingEventBus eventBus, + DefaultTurnExecutor executor + ) { + private TurnState execute(AbortSignal signal) { + return executor.execute(new TurnRequest("session-1", "hello", Optional.empty(), signal)); + } + } + private static TurnIds countingIds() { return new TurnIds() { private int index; diff --git a/lypi-agent-core/src/test/java/cn/lypi/agent/TurnEventPublisherTest.java b/lypi-agent-core/src/test/java/cn/lypi/agent/TurnEventPublisherTest.java index 85411510..87cb2376 100644 --- a/lypi-agent-core/src/test/java/cn/lypi/agent/TurnEventPublisherTest.java +++ b/lypi-agent-core/src/test/java/cn/lypi/agent/TurnEventPublisherTest.java @@ -8,7 +8,10 @@ import cn.lypi.contracts.event.MessageBlockSnapshot; import cn.lypi.contracts.event.MessageEndEvent; import cn.lypi.contracts.event.MessageStartEvent; +import cn.lypi.contracts.event.ProviderFallbackEndEvent; +import cn.lypi.contracts.event.ProviderFallbackStartEvent; import cn.lypi.contracts.event.TurnEndEvent; +import cn.lypi.contracts.model.ProviderFallbackNotice; import java.time.Clock; import java.time.Instant; import java.time.ZoneOffset; @@ -64,4 +67,34 @@ void snapshotsToolCallMetadataForMessageEnd() { .containsEntry("complete", true) .containsEntry("inputSummary", "read {path=pom.xml}"); } + + @Test + void mapsProviderFallbackNoticeToLifecycleEvents() { + AgentCoreTestFixtures.RecordingEventBus eventBus = new AgentCoreTestFixtures.RecordingEventBus(); + TurnEventPublisher publisher = new TurnEventPublisher(eventBus, Clock.fixed(NOW, ZoneOffset.UTC)); + ProviderFallbackNotice notice = new ProviderFallbackNotice( + "openai", + 1, + 2, + "responses/websocket", + "responses/sse", + "fallback_candidate", + "provider.fallback_candidate", + "WebSocket handshake failed" + ); + + publisher.publishProviderFallbackStart("session-1", notice); + publisher.publishProviderFallbackEnd("session-1", notice, true); + + assertThat(eventBus.events).containsExactly( + new ProviderFallbackStartEvent( + "session-1", + "responses/websocket", + "responses/sse", + "fallback_candidate", + NOW + ), + new ProviderFallbackEndEvent("session-1", "responses/sse", true, NOW) + ); + } } From 86beaca9ab8edff4b12f36c95a0ffc93db8464c2 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 18:07:26 +0800 Subject: [PATCH 24/58] =?UTF-8?q?feat(tui):=20=E5=B1=95=E7=A4=BA=20provide?= =?UTF-8?q?r=20fallback=20=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lypi/transport/tui/TuiEventReducer.java | 9 +++ .../cn/lypi/transport/tui/TuiRenderState.java | 24 +++++++ .../JLineTuiTransportRenderPipelineTest.java | 47 ++++++++++++ .../transport/tui/TuiEventReducerTest.java | 72 +++++++++++++++++++ 4 files changed, 152 insertions(+) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java index af29de14..40b4211a 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java @@ -13,6 +13,8 @@ import cn.lypi.contracts.event.MessageStartEvent; import cn.lypi.contracts.event.PermissionDecisionEvent; import cn.lypi.contracts.event.PermissionRequestEvent; +import cn.lypi.contracts.event.ProviderFallbackEndEvent; +import cn.lypi.contracts.event.ProviderFallbackStartEvent; import cn.lypi.contracts.event.RetryEndEvent; import cn.lypi.contracts.event.RetryStartEvent; import cn.lypi.contracts.event.SessionStartEvent; @@ -103,6 +105,12 @@ public TuiViewModel reduce(AgentEvent event) { case TurnEndEvent end -> state.turnEnded(end.durationMillis()); case RetryStartEvent start -> state.retryStarted(start.attempt(), start.reason()); case RetryEndEvent ignored -> state.retryEnded(); + case ProviderFallbackStartEvent start -> state.providerFallbackStarted( + start.fromMode(), + start.toMode(), + start.reason() + ); + case ProviderFallbackEndEvent end -> state.providerFallbackEnded(end.toMode(), end.success()); case CompactStartEvent start -> state.compactStarted(start.kind()); case CompactEndEvent ignored -> state.compactEnded(); case InterruptEvent interrupt -> { @@ -531,6 +539,7 @@ private void reducePermissionDecision(PermissionDecisionEvent event) { } private void reduceError(ErrorEvent event) { + state.providerErrorObserved(); state.addBlock(new TuiErrorBlock( event.errorId(), event.message() diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java index c07f3592..c2375859 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java @@ -47,6 +47,7 @@ final class TuiRenderState { private Instant lastTurnObservedAt; private String lastTurnDurationLine; private String retryLine; + private String fallbackLine; private String compactLine; private String interruptLine; @@ -312,6 +313,7 @@ void turnEnded(long durationMillis) { activeTurnStartedAt = null; lastTurnObservedAt = null; retryLine = ""; + fallbackLine = ""; compactLine = ""; interruptLine = ""; lastTurnDurationLine = "worked " + formatTurnDuration(durationMillis); @@ -329,6 +331,23 @@ void retryEnded() { statusBar = withMode(currentMode()); } + void providerFallbackStarted(String fromMode, String toMode, String reason) { + fallbackLine = "fallback " + valueOrEmpty(fromMode) + " -> " + valueOrEmpty(toMode) + suffix(reason); + retryLine = ""; + interruptLine = ""; + statusBar = withMode("running"); + } + + void providerFallbackEnded(String toMode, boolean success) { + fallbackLine = success ? "" : "fallback failed" + suffix(toMode); + statusBar = withMode(currentMode()); + } + + void providerErrorObserved() { + fallbackLine = ""; + statusBar = withMode(currentMode()); + } + void compactStarted(String kind) { compactLine = "compacting" + suffix(kind); interruptLine = ""; @@ -345,6 +364,7 @@ void interrupted(String reason) { runningToolUseIds.clear(); runtimeInterruptibleTool = false; retryLine = ""; + fallbackLine = ""; compactLine = ""; activeTurnId = ""; activeTurnStartedAt = null; @@ -391,6 +411,9 @@ private String runtimeLine() { if (retryLine != null && !retryLine.isBlank()) { return retryLine; } + if (fallbackLine != null && !fallbackLine.isBlank()) { + return fallbackLine; + } if (interruptLine != null && !interruptLine.isBlank()) { return interruptLine; } @@ -413,6 +436,7 @@ private void clearRuntimeLines() { lastTurnObservedAt = null; lastTurnDurationLine = ""; retryLine = ""; + fallbackLine = ""; compactLine = ""; interruptLine = ""; } diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java index 93533a0d..4d8c1dce 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java @@ -16,6 +16,8 @@ import cn.lypi.contracts.event.EventSubscription; import cn.lypi.contracts.event.MessageDeltaEvent; import cn.lypi.contracts.event.PermissionRequestEvent; +import cn.lypi.contracts.event.ProviderFallbackEndEvent; +import cn.lypi.contracts.event.ProviderFallbackStartEvent; import cn.lypi.contracts.event.RetryStartEvent; import cn.lypi.contracts.event.ToolEndEvent; import cn.lypi.contracts.event.ToolProgressEvent; @@ -449,6 +451,51 @@ void retryStatusRendersAsTransientTranscriptLineWithoutMovingStatusBar() { assertTrue(latest.getLast().contains("ses_1")); } + @Test + void providerFallbackRendersUntilSuccessfulOutputWithoutMovingStatusBar() { + RecordingEventBus events = new RecordingEventBus(); + List> frames = new ArrayList<>(); + JLineTuiTransport transport = JLineTuiTransport.withRenderer(frames::add, 100, 6); + Instant timestamp = Instant.parse("2026-06-09T00:00:00Z"); + + transport.attach(events, TestRuntimeStates.basic("ses_1")); + events.emit(new ProviderFallbackStartEvent( + "ses_1", + "responses/websocket", + "responses/sse", + "provider.fallback_candidate", + timestamp + )); + transport.flushPendingFrameForTest(); + + List fallbackFrame = frames.getLast(); + assertTrue(fallbackFrame.contains( + "· fallback responses/websocket -> responses/sse provider.fallback_candidate" + )); + assertTrue(fallbackFrame.getLast().contains("ses_1")); + + events.emit(new ProviderFallbackEndEvent("ses_1", "responses/sse", true, timestamp.plusMillis(1))); + events.emit(new MessageDeltaEvent( + "ses_1", + "msg_1", + MessageRole.ASSISTANT, + MessageKind.TEXT, + "block_1", + ContentBlockKind.TEXT, + "fallback ok", + true, + java.util.Map.of(), + timestamp.plusMillis(2) + )); + transport.flushPendingFrameForTest(); + + List outputFrame = frames.getLast(); + assertTrue(outputFrame.contains("fallback ok")); + assertFalse(outputFrame.stream().anyMatch(line -> line.contains("· fallback"))); + assertEquals(fallbackFrame.size(), outputFrame.size()); + assertTrue(outputFrame.getLast().contains("ses_1")); + } + @Test void inputAfterResizeUsesUpdatedDimensions() throws Exception { RecordingEventBus events = new RecordingEventBus(); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java index 53ae7c37..535f778f 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java @@ -25,6 +25,8 @@ import cn.lypi.contracts.event.PermissionDecisionEvent; import cn.lypi.contracts.event.PermissionRequestEvent; import cn.lypi.contracts.event.PermissionResponseEvent; +import cn.lypi.contracts.event.ProviderFallbackEndEvent; +import cn.lypi.contracts.event.ProviderFallbackStartEvent; import cn.lypi.contracts.event.RetryEndEvent; import cn.lypi.contracts.event.RetryStartEvent; import cn.lypi.contracts.event.SessionStartEvent; @@ -449,6 +451,76 @@ void runtimeEventsUpdateEphemeralRuntimeLineWithoutAddingBlocks() { assertEquals(0, reducer.view().blocks().size()); } + @Test + void providerFallbackEventsUpdateEphemeralRuntimeLineWithExistingPriorities() { + TuiEventReducer reducer = TuiEventReducer.withRuntimeState(TestRuntimeStates.basic("ses_1")); + reducer.reduce(new TurnStartEvent("ses_1", "turn_1", NOW)); + + reducer.reduce(new ProviderFallbackStartEvent( + "ses_1", + "responses/websocket", + "responses/sse", + "provider.fallback_candidate", + NOW + )); + assertEquals( + "fallback responses/websocket -> responses/sse provider.fallback_candidate", + reducer.view().runtimeLine() + ); + assertEquals(0, reducer.view().blocks().size()); + + reducer.reduce(new CompactStartEvent("ses_1", "session", NOW)); + assertEquals("compacting session", reducer.view().runtimeLine()); + reducer.reduce(new CompactEndEvent("ses_1", "compact_1", NOW)); + assertEquals( + "fallback responses/websocket -> responses/sse provider.fallback_candidate", + reducer.view().runtimeLine() + ); + + reducer.reduce(new RetryStartEvent("ses_1", 2, "rate limit", NOW)); + assertEquals("retrying attempt 2 rate limit", reducer.view().runtimeLine()); + reducer.reduce(new RetryEndEvent("ses_1", 2, true, NOW)); + assertEquals( + "fallback responses/websocket -> responses/sse provider.fallback_candidate", + reducer.view().runtimeLine() + ); + + reducer.reduce(new ProviderFallbackEndEvent("ses_1", "responses/sse", true, NOW)); + assertEquals("working (0s)", reducer.view().runtimeLine()); + } + + @Test + void failedProviderFallbackLineIsReplacedByErrorAndClearedByRuntimeBoundaries() { + TuiEventReducer reducer = TuiEventReducer.withRuntimeState(TestRuntimeStates.basic("ses_1")); + reducer.reduce(new TurnStartEvent("ses_1", "turn_1", NOW)); + ProviderFallbackStartEvent start = new ProviderFallbackStartEvent( + "ses_1", + "responses/websocket", + "responses/sse", + "provider.fallback_candidate", + NOW + ); + + reducer.reduce(start); + reducer.reduce(new ProviderFallbackEndEvent("ses_1", "responses/sse", false, NOW)); + assertEquals("fallback failed responses/sse", reducer.view().runtimeLine()); + + reducer.reduce(new ErrorEvent("ses_1", "provider.request_failed", "request failed", NOW)); + assertEquals("working (0s)", reducer.view().runtimeLine()); + + reducer.reduce(start); + reducer.reduce(new InterruptEvent("ses_1", "esc", NOW)); + assertEquals("interrupted esc", reducer.view().runtimeLine()); + + reducer.reduce(start); + reducer.reduce(new TurnEndEvent("ses_1", "turn_1", "FAILED", NOW)); + assertEquals("worked 0s", reducer.view().runtimeLine()); + + reducer.reduce(start); + reducer.configureRuntimeState(TestRuntimeStates.basic("ses_2")); + assertEquals("", reducer.view().runtimeLine()); + } + @Test void turnRuntimeLineShowsWorkingElapsedTime() { TuiEventReducer reducer = TuiEventReducer.withRuntimeState(TestRuntimeStates.basic("ses_1")); From a195eb6d4fc4bfef95f9826480800e5faa47c7c3 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 18:13:18 +0800 Subject: [PATCH 25/58] =?UTF-8?q?docs(architecture):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=20provider=20fallback=20=E4=BA=8B=E4=BB=B6=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/skills/lypi-architecture/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.codex/skills/lypi-architecture/SKILL.md b/.codex/skills/lypi-architecture/SKILL.md index 9c30f4d8..a010397c 100644 --- a/.codex/skills/lypi-architecture/SKILL.md +++ b/.codex/skills/lypi-architecture/SKILL.md @@ -47,7 +47,8 @@ The root `pom.xml` defines these Maven modules: - Transports adapt input/output and display. They should not own durable session or tool state. - Session history is append-only JSONL; branch movement changes the leaf, not old entries. - Permission runtime changes are represented by session entries and replayed into `SessionContext`; do not mutate historical entries to change permission state. -- Tool calls, permission decisions, retry, compaction and UI updates flow through contract events where possible. +- Tool calls, permission decisions, retry, provider fallback, compaction and UI updates flow through contract events where possible. +- Provider retry and fallback notices are stream control events: agent core maps them to lifecycle events, TUI projects transient state, and neither enters the durable transcript. - Permission request/decision events expose approval kind, available decisions and additional permission metadata for TUI/headless rendering. - Memory consolidation is driven by `TurnEndEvent` after the main turn completes; `DefaultTurnExecutor` must not synchronously call legacy `MemoryExtractionWorker` on the user-facing path. - `TurnEndEvent.leafEntryId` is the stable fork point for background consolidation. Runtime listeners must use this event field instead of the mutable `SessionManagerPort.currentView().leafId()`. From cece49de034cca422fea0347873cf5e0518a9acf Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 18:17:39 +0800 Subject: [PATCH 26/58] =?UTF-8?q?fix(ai):=20=E4=BF=9D=E7=95=99=20provider?= =?UTF-8?q?=20fallback=20=E9=80=9A=E7=9F=A5=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openai/OpenAiAssistantEventStream.java | 11 +++-- .../OpenAiCompatibleProviderAdapter.java | 8 +++- .../OpenAiCompatibleProviderAdapterTest.java | 45 ++++++++++++++++++- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiAssistantEventStream.java b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiAssistantEventStream.java index 664e1d75..eb0657b8 100644 --- a/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiAssistantEventStream.java +++ b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiAssistantEventStream.java @@ -33,6 +33,7 @@ public final class OpenAiAssistantEventStream implements AssistantEventStream { private final List attempts; + private final String provider; private final AbortSignal signal; private final ProviderFallbackDecider fallbackDecider; private final ProviderErrorClassifier errorClassifier = new ProviderErrorClassifier(); @@ -58,14 +59,16 @@ public final class OpenAiAssistantEventStream implements AssistantEventStream { public OpenAiAssistantEventStream( List attempts, + String provider, AbortSignal signal, ProviderFallbackDecider fallbackDecider, int maxRetries ) { this.attempts = List.copyOf(Objects.requireNonNull(attempts, "attempts")); + this.provider = Objects.requireNonNull(provider, "provider"); this.signal = Objects.requireNonNull(signal, "signal"); this.fallbackDecider = Objects.requireNonNull(fallbackDecider, "fallbackDecider"); - this.retryCoordinator = ProviderRetryCoordinator.defaultSleep("openai", ProviderRetryPolicy.defaults(maxRetries)); + this.retryCoordinator = ProviderRetryCoordinator.defaultSleep(provider, ProviderRetryPolicy.defaults(maxRetries)); } @Override @@ -263,13 +266,13 @@ private void handleAttemptFailure(RuntimeException exception) { && attemptIndex < attempts.size()) { ProviderErrorClassification classification = errorClassifier.classify(exception, visibleOutputStarted); pendingEvents.add(new ProviderFallbackNotice( - "openai", + provider, failedAttemptIndex + 1, attemptIndex + 1, attempts.get(failedAttemptIndex).mode(), attempts.get(attemptIndex).mode(), - classification.reason(), - classification.errorId(), + classification.fallbackAllowed() ? classification.reason() : "fallback_candidate", + classification.fallbackAllowed() ? classification.errorId() : "provider.fallback_candidate", exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage() )); return; diff --git a/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapter.java b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapter.java index fe7eb848..a32049b9 100644 --- a/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapter.java +++ b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapter.java @@ -131,7 +131,13 @@ public AssistantEventStream stream( if (!options.sessionId().isBlank()) { request = requestWithPromptCacheKey(request, options.sessionId()); } - return new OpenAiAssistantEventStream(attempts(request), signal, fallbackDecider, config.maxRetries()); + return new OpenAiAssistantEventStream( + attempts(request), + config.provider(), + signal, + fallbackDecider, + config.maxRetries() + ); } private LypiModelRequest requestWithPromptCacheKey(LypiModelRequest request, String promptCacheKey) { diff --git a/lypi-ai/src/test/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapterTest.java b/lypi-ai/src/test/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapterTest.java index 16af2b50..cfd35f0a 100644 --- a/lypi-ai/src/test/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapterTest.java +++ b/lypi-ai/src/test/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapterTest.java @@ -433,6 +433,34 @@ void emitsFallbackNoticeBeforeOpeningNextProviderAttempt() { } } + @Test + void fallbackNoticeUsesConfiguredProviderAndFallbackClassification() { + RecordingTransport websocket = RecordingTransport.fail( + "Provider HTTP 400: previous_response_id is unsupported" + ); + RecordingTransport sse = RecordingTransport.events( + "{\"type\":\"response.created\",\"response\":{\"id\":\"resp-fallback\"}}", + "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp-fallback\"}}" + ); + OpenAiCompatibleProviderAdapter adapter = new OpenAiCompatibleProviderAdapter( + config("gateway", TransportMode.AUTO, "test-key"), + websocket, + sse, + RecordingTransport.events() + ); + + List events = collect(adapter.stream(context(), descriptor(), () -> false)); + + assertThat(events) + .filteredOn(ProviderFallbackNotice.class::isInstance) + .singleElement() + .isInstanceOfSatisfying(ProviderFallbackNotice.class, notice -> { + assertThat(notice.provider()).isEqualTo("gateway"); + assertThat(notice.reason()).isEqualTo("fallback_candidate"); + assertThat(notice.errorId()).isEqualTo("provider.fallback_candidate"); + }); + } + @Test void reportsErrorWhenAttemptClosesAfterOutputWithoutAssistantDone() { RecordingTransport websocket = RecordingTransport.events( @@ -774,6 +802,10 @@ private static OpenAiProviderConfig config(TransportMode transportMode, String a return config(transportMode, apiKey, RequestStyle.RESPONSES, RequestStyle.CHAT_COMPLETIONS); } + private static OpenAiProviderConfig config(String provider, TransportMode transportMode, String apiKey) { + return config(provider, transportMode, apiKey, RequestStyle.RESPONSES, RequestStyle.CHAT_COMPLETIONS, 0); + } + private static List collect(AssistantEventStream stream) { try (stream) { return StreamSupport.stream(stream.spliterator(), false).toList(); @@ -795,9 +827,20 @@ private static OpenAiProviderConfig config( RequestStyle requestStyle, RequestStyle fallbackRequestStyle, int maxRetries + ) { + return config("openai", transportMode, apiKey, requestStyle, fallbackRequestStyle, maxRetries); + } + + private static OpenAiProviderConfig config( + String provider, + TransportMode transportMode, + String apiKey, + RequestStyle requestStyle, + RequestStyle fallbackRequestStyle, + int maxRetries ) { return new OpenAiProviderConfig( - "openai", + provider, URI.create("https://api.openai.test/v1"), Optional.empty(), "/v1/responses", From 1a5b33db627a3e1b84ddc5524df336fad02e4fd3 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 18:31:49 +0800 Subject: [PATCH 27/58] =?UTF-8?q?test(tui):=20=E5=9B=BA=E5=AE=9A=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E5=85=B3=E8=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/tui/TuiEventReducerTest.java | 7 +- .../tui/TuiTranscriptProjectorTest.java | 223 ++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptProjectorTest.java diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java index 535f778f..f753d61b 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java @@ -709,7 +709,7 @@ void runtimeTranscriptProjectsToolResultWithoutDuplicateToolMessage() { List.of(new ToolCallContentBlock("call_1", "read", "", Map.of( "input", Map.of("path", "AGENTS.md"), "complete", true, - "inputSummary", "read {path=AGENTS.md}" + "inputSummary", "read AGENTS.md" ))), NOW.plusMillis(1), Optional.empty(), @@ -749,6 +749,11 @@ void runtimeTranscriptProjectsToolResultWithoutDuplicateToolMessage() { .orElseThrow(); assertEquals("call_1", tool.toolUseId()); assertEquals("read", tool.toolName()); + assertEquals(TuiToolState.DONE, tool.state()); + assertFalse(tool.active()); + assertEquals("read AGENTS.md", tool.label()); + assertTrue(tool.details().contains("File: AGENTS.md")); + assertTrue(tool.details().contains("用户名字叫末声")); } @Test diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptProjectorTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptProjectorTest.java new file mode 100644 index 00000000..67d9cb53 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptProjectorTest.java @@ -0,0 +1,223 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import cn.lypi.contracts.context.AgentMessage; +import cn.lypi.contracts.context.ContentBlock; +import cn.lypi.contracts.context.MessageKind; +import cn.lypi.contracts.context.MessageRole; +import cn.lypi.contracts.context.TextContentBlock; +import cn.lypi.contracts.context.ToolCallContentBlock; +import cn.lypi.contracts.context.ToolResultContentBlock; +import cn.lypi.contracts.tui.SessionRuntimeState; +import cn.lypi.contracts.tui.TuiBlock; +import cn.lypi.contracts.tui.TuiMessageBlock; +import cn.lypi.contracts.tui.TuiToolBlock; +import cn.lypi.contracts.tui.TuiToolState; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class TuiTranscriptProjectorTest { + private static final Instant NOW = Instant.parse("2026-07-10T00:00:00Z"); + + @ParameterizedTest + @MethodSource("resultStates") + void projectsCompletedResultState(boolean error, String status, TuiToolState expectedState) { + Map metadata = status.isBlank() ? Map.of() : Map.of("status", status); + String resultText = "visible result\n" + "x".repeat(500); + + TuiToolBlock tool = onlyTool(project(List.of( + assistant("call-message", call("call-1", "bash", "run checks")), + result("result-message", new ToolResultContentBlock("call-1", resultText, error, metadata)) + ))); + + assertEquals(expectedState, tool.state()); + assertFalse(tool.active()); + assertTrue(tool.details().contains("visible result")); + assertTrue(tool.details().codePointCount(0, tool.details().length()) <= 200); + } + + private static Stream resultStates() { + return Stream.of( + Arguments.of(false, "", TuiToolState.DONE), + Arguments.of(true, "", TuiToolState.FAILED), + Arguments.of(true, "CANCELLED", TuiToolState.CANCELLED), + Arguments.of(true, "TIMED_OUT", TuiToolState.FAILED) + ); + } + + @Test + void matchesOutOfOrderResultsWithoutMovingOriginalCalls() { + List blocks = project(List.of( + assistant( + "calls", + call("read-1", "read", "read AGENTS.md"), + call("bash-1", "bash", "pwd") + ), + assistant("assistant-note", new TextContentBlock("working")), + user("user-note", new TextContentBlock("continue")), + result("bash-result", new ToolResultContentBlock("bash-1", "/workspace", false)), + result("read-result", new ToolResultContentBlock("read-1", "project rules", false)) + )); + + List tools = tools(blocks); + assertEquals(List.of("read-1", "bash-1"), tools.stream().map(TuiToolBlock::toolUseId).toList()); + assertEquals(List.of(TuiToolState.DONE, TuiToolState.DONE), tools.stream().map(TuiToolBlock::state).toList()); + assertTrue(tools.get(0).details().contains("project rules")); + assertTrue(tools.get(1).details().contains("/workspace")); + assertEquals("assistant", ((TuiMessageBlock) blocks.get(2)).role()); + assertEquals("user", ((TuiMessageBlock) blocks.get(3)).role()); + } + + @Test + void keepsUnfinishedCallPendingAndProjectsUnmatchedResult() { + List blocks = project(List.of( + assistant("pending-call", call("pending-1", "read", "read README.md")), + result( + "orphan-result", + new ToolResultContentBlock( + "orphan-1", + "orphan output", + false, + Map.of("toolName", "write") + ) + ) + )); + + List tools = tools(blocks); + assertEquals(2, tools.size()); + assertEquals(TuiToolState.PENDING, tools.get(0).state()); + assertFalse(tools.get(0).active()); + assertEquals("orphan-1", tools.get(1).toolUseId()); + assertEquals("write", tools.get(1).toolName()); + assertEquals(TuiToolState.DONE, tools.get(1).state()); + assertTrue(tools.get(1).details().contains("orphan output")); + assertEquals(0, blocks.stream() + .filter(TuiMessageBlock.class::isInstance) + .map(TuiMessageBlock.class::cast) + .filter(block -> "tool".equals(block.role())) + .count()); + } + + @Test + void usesStableFallbackIdsAndAppliesLastDuplicateResult() { + List firstProjection = project(edgeCaseTranscript()); + List secondProjection = project(edgeCaseTranscript()); + + List firstTools = tools(firstProjection); + List secondTools = tools(secondProjection); + assertEquals(3, firstTools.size()); + assertEquals( + firstTools.stream().map(TuiToolBlock::blockId).toList(), + secondTools.stream().map(TuiToolBlock::blockId).toList() + ); + assertNotEquals(firstTools.get(0).blockId(), firstTools.get(1).blockId()); + assertNotEquals(firstTools.get(0).toolUseId(), firstTools.get(1).toolUseId()); + assertEquals("tool:blank-calls:tool_call:0", firstTools.get(0).blockId()); + assertEquals("tool:blank-calls:tool_call:1", firstTools.get(1).blockId()); + + TuiToolBlock duplicate = firstTools.get(2); + assertEquals("duplicate-1", duplicate.toolUseId()); + assertEquals(TuiToolState.FAILED, duplicate.state()); + assertTrue(duplicate.details().contains("second result")); + assertFalse(duplicate.details().contains("first result")); + } + + private static List edgeCaseTranscript() { + return List.of( + assistant( + "blank-calls", + call("", "read", "first blank"), + call("", "bash", "second blank"), + call("duplicate-1", "read", "first duplicate"), + call("duplicate-1", "bash", "second duplicate") + ), + result("first-result", new ToolResultContentBlock("duplicate-1", "first result", false)), + result("second-result", new ToolResultContentBlock("duplicate-1", "second result", true)) + ); + } + + private static ToolCallContentBlock call(String toolUseId, String toolName, String label) { + return new ToolCallContentBlock( + toolUseId, + toolName, + "", + Map.of("inputSummary", label) + ); + } + + private static AgentMessage assistant(String id, ContentBlock... blocks) { + MessageKind kind = Stream.of(blocks).allMatch(ToolCallContentBlock.class::isInstance) + ? MessageKind.TOOL_CALL + : MessageKind.TEXT; + return message(id, MessageRole.ASSISTANT, kind, blocks); + } + + private static AgentMessage user(String id, ContentBlock... blocks) { + return message(id, MessageRole.USER, MessageKind.TEXT, blocks); + } + + private static AgentMessage result(String id, ContentBlock... blocks) { + return message(id, MessageRole.TOOL_RESULT, MessageKind.TOOL_RESULT, blocks); + } + + private static AgentMessage message( + String id, + MessageRole role, + MessageKind kind, + ContentBlock... blocks + ) { + return new AgentMessage( + id, + role, + kind, + List.of(blocks), + NOW, + Optional.empty(), + Optional.empty() + ); + } + + private static List project(List transcript) { + SessionRuntimeState base = TestRuntimeStates.basic("session-1"); + SessionRuntimeState runtimeState = new SessionRuntimeState( + base.sessionId(), + base.cwd(), + base.currentBranchLeafId(), + base.model(), + base.thinkingLevel(), + base.agentMode(), + base.permissionRuntimeState(), + base.budget(), + transcript, + false, + false, + false, + false + ); + return TuiEventReducer.fromRuntimeState(runtimeState).view().blocks(); + } + + private static TuiToolBlock onlyTool(List blocks) { + List tools = tools(blocks); + assertEquals(1, tools.size()); + return tools.getFirst(); + } + + private static List tools(List blocks) { + return blocks.stream() + .filter(TuiToolBlock.class::isInstance) + .map(TuiToolBlock.class::cast) + .toList(); + } +} From 0e2f6afb8cf795ab83bc0c930f1f783b039f527e Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 18:39:26 +0800 Subject: [PATCH 28/58] =?UTF-8?q?fix(tui):=20=E5=85=B3=E8=81=94=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=20transcript=20=E7=9A=84=E5=B7=A5=E5=85=B7=E7=BB=93?= =?UTF-8?q?=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cn/lypi/transport/tui/TuiRenderState.java | 107 +----- .../transport/tui/TuiTranscriptProjector.java | 326 ++++++++++++++++++ 2 files changed, 332 insertions(+), 101 deletions(-) create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptProjector.java diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java index c2375859..0c7c203a 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java @@ -1,10 +1,6 @@ package cn.lypi.transport.tui; import cn.lypi.contracts.common.ToolProgress; -import cn.lypi.contracts.context.AgentMessage; -import cn.lypi.contracts.context.ContentBlock; -import cn.lypi.contracts.context.MessageRole; -import cn.lypi.contracts.context.ToolCallContentBlock; import cn.lypi.contracts.event.SessionStateEvent; import cn.lypi.contracts.event.ToolEndEvent; import cn.lypi.contracts.tui.DiffView; @@ -12,10 +8,7 @@ import cn.lypi.contracts.tui.SessionFileView; import cn.lypi.contracts.tui.SessionRuntimeState; import cn.lypi.contracts.tui.StatusBarState; -import cn.lypi.contracts.tui.TuiErrorBlock; import cn.lypi.contracts.tui.TuiBlock; -import cn.lypi.contracts.tui.TuiMessageBlock; -import cn.lypi.contracts.tui.TuiThinkingBlock; import cn.lypi.contracts.tui.TuiToolBlock; import cn.lypi.contracts.tui.TuiToolState; import cn.lypi.contracts.tui.TuiViewModel; @@ -128,19 +121,20 @@ void clearDiffView() { void configure(SessionRuntimeState runtimeState) { toolProgressBuffers.clear(); + clearPermissionPrompt(); + clearDiffView(); + runningToolUseIds.clear(); + clearRuntimeLines(); if (runtimeState == null) { + replaceBlocks(List.of()); statusBar = new StatusBarState("", "", "ready", ""); agentMode = "ready"; runtimeInterruptibleTool = false; - runningToolUseIds.clear(); - clearRuntimeLines(); return; } agentMode = enumLabel(runtimeState.agentMode()); runtimeInterruptibleTool = runtimeState.hasInterruptibleTool(); - replaceBlocks(projectTranscript(runtimeState.transcript())); - runningToolUseIds.clear(); - clearRuntimeLines(); + replaceBlocks(new TuiTranscriptProjector().project(runtimeState.transcript())); statusBar = new StatusBarState( valueOrEmpty(runtimeState.sessionId()), modelLabel(runtimeState), @@ -162,61 +156,6 @@ private void replaceBlocks(List nextBlocks) { rebuildIndexes(); } - private List projectTranscript(List transcript) { - if (transcript == null || transcript.isEmpty()) { - return List.of(); - } - List projected = new ArrayList<>(); - for (AgentMessage message : transcript) { - for (int index = 0; index < message.content().size(); index++) { - ContentBlock block = message.content().get(index); - String blockId = message.id() + ":" + block.kind().name().toLowerCase() + ":" + index; - switch (block.kind()) { - case TEXT -> projected.add(new TuiMessageBlock( - blockId, - message.id(), - roleName(message.role()), - block.text(), - false - )); - case THINKING -> projected.add(new TuiThinkingBlock( - blockId, - message.id(), - block.text(), - false, - false - )); - case ERROR -> projected.add(new TuiErrorBlock(blockId, block.text())); - case TOOL_CALL -> projected.add(projectToolCall(message.id(), block, blockId)); - case TOOL_RESULT -> { - } - default -> { - } - } - } - } - return projected; - } - - private TuiToolBlock projectToolCall(String messageId, ContentBlock block, String blockId) { - String toolUseId = block instanceof ToolCallContentBlock toolCall - ? firstNonBlank(toolCall.toolUseId(), metadataString(block.metadata(), "toolUseId", blockId)) - : metadataString(block.metadata(), "toolUseId", blockId); - String toolName = block instanceof ToolCallContentBlock toolCall - ? firstNonBlank(toolCall.toolName(), metadataString(block.metadata(), "toolName", "unknown")) - : metadataString(block.metadata(), "toolName", "unknown"); - String label = metadataString(block.metadata(), "inputSummary", firstNonBlank(block.text(), toolName)); - return new TuiToolBlock( - "tool:" + toolUseId, - messageId, - toolUseId, - toolName, - TuiToolState.PENDING, - label, - false - ); - } - void toolStarted(String toolUseId) { if (toolUseId != null && !toolUseId.isBlank()) { runningToolUseIds.add(toolUseId); @@ -505,40 +444,6 @@ private String valueOrEmpty(String value) { return value == null ? "" : value; } - private String roleName(MessageRole role) { - if (role == MessageRole.USER) { - return "user"; - } - if (role == MessageRole.SYSTEM_LOCAL) { - return "system"; - } - if (role == MessageRole.TOOL_RESULT) { - return "tool"; - } - return "assistant"; - } - - private String metadataString(Map metadata, String key, String fallback) { - if (metadata == null) { - return fallback; - } - Object value = metadata.get(key); - if (value == null) { - return fallback; - } - String text = value.toString(); - return text.isBlank() ? fallback : text; - } - - private String firstNonBlank(String... values) { - for (String value : values) { - if (value != null && !value.isBlank()) { - return value; - } - } - return ""; - } - private String suffix(String value) { String safe = valueOrEmpty(value); return safe.isBlank() ? "" : " " + safe; diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptProjector.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptProjector.java new file mode 100644 index 00000000..33ab622d --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptProjector.java @@ -0,0 +1,326 @@ +package cn.lypi.transport.tui; + +import cn.lypi.contracts.context.AgentMessage; +import cn.lypi.contracts.context.ContentBlock; +import cn.lypi.contracts.context.MessageRole; +import cn.lypi.contracts.context.ToolCallContentBlock; +import cn.lypi.contracts.context.ToolResultContentBlock; +import cn.lypi.contracts.tui.TuiBlock; +import cn.lypi.contracts.tui.TuiErrorBlock; +import cn.lypi.contracts.tui.TuiMessageBlock; +import cn.lypi.contracts.tui.TuiThinkingBlock; +import cn.lypi.contracts.tui.TuiToolBlock; +import cn.lypi.contracts.tui.TuiToolState; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class TuiTranscriptProjector { + private static final int RESULT_MAX_CODE_POINTS = 200; + private static final Pattern LINE_BREAK = Pattern.compile("\\R"); + + List project(List transcript) { + if (transcript == null || transcript.isEmpty()) { + return List.of(); + } + List projected = new ArrayList<>(); + Map toolIndexes = new LinkedHashMap<>(); + for (AgentMessage message : transcript) { + if (message == null || message.content() == null) { + continue; + } + for (int index = 0; index < message.content().size(); index++) { + ContentBlock block = message.content().get(index); + if (block == null) { + continue; + } + String sourceBlockId = sourceBlockId(message.id(), block, index); + switch (block.kind()) { + case TEXT -> projected.add(new TuiMessageBlock( + sourceBlockId, + message.id(), + roleName(message.role()), + block.text(), + false + )); + case THINKING -> projected.add(new TuiThinkingBlock( + sourceBlockId, + message.id(), + block.text(), + false, + false + )); + case ERROR -> projected.add(new TuiErrorBlock(sourceBlockId, block.text())); + case TOOL_CALL -> projectToolCall(projected, toolIndexes, message.id(), block, sourceBlockId); + case TOOL_RESULT -> completeToolBlock( + projected, + toolIndexes, + message.id(), + block, + sourceBlockId + ); + default -> { + } + } + } + } + return List.copyOf(projected); + } + + private void projectToolCall( + List projected, + Map toolIndexes, + String messageId, + ContentBlock block, + String sourceBlockId + ) { + String typedToolUseId = block instanceof ToolCallContentBlock toolCall ? toolCall.toolUseId() : ""; + String toolUseId = firstNonBlank( + typedToolUseId, + metadataString(block.metadata(), "toolUseId"), + sourceBlockId + ); + if (toolIndexes.containsKey(toolUseId)) { + return; + } + String typedToolName = block instanceof ToolCallContentBlock toolCall ? toolCall.toolName() : ""; + String toolName = firstNonBlank( + typedToolName, + metadataString(block.metadata(), "toolName"), + "unknown" + ); + String label = firstNonBlank( + metadataString(block.metadata(), "inputSummary"), + block.text(), + toolName + ); + int projectedIndex = projected.size(); + projected.add(new TuiToolBlock( + "tool:" + toolUseId, + messageId, + toolUseId, + toolName, + TuiToolState.PENDING, + label, + false + )); + toolIndexes.put(toolUseId, projectedIndex); + } + + private void completeToolBlock( + List projected, + Map toolIndexes, + String messageId, + ContentBlock block, + String sourceBlockId + ) { + String typedToolUseId = block instanceof ToolResultContentBlock result ? result.toolUseId() : ""; + String toolUseId = firstNonBlank( + typedToolUseId, + metadataString(block.metadata(), "toolUseId"), + sourceBlockId + ); + Integer projectedIndex = toolIndexes.get(toolUseId); + if (projectedIndex == null) { + addUnmatchedResult(projected, toolIndexes, messageId, block, toolUseId); + return; + } + TuiBlock current = projected.get(projectedIndex); + if (!(current instanceof TuiToolBlock tool)) { + addUnmatchedResult(projected, toolIndexes, messageId, block, toolUseId); + return; + } + projected.set(projectedIndex, new TuiToolBlock( + tool.blockId(), + tool.messageId(), + tool.toolUseId(), + tool.toolName(), + stateFor(block), + tool.label(), + resultDetails(block), + false + )); + } + + private void addUnmatchedResult( + List projected, + Map toolIndexes, + String messageId, + ContentBlock result, + String toolUseId + ) { + String toolName = firstNonBlank(metadataString(result.metadata(), "toolName"), "unknown"); + String label = "unknown".equals(toolName) ? toolUseId : toolName; + int projectedIndex = projected.size(); + projected.add(new TuiToolBlock( + "tool:" + toolUseId, + messageId, + toolUseId, + toolName, + stateFor(result), + label, + resultDetails(result), + false + )); + toolIndexes.put(toolUseId, projectedIndex); + } + + static TuiToolState stateFor(ToolResultContentBlock result) { + return stateFor(result.error(), result.metadata()); + } + + private static TuiToolState stateFor(ContentBlock result) { + if (result instanceof ToolResultContentBlock typedResult) { + return stateFor(typedResult); + } + return stateFor(metadataBoolean(result.metadata(), "error"), result.metadata()); + } + + private static TuiToolState stateFor(boolean error, Map metadata) { + String status = metadataString(metadata, "status").trim().toUpperCase(Locale.ROOT); + if ("CANCELLED".equals(status)) { + return TuiToolState.CANCELLED; + } + if ("FAILED".equals(status) || "TIMED_OUT".equals(status)) { + return TuiToolState.FAILED; + } + return error ? TuiToolState.FAILED : TuiToolState.DONE; + } + + static String resultDetails(ToolResultContentBlock result) { + return resultSummary(result == null ? "" : result.text()); + } + + private static String resultDetails(ContentBlock result) { + return resultSummary(result == null ? "" : result.text()); + } + + static String resultSummary(String text) { + String normalized = normalizeSingleLine(text); + int hiddenLines = lineBreakCount(text); + if (hiddenLines == 0) { + return truncate(normalized, RESULT_MAX_CODE_POINTS); + } + String suffix = " (+" + hiddenLines + " lines)"; + if (codePointCount(normalized) + codePointCount(suffix) <= RESULT_MAX_CODE_POINTS) { + return normalized + suffix; + } + int available = RESULT_MAX_CODE_POINTS - codePointCount(suffix) - 1; + if (available <= 0) { + return truncate(suffix.strip(), RESULT_MAX_CODE_POINTS); + } + return prefix(normalized, available) + "…" + suffix; + } + + private static String sourceBlockId(String messageId, ContentBlock block, int index) { + return valueOrEmpty(messageId) + ":" + block.kind().name().toLowerCase(Locale.ROOT) + ":" + index; + } + + private static String roleName(MessageRole role) { + if (role == MessageRole.USER) { + return "user"; + } + if (role == MessageRole.SYSTEM_LOCAL) { + return "system"; + } + if (role == MessageRole.TOOL_RESULT) { + return "tool"; + } + return "assistant"; + } + + private static String metadataString(Map metadata, String key) { + if (metadata == null || metadata.get(key) == null) { + return ""; + } + return metadata.get(key).toString(); + } + + private static boolean metadataBoolean(Map metadata, String key) { + if (metadata == null || metadata.get(key) == null) { + return false; + } + Object value = metadata.get(key); + return value instanceof Boolean booleanValue + ? booleanValue + : Boolean.parseBoolean(value.toString()); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return ""; + } + + private static String normalizeSingleLine(String value) { + if (value == null || value.isEmpty()) { + return ""; + } + StringBuilder normalized = new StringBuilder(value.length()); + boolean pendingSpace = false; + for (int offset = 0; offset < value.length();) { + int codePoint = value.codePointAt(offset); + offset += Character.charCount(codePoint); + if (Character.isISOControl(codePoint) + || Character.isWhitespace(codePoint) + || Character.isSpaceChar(codePoint)) { + pendingSpace = !normalized.isEmpty(); + continue; + } + if (pendingSpace) { + normalized.append(' '); + pendingSpace = false; + } + normalized.appendCodePoint(codePoint); + } + return normalized.toString(); + } + + private static String truncate(String value, int maxCodePoints) { + if (value == null || maxCodePoints <= 0) { + return ""; + } + if (codePointCount(value) <= maxCodePoints) { + return value; + } + if (maxCodePoints == 1) { + return "…"; + } + return prefix(value, maxCodePoints - 1) + "…"; + } + + private static String prefix(String value, int codePoints) { + if (value == null || value.isEmpty() || codePoints <= 0) { + return ""; + } + int count = Math.min(codePoints, codePointCount(value)); + return value.substring(0, value.offsetByCodePoints(0, count)); + } + + private static int lineBreakCount(String value) { + if (value == null || value.isEmpty()) { + return 0; + } + int count = 0; + Matcher matcher = LINE_BREAK.matcher(value); + while (matcher.find()) { + count++; + } + return count; + } + + private static int codePointCount(String value) { + return value == null ? 0 : value.codePointCount(0, value.length()); + } + + private static String valueOrEmpty(String value) { + return value == null ? "" : value; + } +} From f0e79a47153ec75bcedc88690cbc8a77fa7e5e6f Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 18:43:17 +0800 Subject: [PATCH 29/58] =?UTF-8?q?refactor(tui):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E4=B8=8E=E6=81=A2=E5=A4=8D=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lypi/transport/tui/TuiEventReducer.java | 13 +- .../transport/tui/TuiToolProgressBuffer.java | 2 +- .../transport/tui/TuiTranscriptProjector.java | 18 ++- .../tui/TuiContractEndToEndTest.java | 144 ++++++++++++++++++ 4 files changed, 161 insertions(+), 16 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java index 40b4211a..778432ce 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java @@ -29,7 +29,6 @@ import cn.lypi.contracts.security.FileSystemPermissionPolicy; import cn.lypi.contracts.security.NetworkPermissionPolicy; import cn.lypi.contracts.session.SessionView; -import cn.lypi.contracts.tool.ToolExecutionStatus; import cn.lypi.contracts.tui.DiffView; import cn.lypi.contracts.tui.PermissionPromptView; import cn.lypi.contracts.tui.SessionRuntimeState; @@ -439,7 +438,7 @@ private void reduceToolProgress(ToolProgressEvent event) { private void reduceToolEnd(ToolEndEvent event) { state.toolIndex(event.toolUseId()).ifPresent(index -> { TuiToolBlock current = (TuiToolBlock) state.blocks().get(index); - TuiToolState toolState = toolState(event.status()); + TuiToolState toolState = TuiTranscriptProjector.stateFor(event.status()); String details = state.completeToolProgress( event.toolUseId(), current.details(), @@ -546,16 +545,6 @@ private void reduceError(ErrorEvent event) { )); } - private TuiToolState toolState(ToolExecutionStatus status) { - if (status == ToolExecutionStatus.CANCELLED) { - return TuiToolState.CANCELLED; - } - if (status == ToolExecutionStatus.FAILED || status == ToolExecutionStatus.TIMED_OUT) { - return TuiToolState.FAILED; - } - return TuiToolState.DONE; - } - private String roleName(MessageRole role) { if (role == MessageRole.USER) { return "user"; diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiToolProgressBuffer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiToolProgressBuffer.java index 1aa7ef13..eaa23a43 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiToolProgressBuffer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiToolProgressBuffer.java @@ -77,7 +77,7 @@ void complete(ToolEndEvent event) { } ToolResultSummary summary = event.resultSummary(); if (summary != null) { - addFinalLine(firstNonBlank(summary.summary(), summary.title())); + addFinalLine(TuiTranscriptProjector.resultSummary(firstNonBlank(summary.summary(), summary.title()))); } addFinalLine(preview(event.resultRef(), summary)); } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptProjector.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptProjector.java index 33ab622d..52a8d022 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptProjector.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptProjector.java @@ -5,6 +5,7 @@ import cn.lypi.contracts.context.MessageRole; import cn.lypi.contracts.context.ToolCallContentBlock; import cn.lypi.contracts.context.ToolResultContentBlock; +import cn.lypi.contracts.tool.ToolExecutionStatus; import cn.lypi.contracts.tui.TuiBlock; import cn.lypi.contracts.tui.TuiErrorBlock; import cn.lypi.contracts.tui.TuiMessageBlock; @@ -182,13 +183,24 @@ private static TuiToolState stateFor(ContentBlock result) { private static TuiToolState stateFor(boolean error, Map metadata) { String status = metadataString(metadata, "status").trim().toUpperCase(Locale.ROOT); - if ("CANCELLED".equals(status)) { + if (!status.isBlank()) { + try { + return stateFor(ToolExecutionStatus.valueOf(status)); + } catch (IllegalArgumentException ignored) { + // Fall through to the durable error flag for unknown legacy values. + } + } + return stateFor(error ? ToolExecutionStatus.FAILED : ToolExecutionStatus.SUCCEEDED); + } + + static TuiToolState stateFor(ToolExecutionStatus status) { + if (status == ToolExecutionStatus.CANCELLED) { return TuiToolState.CANCELLED; } - if ("FAILED".equals(status) || "TIMED_OUT".equals(status)) { + if (status == ToolExecutionStatus.FAILED || status == ToolExecutionStatus.TIMED_OUT) { return TuiToolState.FAILED; } - return error ? TuiToolState.FAILED : TuiToolState.DONE; + return TuiToolState.DONE; } static String resultDetails(ToolResultContentBlock result) { diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java index 7673aafb..de9a4d3b 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java @@ -6,10 +6,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import cn.lypi.contracts.common.ToolProgress; +import cn.lypi.contracts.context.AgentMessage; import cn.lypi.contracts.context.ContentReplacementRecord; import cn.lypi.contracts.context.ContentBlockKind; import cn.lypi.contracts.context.MessageKind; import cn.lypi.contracts.context.MessageRole; +import cn.lypi.contracts.context.ToolCallContentBlock; +import cn.lypi.contracts.context.ToolResultContentBlock; import cn.lypi.contracts.event.MessageDeltaEvent; import cn.lypi.contracts.event.MessageEndEvent; import cn.lypi.contracts.event.MessageStartEvent; @@ -41,6 +44,7 @@ import cn.lypi.contracts.tool.ToolOutputRef; import cn.lypi.contracts.tool.ToolResultSummary; import cn.lypi.contracts.tui.PermissionPromptView; +import cn.lypi.contracts.tui.SessionRuntimeState; import cn.lypi.contracts.tui.StatusBarState; import cn.lypi.contracts.tui.TuiBlock; import cn.lypi.contracts.tui.TuiMessageBlock; @@ -53,7 +57,11 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; class TuiContractEndToEndTest { private static final Instant NOW = Instant.parse("2026-06-07T10:00:00Z"); @@ -158,6 +166,127 @@ void assistantThinkingToolLifecycleAndWindowedOutputRenderFromSemanticEvents() { assertEquals(Path.of(".lypi/tool-output/toolu_bash.txt"), replacement.persistedPath()); } + @ParameterizedTest + @MethodSource("terminalToolStates") + void liveAndResumedToolUseHaveEquivalentFinalProjection( + ToolExecutionStatus status, + boolean error, + TuiToolState expectedState + ) { + String resultText = "command output\nsecond line"; + String resultSummary = "command output second line (+1 lines)"; + TuiEventReducer liveReducer = new TuiEventReducer(); + liveReducer.reduce(new ToolStartEvent( + "ses_1", + "toolu_bash", + "msg_tool", + "turn_1", + "bash", + "Run shell", + "printf output", + Map.of("command", "printf output"), + NOW, + NOW + )); + liveReducer.reduce(new ToolProgressEvent( + "ses_1", + "toolu_bash", + ToolProgress.output("stdout", "transient progress\n"), + NOW.plusMillis(1) + )); + liveReducer.reduce(new ToolEndEvent( + "ses_1", + "toolu_bash", + status, + status == ToolExecutionStatus.SUCCEEDED ? 0 : 1, + new ToolResultSummary( + "bash " + status.name().toLowerCase(), + resultSummary, + error, + status == ToolExecutionStatus.SUCCEEDED ? 0 : 1, + false, + resultText.length(), + Map.of() + ), + null, + NOW, + NOW.plusMillis(2), + 2L, + Map.of(), + NOW.plusMillis(2) + )); + + SessionRuntimeState base = TestRuntimeStates.basic("ses_1"); + SessionRuntimeState resumedState = new SessionRuntimeState( + base.sessionId(), + base.cwd(), + base.currentBranchLeafId(), + base.model(), + base.thinkingLevel(), + base.agentMode(), + base.permissionRuntimeState(), + base.budget(), + List.of( + new AgentMessage( + "msg_tool", + MessageRole.ASSISTANT, + MessageKind.TOOL_CALL, + List.of(new ToolCallContentBlock( + "toolu_bash", + "bash", + "", + Map.of("inputSummary", "printf output") + )), + NOW, + Optional.empty(), + Optional.of("tool_calls") + ), + new AgentMessage( + "msg_result", + MessageRole.TOOL_RESULT, + MessageKind.TOOL_RESULT, + List.of(new ToolResultContentBlock( + "toolu_bash", + resultText, + error, + Map.of("status", status.name()) + )), + NOW.plusMillis(2), + Optional.empty(), + Optional.empty() + ) + ), + false, + false, + false, + false + ); + TuiViewModel liveView = liveReducer.view(); + TuiViewModel resumedView = TuiEventReducer.fromRuntimeState(resumedState).view(); + TuiToolBlock liveTool = assertInstanceOf(TuiToolBlock.class, liveView.blocks().getFirst()); + TuiToolBlock resumedTool = assertInstanceOf(TuiToolBlock.class, resumedView.blocks().getFirst()); + + assertEquals(liveTool.toolUseId(), resumedTool.toolUseId()); + assertEquals(liveTool.toolName(), resumedTool.toolName()); + assertEquals(expectedState, liveTool.state()); + assertEquals(liveTool.state(), resumedTool.state()); + assertEquals(liveTool.label(), resumedTool.label()); + assertEquals(liveTool.active(), resumedTool.active()); + + List liveLines = renderedTranscript(liveView); + List resumedLines = renderedTranscript(resumedView); + assertEquals(liveLines.getFirst(), resumedLines.getFirst()); + assertEquals(" " + resultSummary, liveLines.getLast()); + assertEquals(liveLines.getLast(), resumedLines.getLast()); + } + + private static Stream terminalToolStates() { + return Stream.of( + Arguments.of(ToolExecutionStatus.SUCCEEDED, false, TuiToolState.DONE), + Arguments.of(ToolExecutionStatus.FAILED, true, TuiToolState.FAILED) + ); + } + @Test void permissionPromptUsesEventOptionsAndDecisionOnlyClearsOverlay() { TuiEventReducer reducer = new TuiEventReducer(); @@ -364,6 +493,21 @@ private static MessageDeltaEvent delta( ); } + private static List renderedTranscript(TuiViewModel view) { + TuiRenderFrame frame = new TuiRenderer().renderFrame( + view, + new TuiScreen(20), + new TuiLayout(80, 20), + "", + -1, + List.of(), + false + ); + return frame.lines().subList(0, frame.transcriptLineCount()).stream() + .map(line -> line.replaceAll("\\u001B\\[[;\\d]*m", "")) + .toList(); + } + private static PermissionDecision decision( PermissionBehavior behavior, String message, From 2fe0d58247ada88fa6924e1f0e6093a2f8bae053 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 18:48:42 +0800 Subject: [PATCH 30/58] =?UTF-8?q?test(tui):=20=E9=AA=8C=E8=AF=81=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E9=A6=96=E5=B8=A7=E5=B7=A5=E5=85=B7=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../JLineTuiTransportRenderPipelineTest.java | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java index 4d8c1dce..91090300 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java @@ -5,9 +5,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import cn.lypi.contracts.common.ToolProgress; +import cn.lypi.contracts.context.AgentMessage; import cn.lypi.contracts.context.ContentBlockKind; import cn.lypi.contracts.context.MessageKind; import cn.lypi.contracts.context.MessageRole; +import cn.lypi.contracts.context.TextContentBlock; +import cn.lypi.contracts.context.ToolCallContentBlock; +import cn.lypi.contracts.context.ToolResultContentBlock; import cn.lypi.contracts.event.AgentEvent; import cn.lypi.contracts.event.EventBus; import cn.lypi.contracts.event.EventConsumer; @@ -32,6 +36,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -43,6 +48,106 @@ class JLineTuiTransportRenderPipelineTest { private static final String INPUT_CURSOR = "\033[38;5;81m|\033[39m"; private static final String ANSI_RESET = "\033[0m"; + @Test + void completedRuntimeTranscriptRendersFinalToolStatesOnFirstFrame() { + RecordingEventBus events = new RecordingEventBus(); + List> frames = new ArrayList<>(); + JLineTuiTransport transport = JLineTuiTransport.withRenderer(frames::add, 100, 12); + + transport.attach(events, completedRuntimeState("ses_resumed", 0)); + transport.renderCurrentFrameUnderUiLock(); + + TuiToolBlock read = tool(transport, "read-1"); + TuiToolBlock bash = tool(transport, "bash-1"); + assertEquals(TuiToolState.DONE, read.state()); + assertFalse(read.active()); + assertTrue(read.details().contains("read result summary")); + assertEquals(TuiToolState.FAILED, bash.state()); + assertFalse(bash.active()); + assertTrue(bash.details().contains("command failed")); + + String firstFrame = String.join("\n", frames.getFirst()); + assertTrue(firstFrame.contains("tools: read x1 (Ctrl+O details)"), firstFrame); + assertTrue(firstFrame.contains("failed $ exit 1"), firstFrame); + assertTrue(firstFrame.contains("command failed"), firstFrame); + assertTrue(firstFrame.contains("resume complete"), firstFrame); + assertFalse(firstFrame.contains("pending read"), firstFrame); + assertFalse(firstFrame.contains("pending $"), firstFrame); + } + + @Test + void reattachToResumedStateClearsOldProgressAndScrollsOnlyNewTranscript() throws Exception { + RecordingEventBus events = new RecordingEventBus(); + List> frames = new ArrayList<>(); + QueueInputSource input = new QueueInputSource(); + JLineTuiTransport transport = JLineTuiTransport.withInput( + frames::add, + 80, + 8, + input, + new RecordingSubmitHandler() + ); + transport.attach(events, TestRuntimeStates.basic("ses_old")); + events.emit(new ToolStartEvent("ses_old", "old-tool", "bash", Instant.parse("2026-06-09T00:00:00Z"))); + events.emit(new ToolProgressEvent( + "ses_old", + "old-tool", + ToolProgress.output("stdout", "old-progress\n"), + Instant.parse("2026-06-09T00:00:01Z") + )); + assertEquals(TuiToolState.RUNNING, tool(transport, "old-tool").state()); + assertTrue(tool(transport, "old-tool").details().contains("old-progress")); + + SessionRuntimeState resumed = completedRuntimeState("ses_new", 12); + transport.attach(events, resumed); + transport.renderCurrentFrameUnderUiLock(); + + assertFalse(transport.viewForTest().blocks().stream() + .filter(TuiToolBlock.class::isInstance) + .map(TuiToolBlock.class::cast) + .anyMatch(block -> "old-tool".equals(block.toolUseId()))); + assertFalse(transport.viewForTest().blocks().stream() + .filter(TuiToolBlock.class::isInstance) + .map(TuiToolBlock.class::cast) + .anyMatch(block -> block.details().contains("old-progress"))); + assertEquals(TuiToolState.DONE, tool(transport, "read-1").state()); + assertEquals(TuiToolState.FAILED, tool(transport, "bash-1").state()); + + events.emit(new ToolProgressEvent( + "ses_new", + "bash-1", + ToolProgress.output("stderr", "new-index-progress\n"), + Instant.parse("2026-06-09T00:00:02Z") + )); + assertEquals(TuiToolState.DONE, tool(transport, "read-1").state()); + assertEquals(TuiToolState.RUNNING, tool(transport, "bash-1").state()); + assertTrue(tool(transport, "bash-1").details().contains("new-index-progress")); + + transport.attach(events, resumed); + transport.renderCurrentFrameUnderUiLock(); + for (int index = 0; index < 10; index++) { + input.add("\033[6~"); + } + transport.drainInputForTest(); + assertTrue(String.join("\n", frames.getLast()).contains("resume complete")); + + for (int index = 0; index < 10; index++) { + input.add("\033[5~"); + } + transport.drainInputForTest(); + String pageUpFrame = String.join("\n", frames.getLast()); + assertTrue(pageUpFrame.contains("new history 0"), pageUpFrame); + assertFalse(pageUpFrame.contains("old-progress"), pageUpFrame); + + for (int index = 0; index < 10; index++) { + input.add("\033[6~"); + } + transport.drainInputForTest(); + String pageDownFrame = String.join("\n", frames.getLast()); + assertTrue(pageDownFrame.contains("resume complete"), pageDownFrame); + assertFalse(pageDownFrame.contains("old-progress"), pageDownFrame); + } + @Test void toolProgressBurstReducesImmediatelyAndCoalescesTerminalFrames() { RecordingEventBus events = new RecordingEventBus(); @@ -822,6 +927,90 @@ void emit(AgentEvent event) { } } + private static SessionRuntimeState completedRuntimeState(String sessionId, int historyMessages) { + SessionRuntimeState base = TestRuntimeStates.basic(sessionId); + List transcript = new ArrayList<>(); + for (int index = 0; index < historyMessages; index++) { + transcript.add(message( + "history-" + index, + MessageRole.ASSISTANT, + MessageKind.TEXT, + new TextContentBlock("new history " + index) + )); + } + transcript.add(message( + "read-call", + MessageRole.ASSISTANT, + MessageKind.TOOL_CALL, + new ToolCallContentBlock("read-1", "read", "", Map.of("inputSummary", "read AGENTS.md")) + )); + transcript.add(message( + "read-result", + MessageRole.TOOL_RESULT, + MessageKind.TOOL_RESULT, + new ToolResultContentBlock("read-1", "read result summary", false) + )); + transcript.add(message( + "bash-call", + MessageRole.ASSISTANT, + MessageKind.TOOL_CALL, + new ToolCallContentBlock("bash-1", "bash", "", Map.of("inputSummary", "exit 1")) + )); + transcript.add(message( + "bash-result", + MessageRole.TOOL_RESULT, + MessageKind.TOOL_RESULT, + new ToolResultContentBlock("bash-1", "command failed", true, Map.of("status", "FAILED")) + )); + transcript.add(message( + "final-answer", + MessageRole.ASSISTANT, + MessageKind.TEXT, + new TextContentBlock("resume complete") + )); + return new SessionRuntimeState( + base.sessionId(), + base.cwd(), + base.currentBranchLeafId(), + base.model(), + base.thinkingLevel(), + base.agentMode(), + base.permissionRuntimeState(), + base.budget(), + transcript, + false, + false, + false, + false + ); + } + + private static AgentMessage message( + String id, + MessageRole role, + MessageKind kind, + cn.lypi.contracts.context.ContentBlock block + ) { + return new AgentMessage( + id, + role, + kind, + List.of(block), + Instant.parse("2026-06-09T00:00:00Z"), + Optional.empty(), + Optional.empty() + ); + } + + private static TuiToolBlock tool(JLineTuiTransport transport, String toolUseId) { + return transport.viewForTest().blocks().stream() + .filter(TuiToolBlock.class::isInstance) + .map(TuiToolBlock.class::cast) + .filter(block -> toolUseId.equals(block.toolUseId())) + .findFirst() + .orElseThrow(); + } + private static final class QueueInputSource implements TerminalInputSource { private final ArrayDeque chunks; From 020364b3c6b604a21343ba715a1de7f93daebcc7 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 20:30:21 +0800 Subject: [PATCH 31/58] fix(tui): isolate rendering in alternate screen --- .../lypi/transport/tui/TerminalSession.java | 39 ++++++++++++++++--- .../transport/tui/TerminalSessionTest.java | 35 +++++++++-------- 2 files changed, 51 insertions(+), 23 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java index e82a1259..a856bbc4 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java @@ -5,6 +5,8 @@ import org.jline.terminal.TerminalBuilder; public final class TerminalSession implements AutoCloseable { + static final String ENTER_ALTERNATE_SCREEN = "\033[?1049h"; + static final String LEAVE_ALTERNATE_SCREEN = "\033[?1049l"; static final String ENABLE_BRACKETED_PASTE = "\033[?2004h"; static final String DISABLE_BRACKETED_PASTE = "\033[?2004l"; static final String HIDE_CURSOR = "\033[?25l"; @@ -58,17 +60,20 @@ static TerminalSession open(TerminalIo io, Runnable resizeCallback, Runnable int AutoCloseable rawMode = null; AutoCloseable resizeHandler = null; AutoCloseable interruptHandler = null; + boolean alternateScreenEntered = false; try { rawMode = io.enterRawMode(); resizeHandler = io.onResize(resizeCallback); interruptHandler = io.onInterrupt(interruptCallback); + io.write(ENTER_ALTERNATE_SCREEN); + alternateScreenEntered = true; io.write(ENABLE_BRACKETED_PASTE); io.write(HIDE_CURSOR); io.write(ENABLE_MODIFY_OTHER_KEYS); io.flush(); return new TerminalSession(io, rawMode, resizeHandler, interruptHandler); } catch (IOException | RuntimeException exception) { - restoreAfterOpenFailure(interruptHandler, resizeHandler, rawMode); + restoreAfterOpenFailure(io, alternateScreenEntered, interruptHandler, resizeHandler, rawMode); throw exception; } } @@ -85,12 +90,9 @@ public void close() throws Exception { closed = true; try { io.write(DISABLE_MODIFY_OTHER_KEYS); - io.write(SHOW_CURSOR); io.write(DISABLE_BRACKETED_PASTE); - if (renderedRows > 0) { - io.write("\033[" + renderedRows + ";1H"); - } - io.write("\n"); + io.write(LEAVE_ALTERNATE_SCREEN); + io.write(SHOW_CURSOR); io.flush(); } finally { closeQuietly(interruptHandler); @@ -108,15 +110,40 @@ private void closeQuietly(AutoCloseable closeable) { } private static void restoreAfterOpenFailure( + TerminalIo io, + boolean alternateScreenEntered, AutoCloseable interruptHandler, AutoCloseable resizeHandler, AutoCloseable rawMode ) { + if (alternateScreenEntered) { + writeStaticQuietly(io, DISABLE_MODIFY_OTHER_KEYS); + writeStaticQuietly(io, DISABLE_BRACKETED_PASTE); + writeStaticQuietly(io, LEAVE_ALTERNATE_SCREEN); + writeStaticQuietly(io, SHOW_CURSOR); + flushStaticQuietly(io); + } closeStaticQuietly(interruptHandler); closeStaticQuietly(resizeHandler); closeStaticQuietly(rawMode); } + private static void writeStaticQuietly(TerminalIo io, String value) { + try { + io.write(value); + } catch (IOException | RuntimeException ignored) { + // NOTE: 打开失败时每个恢复序列都独立尝试,避免一次写失败阻断退出 alternate screen。 + } + } + + private static void flushStaticQuietly(TerminalIo io) { + try { + io.flush(); + } catch (IOException | RuntimeException ignored) { + // NOTE: 打开失败回滚不能覆盖原始异常。 + } + } + private static void closeStaticQuietly(AutoCloseable closeable) { if (closeable == null) { return; diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java index e9f1fb32..e0ff76bb 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java @@ -17,7 +17,7 @@ void openEntersInteractiveTerminalModesAndCloseRestoresThem() throws Exception { assertTrue(io.rawModeEntered); assertEquals( - "\033[?2004h\033[?25l\033[>4;2m", + "\033[?1049h\033[?2004h\033[?25l\033[>4;2m", io.output.toString() ); @@ -25,8 +25,8 @@ void openEntersInteractiveTerminalModesAndCloseRestoresThem() throws Exception { assertTrue(io.rawModeRestored); assertEquals( - "\033[?2004h\033[?25l\033[>4;2m" - + "\033[>4m\033[?25h\033[?2004l\n", + "\033[?1049h\033[?2004h\033[?25l\033[>4;2m" + + "\033[>4m\033[?2004l\033[?1049l\033[?25h", io.output.toString() ); } @@ -41,17 +41,6 @@ void terminalModesDoNotQueryKittyKeyboardProtocol() throws Exception { assertTrue(!io.output.toString().contains("\033[?u")); } - @Test - void closeMovesBelowRenderedContentWhenRendererReportedRows() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - TerminalSession session = TerminalSession.open(io); - - session.updateRenderedRows(3); - session.close(); - - assertTrue(io.output.toString().endsWith("\033[3;1H\n")); - } - @Test void closeIsIdempotent() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); @@ -80,11 +69,12 @@ void openRegistersInterruptHandlerAndCloseRestoresIt() throws Exception { } @Test - void openFailureRestoresRawModeAndResizeHandler() { - FailingTerminalIo io = new FailingTerminalIo(); + void openFailureLeavesAlternateScreenAndRestoresTerminalResources() { + FailingTerminalIo io = new FailingTerminalIo(2); assertThrows(IOException.class, () -> TerminalSession.open(io)); + assertEquals("\033[?1049h\033[>4m\033[?2004l\033[?1049l\033[?25h", io.output.toString()); assertTrue(io.rawModeRestored); assertTrue(io.resizeHandlerRestored); assertTrue(io.interruptHandlerRestored); @@ -141,9 +131,16 @@ public AutoCloseable onInterrupt(Runnable callback) { } private static final class FailingTerminalIo implements TerminalIo { + private final StringBuilder output = new StringBuilder(); + private final int failingWrite; private boolean rawModeRestored; private boolean resizeHandlerRestored; private boolean interruptHandlerRestored; + private int writeCount; + + private FailingTerminalIo(int failingWrite) { + this.failingWrite = failingWrite; + } @Override public AutoCloseable enterRawMode() { @@ -152,7 +149,11 @@ public AutoCloseable enterRawMode() { @Override public void write(String value) throws IOException { - throw new IOException("write failed"); + writeCount++; + if (writeCount == failingWrite) { + throw new IOException("write failed"); + } + output.append(value); } @Override From 0a242e3b96185957d1edc5e43856bab97d9836e0 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 20:33:42 +0800 Subject: [PATCH 32/58] feat(tui): partition history from live transcript --- .../tui/TuiTranscriptPartitioner.java | 44 ++++++++++++ .../tui/TuiTranscriptPartitionerTest.java | 71 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptPartitioner.java create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptPartitioner.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptPartitioner.java new file mode 100644 index 00000000..9a57d07a --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptPartitioner.java @@ -0,0 +1,44 @@ +package cn.lypi.transport.tui; + +import cn.lypi.contracts.tui.TuiBlock; +import cn.lypi.contracts.tui.TuiErrorBlock; +import cn.lypi.contracts.tui.TuiMessageBlock; +import cn.lypi.contracts.tui.TuiThinkingBlock; +import cn.lypi.contracts.tui.TuiToolBlock; +import cn.lypi.contracts.tui.TuiToolState; +import java.util.List; + +record TuiTranscriptPartition(List history, List live) { + TuiTranscriptPartition { + history = List.copyOf(history); + live = List.copyOf(live); + } +} + +final class TuiTranscriptPartitioner { + TuiTranscriptPartition partition(List blocks) { + int liveStart = 0; + while (liveStart < blocks.size() && stable(blocks.get(liveStart))) { + liveStart++; + } + return new TuiTranscriptPartition( + blocks.subList(0, liveStart), + blocks.subList(liveStart, blocks.size()) + ); + } + + private boolean stable(TuiBlock block) { + return switch (block) { + case TuiMessageBlock message -> !message.streaming(); + case TuiThinkingBlock thinking -> !thinking.streaming(); + case TuiToolBlock tool -> terminal(tool.state()) && !tool.active(); + case TuiErrorBlock ignored -> true; + }; + } + + private boolean terminal(TuiToolState state) { + return state == TuiToolState.DONE + || state == TuiToolState.FAILED + || state == TuiToolState.CANCELLED; + } +} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java new file mode 100644 index 00000000..6da083c0 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java @@ -0,0 +1,71 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import cn.lypi.contracts.tui.TuiBlock; +import cn.lypi.contracts.tui.TuiErrorBlock; +import cn.lypi.contracts.tui.TuiMessageBlock; +import cn.lypi.contracts.tui.TuiThinkingBlock; +import cn.lypi.contracts.tui.TuiToolBlock; +import cn.lypi.contracts.tui.TuiToolState; +import java.util.List; +import org.junit.jupiter.api.Test; + +class TuiTranscriptPartitionerTest { + @Test + void keepsStableBlocksAfterFirstLiveBlockInLiveTail() { + TuiTranscriptPartition partition = new TuiTranscriptPartitioner().partition(List.of( + message("old-user", false), + tool("done-tool", TuiToolState.DONE, false), + message("streaming", true), + tool("later-done", TuiToolState.DONE, false) + )); + + assertEquals(List.of("old-user", "done-tool"), blockIds(partition.history())); + assertEquals(List.of("streaming", "later-done"), blockIds(partition.live())); + } + + @Test + void putsCompleteRestoredProjectionInHistory() { + List blocks = List.of( + message("user", false), + new TuiThinkingBlock("thinking", "message-thinking", "done", false, true), + tool("done", TuiToolState.DONE, false), + tool("failed", TuiToolState.FAILED, false), + tool("cancelled", TuiToolState.CANCELLED, false), + new TuiErrorBlock("error", "failed") + ); + + TuiTranscriptPartition partition = new TuiTranscriptPartitioner().partition(blocks); + + assertEquals(blockIds(blocks), blockIds(partition.history())); + assertEquals(List.of(), partition.live()); + } + + @Test + void treatsStreamingAndNonterminalOrActiveToolsAsLive() { + assertLive(message("streaming-message", true)); + assertLive(new TuiThinkingBlock("streaming-thinking", "message-thinking", "working", true, false)); + assertLive(tool("pending", TuiToolState.PENDING, false)); + assertLive(tool("running", TuiToolState.RUNNING, false)); + assertLive(tool("active-done", TuiToolState.DONE, true)); + } + + private TuiMessageBlock message(String blockId, boolean streaming) { + return new TuiMessageBlock(blockId, "message-" + blockId, "assistant", blockId, streaming); + } + + private TuiToolBlock tool(String blockId, TuiToolState state, boolean active) { + return new TuiToolBlock(blockId, "message-" + blockId, "use-" + blockId, "Bash", state, blockId, active); + } + + private List blockIds(List blocks) { + return blocks.stream().map(TuiBlock::blockId).toList(); + } + + private void assertLive(TuiBlock block) { + TuiTranscriptPartition partition = new TuiTranscriptPartitioner().partition(List.of(block)); + assertEquals(List.of(), partition.history()); + assertEquals(List.of(block), partition.live()); + } +} From c800262fd3caf3d69a45b55bdb677fbdbe8db4d6 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 20:38:45 +0800 Subject: [PATCH 33/58] fix(tui): retain bounded history projection --- .../java/cn/lypi/transport/tui/TuiScreen.java | 33 ++++++++++++-- .../cn/lypi/transport/tui/TuiScreenTest.java | 45 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java index b1d3a16d..1de81e28 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java @@ -3,6 +3,8 @@ import java.util.List; final class TuiScreen { + static final int MAX_RETAINED_HISTORY_LINES = 500; + private int viewportHeight; private List transcript = List.of(); private int linesBelow; @@ -22,14 +24,15 @@ void updateViewportHeight(int viewportHeight) { } void setTranscript(List transcript) { - int previousTranscriptSize = this.transcript.size(); + List previousTranscript = this.transcript; boolean followingTail = linesBelow == 0; - this.transcript = List.copyOf(transcript); + this.transcript = retainTail(transcript); if (followingTail) { linesBelow = 0; return; } - int appendedLines = Math.max(0, this.transcript.size() - previousTranscriptSize); + int overlap = maximumSuffixPrefixOverlap(previousTranscript, this.transcript); + int appendedLines = this.transcript.size() - overlap; linesBelow = Math.min(maxLinesBelow(), linesBelow + appendedLines); } @@ -62,6 +65,30 @@ int linesBelow() { return linesBelow; } + int retainedLineCount() { + return transcript.size(); + } + + void reset() { + transcript = List.of(); + linesBelow = 0; + } + + private List retainTail(List lines) { + int from = Math.max(0, lines.size() - MAX_RETAINED_HISTORY_LINES); + return List.copyOf(lines.subList(from, lines.size())); + } + + private int maximumSuffixPrefixOverlap(List previous, List current) { + int maximum = Math.min(previous.size(), current.size()); + for (int overlap = maximum; overlap > 0; overlap--) { + if (previous.subList(previous.size() - overlap, previous.size()).equals(current.subList(0, overlap))) { + return overlap; + } + } + return 0; + } + private int maxLinesBelow() { return Math.max(0, transcript.size() - viewportHeight); } diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java index 32e9f548..d3d58fec 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java @@ -3,9 +3,48 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.List; +import java.util.stream.IntStream; import org.junit.jupiter.api.Test; class TuiScreenTest { + @Test + void retainsOnlyLatestFiveHundredPhysicalHistoryLines() { + TuiScreen screen = new TuiScreen(10); + screen.setTranscript(IntStream.rangeClosed(1, 501) + .mapToObj(index -> "line-" + index) + .toList()); + + assertEquals(500, screen.retainedLineCount()); + screen.scrollUp(1_000); + assertEquals("line-2", screen.visibleTranscript().getFirst()); + } + + @Test + void preservesScrolledContentWhenBoundedWindowReceivesAnotherLine() { + TuiScreen screen = new TuiScreen(10); + screen.setTranscript(historyLines(1, 500)); + screen.scrollUp(5); + List visibleBeforeAppend = screen.visibleTranscript(); + + screen.setTranscript(historyLines(1, 501)); + + assertEquals(6, screen.linesBelow()); + assertEquals(visibleBeforeAppend, screen.visibleTranscript()); + } + + @Test + void resetClearsRetainedHistoryAndScrollOffset() { + TuiScreen screen = new TuiScreen(10); + screen.setTranscript(historyLines(1, 20)); + screen.scrollUp(5); + + screen.reset(); + + assertEquals(0, screen.retainedLineCount()); + assertEquals(0, screen.linesBelow()); + assertEquals(List.of(), screen.visibleTranscript()); + } + @Test void visibleTranscriptReturnsOnlyViewportTailByDefault() { TuiScreen screen = new TuiScreen(2); @@ -52,4 +91,10 @@ void pageScrollUsesViewportHeightMinusOne() { assertEquals(List.of("e", "f", "g", "h"), screen.visibleTranscript()); assertEquals(0, screen.linesBelow()); } + + private List historyLines(int first, int last) { + return IntStream.rangeClosed(first, last) + .mapToObj(index -> "line-" + index) + .toList(); + } } From 14ecd9e685f49c4668f8d2ee05adacc035fd75d1 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 20:41:46 +0800 Subject: [PATCH 34/58] feat(tui): allocate history and live content regions --- .../lypi/transport/tui/TuiContentLayout.java | 47 ++++++++++++++++ .../transport/tui/TuiContentLayoutTest.java | 56 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiContentLayout.java create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContentLayoutTest.java diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiContentLayout.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiContentLayout.java new file mode 100644 index 00000000..385a8c85 --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiContentLayout.java @@ -0,0 +1,47 @@ +package cn.lypi.transport.tui; + +record TuiContentLayout(int historyHeight, int separatorHeight, int liveHeight) { + TuiContentLayout { + if (historyHeight < 0 || separatorHeight < 0 || liveHeight < 0) { + throw new IllegalArgumentException("content region heights must be non-negative"); + } + } + + static TuiContentLayout allocate(int height, boolean hasHistory, int desiredLiveHeight) { + if (height < 0) { + throw new IllegalArgumentException("height must be non-negative"); + } + if (desiredLiveHeight < 0) { + throw new IllegalArgumentException("desiredLiveHeight must be non-negative"); + } + if (height == 0) { + return new TuiContentLayout(0, 0, 0); + } + + boolean hasLive = desiredLiveHeight > 0; + if (!hasHistory) { + return hasLive + ? new TuiContentLayout(0, 0, height) + : new TuiContentLayout(height, 0, 0); + } + if (!hasLive) { + return new TuiContentLayout(height, 0, 0); + } + if (height == 1) { + return new TuiContentLayout(0, 0, 1); + } + if (height == 2) { + return new TuiContentLayout(1, 0, 1); + } + + int separatorHeight = 1; + int availableContentHeight = height - separatorHeight; + int liveHeight = Math.min(desiredLiveHeight, availableContentHeight / 2); + int historyHeight = availableContentHeight - liveHeight; + return new TuiContentLayout(historyHeight, separatorHeight, liveHeight); + } + + int totalHeight() { + return historyHeight + separatorHeight + liveHeight; + } +} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContentLayoutTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContentLayoutTest.java new file mode 100644 index 00000000..35fbc65e --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContentLayoutTest.java @@ -0,0 +1,56 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class TuiContentLayoutTest { + @Test + void allocatesTinyBudgetsBetweenHistoryAndLiveRegions() { + assertLayout(TuiContentLayout.allocate(0, true, 20), 0, 0, 0); + assertLayout(TuiContentLayout.allocate(1, true, 20), 0, 0, 1); + assertLayout(TuiContentLayout.allocate(2, true, 20), 1, 0, 1); + assertLayout(TuiContentLayout.allocate(3, true, 20), 1, 1, 1); + } + + @Test + void capsLiveRegionAtHalfOfContentAfterSeparator() { + TuiContentLayout split = TuiContentLayout.allocate(10, true, 20); + + assertLayout(split, 5, 1, 4); + assertTrue(split.liveHeight() <= 4); + assertTrue(split.historyHeight() >= split.liveHeight()); + } + + @Test + void allocatesWholeBudgetToSingleOrEmptyRegion() { + assertLayout(TuiContentLayout.allocate(10, true, 0), 10, 0, 0); + assertLayout(TuiContentLayout.allocate(10, false, 3), 0, 0, 10); + assertLayout(TuiContentLayout.allocate(10, false, 0), 10, 0, 0); + } + + @Test + void usesOnlyDesiredLiveHeightWhenHistoryCanUseTheRemainder() { + assertLayout(TuiContentLayout.allocate(10, true, 1), 8, 1, 1); + } + + @Test + void rejectsNegativeInputs() { + assertThrows(IllegalArgumentException.class, () -> TuiContentLayout.allocate(-1, true, 1)); + assertThrows(IllegalArgumentException.class, () -> TuiContentLayout.allocate(1, true, -1)); + } + + private void assertLayout( + TuiContentLayout layout, + int expectedHistory, + int expectedSeparator, + int expectedLive + ) { + assertEquals(expectedHistory, layout.historyHeight()); + assertEquals(expectedSeparator, layout.separatorHeight()); + assertEquals(expectedLive, layout.liveHeight()); + assertEquals(expectedHistory + expectedSeparator + expectedLive, layout.totalHeight()); + } +} From a21b56a35cff741d5ae367c133b7c4f03c86cbca Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 20:51:40 +0800 Subject: [PATCH 35/58] feat(tui): render bounded history beside live output --- .../cn/lypi/transport/tui/TuiRenderer.java | 85 ++++++--- .../lypi/transport/tui/TuiRendererTest.java | 174 ++++++++++++++++-- 2 files changed, 221 insertions(+), 38 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java index 61642bd3..eb5f0876 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java @@ -23,9 +23,11 @@ final class TuiRenderer { private static final String INPUT_CURSOR = "\033[38;5;81m|\033[39m"; private static final String ANSI_RESET = "\033[0m"; private static final String INPUT_PREFIX = "> "; + private static final String CONTENT_SEPARATOR_CHARACTER = "┄"; private static final int THINKING_VISIBLE_LINE_LIMIT = 3; private final MarkdownRenderer markdownRenderer = new MarkdownRenderer(); private final ToolDisplayRendererRegistry toolDisplayRenderers = ToolDisplayRendererRegistry.defaults(); + private final TuiTranscriptPartitioner transcriptPartitioner = new TuiTranscriptPartitioner(); List render(TuiViewModel view, TuiScreen screen, TuiLayout layout, String input) { return renderFrame(view, screen, layout, input, -1).lines(); @@ -70,7 +72,20 @@ TuiRenderFrame renderFrame( List overlayLines, boolean toolOutputExpanded ) { - List fullTranscript = transcriptLines(view, layout.width(), toolOutputExpanded, Integer.MAX_VALUE); + TuiTranscriptPartition partition = transcriptPartitioner.partition(view.blocks()); + List fullHistory = renderTranscriptBlocks( + partition.history(), + layout.width(), + false, + Integer.MAX_VALUE + ); + List fullLive = renderLiveLines( + view, + partition.live(), + layout.width(), + toolOutputExpanded, + Integer.MAX_VALUE + ); InputCandidate inputCandidate = compactRunning(view) ? readonlyRuntimeInputCandidate("compact 正在进行...", layout.width()) : measureInput(input, cursor, layout.width()); @@ -81,40 +96,46 @@ TuiRenderFrame renderFrame( TuiRegionLayout regions = layout.allocate( inputCandidate.desiredHeight(), fullOverlay.lines().size(), - !fullTranscript.isEmpty() + !fullHistory.isEmpty() || !fullLive.isEmpty() + ); + TuiContentLayout content = TuiContentLayout.allocate( + regions.transcriptHeight(), + !fullHistory.isEmpty(), + fullLive.size() ); - if (toolOutputExpanded) { - fullTranscript = transcriptLines( - view, - layout.width(), - true, - regions.transcriptHeight() - ); - } InputBlock inputBlock = inputCandidate.render(regions.inputHeight()); List overlay = windowOverlay( fullOverlay.lines(), regions.overlayHeight(), fullOverlay.selectedRow() ); - screen.updateViewportHeight(regions.transcriptHeight()); - screen.setTranscript(fullTranscript); - List transcript = screen.visibleTranscript(); + screen.updateViewportHeight(content.historyHeight()); + screen.setTranscript(fullHistory); + List history = topPaddedTail(screen.visibleTranscript(), content.historyHeight()); + List live = topPaddedTail(fullLive, content.liveHeight()); int chromeLineCount = inputBlock.lines().size() + overlay.size() + regions.statusHeight(); List lines = new ArrayList<>(); - lines.addAll(transcript); + lines.addAll(history); + if (content.separatorHeight() > 0) { + lines.add(contentSeparator(layout.width())); + } + lines.addAll(live); lines.addAll(inputBlock.lines()); lines.addAll(overlay); lines.add(statusLine(view.statusBar(), screen, layout.width())); + if (lines.size() != layout.height()) { + throw new IllegalStateException("rendered frame must match terminal height"); + } return TuiRenderFrame.fromTextLines(lines, chromeLineCount); } - private List transcriptLines(List blocks, int width, boolean toolOutputExpanded) { - return transcriptLines(blocks, width, toolOutputExpanded, Integer.MAX_VALUE); - } - - private List transcriptLines(List blocks, int width, boolean toolOutputExpanded, int lineBudget) { + List renderTranscriptBlocks( + List blocks, + int width, + boolean toolOutputExpanded, + int lineBudget + ) { List lines = new ArrayList<>(); for (int index = 0; index < blocks.size(); index++) { if (lines.size() >= lineBudget) { @@ -175,8 +196,14 @@ private List readLikeToolSummaryLines(List tools, int widt return wrap("tools: " + summary + " (Ctrl+O details)", width); } - private List transcriptLines(TuiViewModel view, int width, boolean toolOutputExpanded, int lineBudget) { - List lines = transcriptLines(view.blocks(), width, toolOutputExpanded, lineBudget); + private List renderLiveLines( + TuiViewModel view, + List blocks, + int width, + boolean toolOutputExpanded, + int lineBudget + ) { + List lines = renderTranscriptBlocks(blocks, width, toolOutputExpanded, lineBudget); view.diffView().ifPresent(diff -> new DiffOverlay(diff) .lines() .forEach(line -> appendWithinBudget(lines, wrap(line, width), lineBudget))); @@ -605,6 +632,22 @@ private String inputBorder(int width) { return INPUT_BORDER + "─".repeat(width) + ANSI_RESET; } + private String contentSeparator(int width) { + return INPUT_BORDER + CONTENT_SEPARATOR_CHARACTER.repeat(width) + ANSI_RESET; + } + + private List topPaddedTail(List lines, int height) { + if (height <= 0) { + return List.of(); + } + int start = Math.max(0, lines.size() - height); + List visible = lines.subList(start, lines.size()); + List padded = new ArrayList<>(height); + padded.addAll(blankLines(height - visible.size())); + padded.addAll(visible); + return List.copyOf(padded); + } + private List blankLines(int count) { if (count <= 0) { return List.of(); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java index 64fb493b..c011c9b0 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java @@ -33,6 +33,115 @@ class TuiRendererTest { private static final String INPUT_CURSOR = "\033[38;5;81m|\033[39m"; private static final String ANSI_RESET = "\033[0m"; + @Test + void rendersFixedHeightHistoryAndLiveRegions() { + TuiRenderer renderer = new TuiRenderer(); + TuiScreen screen = new TuiScreen(6); + TuiViewModel view = new TuiViewModel( + List.of( + new TuiMessageBlock("history", "m1", "assistant", "old answer", false), + new TuiMessageBlock("live", "m2", "assistant", "streaming answer", true) + ), + new StatusBarState("ses_1", "gpt-5.4", "running", "default"), + List.of(), + Optional.empty(), + Optional.empty() + ); + + TuiRenderFrame frame = renderer.renderFrame(view, screen, new TuiLayout(40, 10), "draft", 5); + + assertEquals(10, frame.lines().size()); + int historyIndex = frame.lines().indexOf("old answer"); + int separatorIndex = indexOfContentSeparator(frame.lines()); + int liveIndex = frame.lines().indexOf("streaming answer"); + int inputIndex = indexOfLineContaining(frame.lines(), "> draft"); + assertTrue(historyIndex >= 0); + assertTrue(historyIndex < separatorIndex); + assertTrue(separatorIndex < liveIndex); + assertTrue(liveIndex < inputIndex); + assertEquals(1, frame.lines().stream().filter(this::isContentSeparator).count()); + } + + @Test + void retainsOnlyLatestFiveHundredRenderedHistoryLines() { + TuiRenderer renderer = new TuiRenderer(); + TuiScreen screen = new TuiScreen(16); + List history = java.util.stream.IntStream.rangeClosed(1, 510) + .mapToObj(index -> (TuiBlock) new TuiMessageBlock( + "history-" + index, + "message-" + index, + "assistant", + "history-" + index, + false + )) + .toList(); + TuiViewModel view = new TuiViewModel( + history, + new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), + List.of(), + Optional.empty(), + Optional.empty() + ); + + renderer.renderFrame(view, screen, new TuiLayout(40, 20), "", 0); + screen.scrollUp(1_000); + List lines = renderer.renderFrame(view, screen, new TuiLayout(40, 20), "", 0).lines(); + + assertFalse(lines.stream().anyMatch(line -> line.matches("history-(?:[1-9]|10)"))); + assertTrue(lines.contains("history-11")); + assertEquals(500, screen.retainedLineCount()); + } + + @Test + void toolExpansionChangesOnlyLiveRegion() { + TuiRenderer renderer = new TuiRenderer(); + TuiViewModel view = new TuiViewModel( + List.of( + new TuiToolBlock( + "history-tool", + "message-history", + "use-history", + "custom_tool", + TuiToolState.DONE, + "completed", + "history detail 1\nhistory detail 2\nhistory detail 3\nhistory detail 4\nhistory detail 5", + false + ), + new TuiToolBlock( + "live-tool", + "message-live", + "use-live", + "custom_tool", + TuiToolState.RUNNING, + "active", + String.join("\n", java.util.stream.IntStream.rangeClosed(1, 20) + .mapToObj(index -> "live detail " + index) + .toList()), + true + ) + ), + new StatusBarState("ses_1", "gpt-5.4", "running", "default"), + List.of(), + Optional.empty(), + Optional.empty() + ); + + List collapsed = renderer.renderFrame( + view, new TuiScreen(10), new TuiLayout(60, 14), "", -1, List.of(), false + ).lines(); + List expanded = renderer.renderFrame( + view, new TuiScreen(10), new TuiLayout(60, 14), "", -1, List.of(), true + ).lines(); + + int collapsedSeparator = indexOfContentSeparator(collapsed); + int expandedSeparator = indexOfContentSeparator(expanded); + assertEquals(collapsed.subList(0, collapsedSeparator), expanded.subList(0, expandedSeparator)); + assertFalse( + collapsed.subList(collapsedSeparator + 1, indexOfInputBorder(collapsed)) + .equals(expanded.subList(expandedSeparator + 1, indexOfInputBorder(expanded))) + ); + } + @Test void rendersLinearTranscriptStatusAndInput() { TuiRenderer renderer = new TuiRenderer(); @@ -267,11 +376,12 @@ void emptyTranscriptRendersInputBlockAndStatusOnly() { List lines = renderer.render(view, screen, new TuiLayout(20, 6), "", 0); - assertEquals(4, lines.size()); - assertInputBorder(lines.get(0), 20); - assertInputContent(lines.get(1), "> |CURSOR|" + INPUT_CURSOR); + assertEquals(6, lines.size()); + assertEquals(List.of("", ""), lines.subList(0, 2)); assertInputBorder(lines.get(2), 20); - assertTrue(lines.get(3).contains("ses_1")); + assertInputContent(lines.get(3), "> |CURSOR|" + INPUT_CURSOR); + assertInputBorder(lines.get(4), 20); + assertTrue(lines.get(5).contains("ses_1")); } @Test @@ -633,9 +743,9 @@ void runtimeLineUsesTranscriptSpaceOnlyWhenActive() { List lines = renderer.render(view, screen, new TuiLayout(40, 8), ""); - assertEquals("line1", lines.get(0)); - assertEquals("line2", lines.get(1)); - assertEquals("line3", lines.get(2)); + assertEquals("line2", lines.get(0)); + assertEquals("line3", lines.get(1)); + assertTrue(isContentSeparator(lines.get(2))); assertEquals("· retrying attempt 2 rate limit", lines.get(3)); assertInputBorder(lines.get(4), 40); assertInputContent(lines.get(5), "> "); @@ -652,9 +762,8 @@ void runtimeLineUsesTranscriptSpaceOnlyWhenActive() { ); List withoutRuntimeLines = renderer.render(withoutRuntime, screenWithoutRuntime, new TuiLayout(40, 8), ""); - assertEquals("line1", withoutRuntimeLines.get(0)); - assertEquals("line2", withoutRuntimeLines.get(1)); - assertEquals("line3", withoutRuntimeLines.get(2)); + assertEquals("", withoutRuntimeLines.get(0)); + assertEquals(List.of("line1", "line2", "line3"), withoutRuntimeLines.subList(1, 4)); } @Test @@ -833,7 +942,7 @@ void readToolOutputNeverShowsFileContentAndExpandsToInvocationOnly() { assertFalse(collapsed.contains(" 1 | line 1")); assertFalse(collapsed.contains(" 11 | line 11")); assertTrue(collapsed.contains("tools: read x1 (Ctrl+O details)")); - assertTrue(expanded.contains("done read src/Large.java:1-20")); + assertEquals(collapsed, expanded); assertFalse(expanded.contains(" 1 | line 1")); assertFalse(expanded.contains(" 11 | line 11")); } @@ -911,11 +1020,13 @@ void searchToolsCollapseToCountsAndExpandToInvocationOnly() { List collapsed = renderer.renderFrame(view, screen, new TuiLayout(80, 20), "", -1, List.of(), false).lines(); List expanded = renderer.renderFrame(view, screen, new TuiLayout(80, 20), "", -1, List.of(), true).lines(); - assertTrue(collapsed.contains("tools: glob x1, read x1, grep x1 (Ctrl+O details)")); + assertTrue(collapsed.contains("tools: glob x1, read x1 (Ctrl+O details)")); + assertTrue(collapsed.contains("tools: grep x1 (Ctrl+O details)")); assertFalse(collapsed.contains(" matched AGENTS.md")); assertFalse(collapsed.contains("File: AGENTS.md")); - assertTrue(expanded.contains("done glob {path=., pattern=**/*}")); - assertTrue(expanded.contains("done read AGENTS.md:1-200")); + assertTrue(expanded.contains("tools: glob x1, read x1 (Ctrl+O details)")); + assertFalse(expanded.contains("done glob {path=., pattern=**/*}")); + assertFalse(expanded.contains("done read AGENTS.md:1-200")); assertTrue(expanded.contains("running grep {pattern=apiKey, path=.}")); assertFalse(expanded.contains(" matched AGENTS.md")); assertFalse(expanded.contains("File: AGENTS.md")); @@ -1032,9 +1143,10 @@ void narrowWrappedToolTitleStillLeavesAnOmissionMarkerWithinFiveRows() { Optional.empty() ); + TuiScreen screen = new TuiScreen(20); TuiRenderFrame frame = new TuiRenderer().renderFrame( view, - new TuiScreen(20), + screen, new TuiLayout(20, 20), "", -1, @@ -1042,7 +1154,8 @@ void narrowWrappedToolTitleStillLeavesAnOmissionMarkerWithinFiveRows() { false ); - assertTrue(frame.transcriptLineCount() <= 5); + assertEquals(20, frame.lines().size()); + assertTrue(screen.retainedLineCount() <= 5); assertTrue(frame.lines().stream().anyMatch(line -> line.contains("more lines"))); } @@ -1108,7 +1221,8 @@ view, screen, new TuiLayout(40, 10), "/", 1, view, screen, new TuiLayout(40, 10), "/", 1 ); - assertEquals(withoutOverlay.size() + 3, withOverlay.size()); + assertEquals(10, withOverlay.size()); + assertEquals(10, withoutOverlay.size()); int overlayIndex = -1; int inputBorderIndex = -1; for (int i = 0; i < withOverlay.size(); i++) { @@ -1155,4 +1269,30 @@ private void assertInputBorder(String line, int width) { private void assertInputContent(String line, String content) { assertEquals(INPUT_BACKGROUND + content + ANSI_RESET, line); } + + private int indexOfContentSeparator(List lines) { + for (int index = 0; index < lines.size(); index++) { + if (isContentSeparator(lines.get(index))) { + return index; + } + } + return -1; + } + + private int indexOfLineContaining(List lines, String content) { + for (int index = 0; index < lines.size(); index++) { + if (lines.get(index).contains(content)) { + return index; + } + } + return -1; + } + + private int indexOfInputBorder(List lines) { + return indexOfLineContaining(lines, "─"); + } + + private boolean isContentSeparator(String line) { + return line.contains("┄"); + } } From 60fbf49b12a44e2432922a94dcb355a8e563acec Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 21:01:38 +0800 Subject: [PATCH 36/58] refactor(tui): render fixed alternate-screen frames --- .../java/cn/lypi/transport/tui/FrameSink.java | 3 +- .../lypi/transport/tui/JLineTuiTransport.java | 4 +- .../transport/tui/TerminalFrameRenderer.java | 452 +++----------- .../lypi/transport/tui/TerminalSession.java | 5 - .../cn/lypi/transport/tui/TuiRenderFrame.java | 15 +- .../cn/lypi/transport/tui/TuiRenderer.java | 4 +- .../tui/TerminalFrameRendererTest.java | 577 +++--------------- .../tui/TuiContractEndToEndTest.java | 17 +- .../lypi/transport/tui/TuiFramePtyProbe.java | 2 +- .../lypi/transport/tui/TuiRendererTest.java | 3 +- 10 files changed, 166 insertions(+), 916 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/FrameSink.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/FrameSink.java index 03c57d42..f15128e2 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/FrameSink.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/FrameSink.java @@ -10,8 +10,7 @@ interface FrameSink { void render(List lines); /** - * 渲染已经过物理行校验并携带底部 chrome 元数据的一帧。 - * 兼容 sink 可消费文本视图;真实终端 sink 应覆盖此方法并保留类型化帧。 + * 渲染已经过物理行校验的完整帧。 */ default void render(TuiRenderFrame frame) { render(frame.lines()); diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java index e585d05c..b3f05e44 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java @@ -703,11 +703,11 @@ static JLineTuiTransport open( holder[0].handleInterruptSignal(); } }); - TerminalFrameRenderer frameRenderer = TerminalFrameRenderer.withStartupPadding(io, session::updateRenderedRows); + TerminalFrameRenderer frameRenderer = new TerminalFrameRenderer(io); FrameSink frameSink = new FrameSink() { @Override public void render(List lines) { - render(TuiRenderFrame.transcriptOnly(lines)); + render(TuiRenderFrame.fromTextLines(lines)); } @Override diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java index 274b584a..00d9df34 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java @@ -3,453 +3,151 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; -import java.util.function.IntConsumer; +import java.util.Optional; final class TerminalFrameRenderer { static final String CURSOR_MARKER = "|CURSOR|"; private static final String SYNC_START = "\033[?2026h"; private static final String SYNC_END = "\033[?2026l"; private static final String FULL_CLEAR = "\033[2J\033[H"; - private static final String ANSI_RESET = "\033[0m"; - private static final String WELCOME_PRIMARY = "\033[38;5;81m"; - private static final String WELCOME_ACCENT = "\033[38;5;213m"; - private static final String WELCOME_DIM = "\033[38;5;244m"; - private static final String WELCOME_BOLD = "\033[1m"; + private static final String CLEAR_LINE = "\033[2K"; private static final TerminalLine EMPTY_LINE = new TerminalLine(""); - private static final IntConsumer NOOP_RENDERED_ROWS = rows -> { - }; private final TerminalIo io; - private final IntConsumer renderedRows; - private final boolean startupPaddingEnabled; private List previousLines = List.of(); private int previousWidth; private int previousHeight; - private int maxLinesRendered; - private int previousViewportTop; - private int previousTranscriptLineCount; - private int hardwareCursorRow; - private int startupPaddingLineCount = -1; + private boolean rendered; TerminalFrameRenderer(TerminalIo io) { - this(io, NOOP_RENDERED_ROWS, false); - } - - TerminalFrameRenderer(TerminalIo io, IntConsumer renderedRows) { - this(io, renderedRows, false); - } - - static TerminalFrameRenderer withStartupPadding(TerminalIo io, IntConsumer renderedRows) { - return new TerminalFrameRenderer(io, renderedRows, true); - } - - private TerminalFrameRenderer(TerminalIo io, IntConsumer renderedRows, boolean startupPaddingEnabled) { this.io = io; - this.renderedRows = renderedRows == null ? NOOP_RENDERED_ROWS : renderedRows; - this.startupPaddingEnabled = startupPaddingEnabled; } void render(List lines) throws IOException { - render(TuiRenderFrame.transcriptOnly(lines)); + render(TuiRenderFrame.fromTextLines(lines)); } void render(TuiRenderFrame renderFrame) throws IOException { int width = io.width(); int height = io.height(); - List rawLines = renderFrame.terminalLines(); - if (startupPaddingEnabled && startupPaddingLineCount < 0) { - startupPaddingLineCount = Math.max(0, height - rawLines.size()); - } - CursorFrame frame = stripCursor(withStartupPadding(rawLines)); - List newLines = frame.lines(); - int chromeLineCount = renderFrame.chromeLineCount(); - boolean widthChanged = previousWidth != 0 && previousWidth != width; - boolean heightChanged = previousHeight != 0 && previousHeight != height; - int viewportTop = viewportTopFor(newLines, height); - - if (previousLines.isEmpty() && !widthChanged && !heightChanged) { - writeFullFrame(newLines, frame.cursor(), startupPaddingEnabled, viewportTop, height); - updateState(newLines, width, height, viewportTop, physicalBottomRow(newLines, viewportTop, height), renderFrame.transcriptLineCount()); - return; - } - - if (widthChanged || heightChanged) { - logFullRedraw("terminal size changed"); - writeFullFrame(newLines, frame.cursor(), true, viewportTop, height); - updateState(newLines, width, height, viewportTop, physicalBottomRow(newLines, viewportTop, height), renderFrame.transcriptLineCount()); - return; - } - - if (newLines.size() < previousLines.size()) { - viewportTop = Math.max(0, newLines.size() - height); - writeShrinkPatch(newLines, frame.cursor(), viewportTop, height); - updateState(newLines, width, height, viewportTop, physicalBottomRow(newLines, viewportTop, height), renderFrame.transcriptLineCount()); - io.flush(); - return; - } - - int firstChanged = firstChangedLine(newLines); - if (firstChanged < 0) { - moveCursor(frame.cursor(), previousViewportTop, height); - updateState(newLines, width, height, previousViewportTop, hardwareCursorRow, renderFrame.transcriptLineCount()); - return; - } - - int previousContentViewportTop = Math.max(0, previousLines.size() - height); - if (firstChanged < previousContentViewportTop) { - logFullRedraw("first changed line above previous viewport"); - writeFullFrame(newLines, frame.cursor(), true, viewportTop, height); - updateState(newLines, width, height, viewportTop, physicalBottomRow(newLines, viewportTop, height), renderFrame.transcriptLineCount()); - return; + if (height > 0 && renderFrame.terminalLines().size() > height) { + throw new IllegalArgumentException("frame height exceeds terminal height"); } - boolean appendOnly = newLines.size() > previousLines.size() - && firstChanged == previousLines.size() - && viewportTop == previousViewportTop; - if (appendOnly) { - for (int i = firstChanged; i < newLines.size(); i++) { - io.write("\n"); - writeLine(newLines.get(i)); - } - hardwareCursorRow = physicalBottomRow(newLines, viewportTop, height); - moveCursor(frame.cursor(), viewportTop, height); - updateState(newLines, width, height, viewportTop, hardwareCursorRow, renderFrame.transcriptLineCount()); - io.flush(); - return; - } - - boolean transcriptGrew = renderFrame.transcriptLineCount() > previousTranscriptLineCount; - if (transcriptGrew && viewportTop > previousViewportTop && newLines.size() > previousLines.size()) { - writeFlowingTail(newLines, frame.cursor(), firstChanged, previousViewportTop, viewportTop, height, chromeLineCount); - updateState(newLines, width, height, viewportTop, hardwareCursorRow, renderFrame.transcriptLineCount()); - io.flush(); - return; - } - - if (viewportTop != previousViewportTop) { - writeShrinkPatch(newLines, frame.cursor(), viewportTop, height); - updateState(newLines, width, height, viewportTop, physicalBottomRow(newLines, viewportTop, height), renderFrame.transcriptLineCount()); - io.flush(); - return; - } - - writePatch(newLines, frame.cursor(), firstChanged, lastChangedLine(newLines), previousViewportTop, height); - updateState(newLines, width, height, previousViewportTop, hardwareCursorRow, renderFrame.transcriptLineCount()); - io.flush(); - } - - private void writeFullFrame( - List lines, - java.util.Optional cursor, - boolean clear, - int viewportTop, - int height - ) throws IOException { - if (clear) { - io.write(SYNC_START); - io.write(FULL_CLEAR); - } - writeLines(visibleLines(lines, viewportTop, height)); - hardwareCursorRow = physicalBottomRow(lines, viewportTop, height); - moveCursor(cursor, viewportTop, height); - if (clear) { - io.write(SYNC_END); - } - io.flush(); - } - - private void writePatch( - List lines, - java.util.Optional cursor, - int firstChanged, - int lastChanged, - int viewportTop, - int height - ) throws IOException { - io.write(SYNC_START); - for (int i = firstChanged; i <= Math.min(lastChanged, lines.size() - 1); i++) { - if (!visibleLogicalRow(i + 1, viewportTop, height)) { - continue; - } - int physicalRow = physicalRow(i + 1, viewportTop, height); - io.write("\033[" + physicalRow + ";1H"); - io.write("\033[2K"); - writeLine(lines.get(i)); - hardwareCursorRow = physicalRow; + CursorFrame frame = stripCursor(renderFrame.terminalLines()); + boolean firstFrame = !rendered; + boolean resized = !firstFrame && (previousWidth != width || previousHeight != height); + if (firstFrame || resized) { + writeFullFrame(frame); + } else { + writeDiff(frame); } - moveCursor(cursor, viewportTop, height); - io.write(SYNC_END); + previousLines = List.copyOf(frame.lines()); + previousWidth = width; + previousHeight = height; + rendered = true; } - private void writeShrinkPatch( - List lines, - java.util.Optional cursor, - int viewportTop, - int height - ) throws IOException { + private void writeFullFrame(CursorFrame frame) throws IOException { io.write(SYNC_START); - List visible = visibleLines(lines, viewportTop, height); - for (int row = 0; row < Math.max(previousHeight, height); row++) { - int physicalRow = row + 1; - if (physicalRow > Math.max(1, height)) { - break; - } - io.write("\033[" + physicalRow + ";1H"); - io.write("\033[2K"); - if (row < visible.size()) { - writeLine(visible.get(row)); - hardwareCursorRow = physicalRow; - } + io.write(FULL_CLEAR); + for (int row = 0; row < frame.lines().size(); row++) { + moveTo(row + 1, 1); + writeLine(frame.lines().get(row)); } - moveCursor(cursor, viewportTop, height); + moveCursor(frame.cursor()); io.write(SYNC_END); + io.flush(); } - private void writeFlowingTail( - List lines, - java.util.Optional cursor, - int firstChanged, - int previousViewportTop, - int viewportTop, - int height, - int chromeLineCount - ) throws IOException { - io.write(SYNC_START); - int firstVisibleChange = Math.max(firstChanged, previousViewportTop); - int firstChromeLine = Math.max(0, lines.size() - chromeLineCount); - int firstVisibleChromeLine = Math.max(firstChromeLine, viewportTop); - int flowingEnd = chromeLineCount == 0 ? lines.size() : Math.min(lines.size(), firstChromeLine); - int startRow = physicalRow(firstVisibleChange + 1, previousViewportTop, height); - if (chromeLineCount == 0) { - io.write("\033[" + startRow + ";1H"); - for (int i = firstVisibleChange; i < flowingEnd; i++) { - if (i > firstVisibleChange) { - io.write("\r\n"); - } - io.write("\033[2K"); - writeLine(lines.get(i)); - hardwareCursorRow = physicalRow(i + 1, viewportTop, height); - } - } else { - boolean bottomContainsTranscript = false; - for (int i = firstVisibleChange; i < flowingEnd; i++) { - int physicalRow = physicalRow(i + 1, previousViewportTop, height); - io.write("\033[" + physicalRow + ";1H"); - io.write("\033[2K"); - writeLine(lines.get(i)); - hardwareCursorRow = physicalRow; - bottomContainsTranscript = physicalRow == Math.max(1, height); - } - int scrollDelta = Math.max(0, viewportTop - previousViewportTop); - for (int scroll = 0; scroll < scrollDelta; scroll++) { - io.write("\033[" + Math.max(1, height) + ";1H"); - if (!bottomContainsTranscript) { - io.write("\033[2K"); - } - io.write("\r\n"); - hardwareCursorRow = Math.max(1, height); - bottomContainsTranscript = false; - } - for (int i = firstVisibleChromeLine; i < lines.size(); i++) { - if (!visibleLogicalRow(i + 1, viewportTop, height)) { + private void writeDiff(CursorFrame frame) throws IOException { + int firstChanged = firstChangedLine(frame.lines()); + int lastChanged = lastChangedLine(frame.lines()); + if (firstChanged >= 0) { + io.write(SYNC_START); + for (int row = firstChanged; row <= lastChanged; row++) { + TerminalLine previous = lineAt(previousLines, row); + TerminalLine current = lineAt(frame.lines(), row); + if (previous.equals(current)) { continue; } - int physicalRow = physicalRow(i + 1, viewportTop, height); - io.write("\033[" + physicalRow + ";1H"); - io.write("\033[2K"); - writeLine(lines.get(i)); - hardwareCursorRow = physicalRow; + moveTo(row + 1, 1); + io.write(CLEAR_LINE); + if (row < frame.lines().size()) { + writeLine(current); + } } + moveCursor(frame.cursor()); + io.write(SYNC_END); + io.flush(); + return; } - moveCursor(cursor, viewportTop, height); - io.write(SYNC_END); - } - - private int firstChangedLine(List newLines) { - int max = Math.max(previousLines.size(), newLines.size()); - for (int i = 0; i < max; i++) { - TerminalLine previous = i < previousLines.size() ? previousLines.get(i) : EMPTY_LINE; - TerminalLine current = i < newLines.size() ? newLines.get(i) : EMPTY_LINE; - if (!previous.equals(current)) { - return i; - } + if (frame.cursor().isPresent()) { + moveCursor(frame.cursor()); + io.flush(); } - return -1; } - private int lastChangedLine(List newLines) { - int max = Math.max(previousLines.size(), newLines.size()); - for (int i = max - 1; i >= 0; i--) { - TerminalLine previous = i < previousLines.size() ? previousLines.get(i) : EMPTY_LINE; - TerminalLine current = i < newLines.size() ? newLines.get(i) : EMPTY_LINE; - if (!previous.equals(current)) { - return i; + private int firstChangedLine(List lines) { + int maximum = Math.max(previousLines.size(), lines.size()); + for (int row = 0; row < maximum; row++) { + if (!lineAt(previousLines, row).equals(lineAt(lines, row))) { + return row; } } return -1; } - private void moveCursor(java.util.Optional cursor, int viewportTop, int height) throws IOException { - if (cursor.isEmpty()) { - return; - } - CursorPosition position = cursor.orElseThrow(); - int physicalRow = physicalRow(position.row(), viewportTop, height); - io.write("\033[" + physicalRow + ";" + position.column() + "H"); - hardwareCursorRow = physicalRow; - } - - private void updateState( - List lines, - int width, - int height, - int viewportTop, - int currentCursorRow, - int transcriptLineCount - ) { - previousLines = List.copyOf(lines); - previousWidth = width; - previousHeight = height; - maxLinesRendered = Math.max(maxLinesRendered, lines.size()); - previousViewportTop = Math.max(0, viewportTop); - previousTranscriptLineCount = Math.max(0, transcriptLineCount); - hardwareCursorRow = Math.max(1, currentCursorRow); - renderedRows.accept(physicalBottomRow(lines, previousViewportTop, height)); - } - - private int viewportTopFor(List lines, int height) { - return Math.max(0, lines.size() - height); - } - - private int physicalBottomRow(List lines, int viewportTop, int height) { - return physicalRow(Math.max(1, lines.size()), viewportTop, height); - } - - private int physicalRow(int logicalRow, int viewportTop, int height) { - int physicalRow = logicalRow - viewportTop; - return Math.max(1, Math.min(Math.max(1, height), physicalRow)); - } - - private boolean visibleLogicalRow(int logicalRow, int viewportTop, int height) { - int physicalRow = logicalRow - viewportTop; - return physicalRow >= 1 && physicalRow <= Math.max(1, height); - } - - private List visibleLines(List lines, int viewportTop, int height) { - if (lines.isEmpty()) { - return List.of(); - } - int start = Math.max(0, viewportTop); - int end = Math.min(lines.size(), start + Math.max(1, height)); - if (start >= end) { - return List.of(); - } - return lines.subList(start, end); - } - - private List withStartupPadding(List lines) { - if (startupPaddingLineCount <= 0) { - return lines; - } - List padded = new ArrayList<>(startupPaddingLineCount + lines.size()); - padded.addAll(startupWelcomeLines(startupPaddingLineCount, io.width())); - padded.addAll(lines); - return padded; - } - - private List startupWelcomeLines(int lineCount, int width) { - if (lineCount <= 0) { - return List.of(); - } - List content = width >= 46 ? fullWelcomeLines(width) : compactWelcomeLines(width); - List result = new ArrayList<>(lineCount); - int topPadding = Math.max(0, (lineCount - content.size()) / 2); - for (int i = 0; i < topPadding && result.size() < lineCount; i++) { - result.add(EMPTY_LINE); - } - for (TerminalLine line : content) { - if (result.size() >= lineCount) { - break; + private int lastChangedLine(List lines) { + int maximum = Math.max(previousLines.size(), lines.size()); + for (int row = maximum - 1; row >= 0; row--) { + if (!lineAt(previousLines, row).equals(lineAt(lines, row))) { + return row; } - result.add(line); - } - while (result.size() < lineCount) { - result.add(EMPTY_LINE); } - return result; - } - - private List fullWelcomeLines(int width) { - return List.of( - center(WELCOME_DIM + "╭────────────────────────────────────────────────────────╮" + ANSI_RESET, width), - center(WELCOME_PRIMARY + WELCOME_BOLD + "██╗ ██╗ ██╗ ██████╗ ██╗" + ANSI_RESET, width), - center(WELCOME_PRIMARY + WELCOME_BOLD + "██║ ╚██╗ ██╔╝ ██╔══██╗ ██║" + ANSI_RESET, width), - center(WELCOME_ACCENT + WELCOME_BOLD + "██║ ╚████╔╝ █████╗██████╔╝ ██║" + ANSI_RESET, width), - center(WELCOME_ACCENT + WELCOME_BOLD + "██║ ╚██╔╝ ╚════╝██╔═══╝ ██║" + ANSI_RESET, width), - center(WELCOME_PRIMARY + WELCOME_BOLD + "███████╗ ██║ ██║ ██║" + ANSI_RESET, width), - center(WELCOME_DIM + "╰──────────────────────── LY-PI ────────────────────────╯" + ANSI_RESET, width), - center(WELCOME_ACCENT + "coding agent cockpit" + ANSI_RESET, width) - ); + return -1; } - private List compactWelcomeLines(int width) { - return List.of( - center(WELCOME_PRIMARY + WELCOME_BOLD + "LY-PI" + ANSI_RESET, width), - center(WELCOME_ACCENT + "coding agent" + ANSI_RESET, width) - ); + private TerminalLine lineAt(List lines, int row) { + return row < lines.size() ? lines.get(row) : EMPTY_LINE; } - private TerminalLine center(String line, int width) { - int lineWidth = AnsiWidth.displayWidth(line); - if (lineWidth >= width) { - return new TerminalLine(line); + private void moveCursor(Optional cursor) throws IOException { + if (cursor.isPresent()) { + CursorPosition position = cursor.orElseThrow(); + moveTo(position.row(), position.column()); } - return new TerminalLine(" ".repeat((width - lineWidth) / 2) + line); } - private void writeLines(List lines) throws IOException { - for (int index = 0; index < lines.size(); index++) { - if (index > 0) { - io.write("\n"); - } - writeLine(lines.get(index)); - } + private void moveTo(int row, int column) throws IOException { + io.write("\033[" + row + ";" + column + "H"); } private void writeLine(TerminalLine line) throws IOException { - io.write(AnsiWidth.truncate(line.text(), io.width())); - } - - private void logFullRedraw(String reason) { - if (!"1".equals(System.getenv("PI_DEBUG_REDRAW"))) { - return; - } - System.err.println("PI_DEBUG_REDRAW full render: " + reason - + " previousLines=" + previousLines.size() - + " maxLinesRendered=" + maxLinesRendered - + " previousViewportTop=" + previousViewportTop); + int width = io.width(); + io.write(width > 0 ? AnsiWidth.truncate(line.text(), width) : line.text()); } private CursorFrame stripCursor(List lines) { - List stripped = new ArrayList<>(); + List stripped = new ArrayList<>(lines.size()); CursorPosition cursor = null; for (int row = 0; row < lines.size(); row++) { TerminalLine line = lines.get(row); String text = line.text(); int marker = text.indexOf(CURSOR_MARKER); - if (marker >= 0) { - String before = text.substring(0, marker); - String after = text.substring(marker + CURSOR_MARKER.length()); - stripped.add(new TerminalLine(before + after)); - cursor = new CursorPosition(row + 1, AnsiWidth.displayWidth(before) + 1); - } else { + if (marker < 0) { stripped.add(line); + continue; } + String before = text.substring(0, marker); + String after = text.substring(marker + CURSOR_MARKER.length()); + stripped.add(new TerminalLine(before + after)); + cursor = new CursorPosition(row + 1, AnsiWidth.displayWidth(before) + 1); } - return new CursorFrame(stripped, java.util.Optional.ofNullable(cursor)); + return new CursorFrame(List.copyOf(stripped), Optional.ofNullable(cursor)); } - private record CursorFrame(List lines, java.util.Optional cursor) { + private record CursorFrame(List lines, Optional cursor) { } private record CursorPosition(int row, int column) { diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java index a856bbc4..2c6a5151 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java @@ -19,7 +19,6 @@ public final class TerminalSession implements AutoCloseable { private final AutoCloseable resizeHandler; private final AutoCloseable interruptHandler; private boolean closed; - private int renderedRows; private TerminalSession(TerminalIo io, AutoCloseable rawMode, AutoCloseable resizeHandler, AutoCloseable interruptHandler) { this.io = io; @@ -78,10 +77,6 @@ static TerminalSession open(TerminalIo io, Runnable resizeCallback, Runnable int } } - void updateRenderedRows(int renderedRows) { - this.renderedRows = Math.max(0, renderedRows); - } - @Override public void close() throws Exception { if (closed) { diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderFrame.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderFrame.java index a04d66a0..a42e159d 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderFrame.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderFrame.java @@ -2,28 +2,19 @@ import java.util.List; -record TuiRenderFrame(List terminalLines, int chromeLineCount) { +record TuiRenderFrame(List terminalLines) { TuiRenderFrame { terminalLines = List.copyOf(terminalLines); - chromeLineCount = Math.max(0, Math.min(chromeLineCount, terminalLines.size())); } - static TuiRenderFrame fromTextLines(List lines, int chromeLineCount) { - return new TuiRenderFrame(toTerminalLines(lines), chromeLineCount); - } - - static TuiRenderFrame transcriptOnly(List lines) { - return fromTextLines(lines, 0); + static TuiRenderFrame fromTextLines(List lines) { + return new TuiRenderFrame(toTerminalLines(lines)); } List lines() { return terminalLines.stream().map(TerminalLine::text).toList(); } - int transcriptLineCount() { - return terminalLines.size() - chromeLineCount; - } - private static List toTerminalLines(List lines) { return lines.stream().map(TerminalLine::new).toList(); } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java index eb5f0876..83e19eb3 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java @@ -113,8 +113,6 @@ TuiRenderFrame renderFrame( screen.setTranscript(fullHistory); List history = topPaddedTail(screen.visibleTranscript(), content.historyHeight()); List live = topPaddedTail(fullLive, content.liveHeight()); - int chromeLineCount = inputBlock.lines().size() + overlay.size() + regions.statusHeight(); - List lines = new ArrayList<>(); lines.addAll(history); if (content.separatorHeight() > 0) { @@ -127,7 +125,7 @@ TuiRenderFrame renderFrame( if (lines.size() != layout.height()) { throw new IllegalStateException("rendered frame must match terminal height"); } - return TuiRenderFrame.fromTextLines(lines, chromeLineCount); + return TuiRenderFrame.fromTextLines(lines); } List renderTranscriptBlocks( diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalFrameRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalFrameRendererTest.java index dd063c40..ed962730 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalFrameRendererTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalFrameRendererTest.java @@ -2,579 +2,142 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.IOException; import java.util.List; +import java.util.stream.IntStream; import org.junit.jupiter.api.Test; class TerminalFrameRendererTest { - @Test - void firstFrameWritesContentWithoutClearingScreenAndPositionsHardwareCursor() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of("hello", "world|CURSOR|")); - - assertEquals("hello\nworld\033[2;6H", io.output.toString()); - assertFalse(io.output.toString().contains("\033[H\033[J")); - } - - @Test - void firstFrameWithStartupPaddingClearsScreenPadsToViewportBottomAndPositionsHardwareCursor() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 4; - TerminalFrameRenderer renderer = TerminalFrameRenderer.withStartupPadding(io, rows -> { - }); - - renderer.render(List.of("hello", "world|CURSOR|")); - - String output = io.output.toString(); - assertTrue(output.startsWith("\033[?2026h\033[2J\033[H")); - assertTrue(output.endsWith("hello\nworld\033[4;6H\033[?2026l")); - } - - @Test - void firstFrameWithStartupPaddingRendersWelcomeScreen() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.width = 80; - io.height = 11; - TerminalFrameRenderer renderer = TerminalFrameRenderer.withStartupPadding(io, rows -> { - }); - - renderer.render(List.of("hello", "> |CURSOR|", "status")); - - String plainOutput = stripAnsi(io.output.toString()); - assertTrue(plainOutput.contains("LY-PI")); - assertTrue(plainOutput.contains("coding agent")); - assertFalse(plainOutput.contains("local-first")); - assertTrue(plainOutput.contains("██████╗ ")); - assertTrue(plainOutput.contains("██╔══██╗")); - assertTrue(plainOutput.contains("██████╔╝")); - assertTrue(plainOutput.endsWith("hello\n> \nstatus")); - assertTrue(io.output.toString().endsWith("\033[10;3H\033[?2026l")); - - for (String line : plainOutput.split("\n", -1)) { - assertTrue(AnsiWidth.displayWidth(line) <= io.width, () -> "line exceeds terminal width: " + line); - } - } - - @Test - void startupPaddingRemainsPartOfLinearScrollbackAfterFirstFrame() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 4; - TerminalFrameRenderer renderer = TerminalFrameRenderer.withStartupPadding(io, rows -> { - }); - - renderer.render(List.of("hello", "> |CURSOR|")); - io.output.setLength(0); - renderer.render(List.of("hello", "assistant", "> |CURSOR|")); - - assertTrue(io.output.toString().contains("\r\n")); - assertTrue(io.output.toString().contains("\033[2Kassistant")); - assertTrue(io.output.toString().contains("\033[2K> ")); - assertTrue(io.output.toString().endsWith("\033[4;3H\033[?2026l")); - } - - @Test - void terminalWritesTruncateLongPhysicalLinesToTerminalWidth() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.width = 10; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of("0123456789abcdef", "> |CURSOR|")); - - String output = io.output.toString(); - assertFalse(output.contains("0123456789abcdef")); - assertTrue(output.contains("012345678…")); - } - - @Test - void appendsTailLinesWithoutHomeAndClear() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of("hello")); - io.output.setLength(0); - renderer.render(List.of("hello", "new line")); - - assertEquals("\nnew line", io.output.toString()); - assertFalse(io.output.toString().contains("\033[H\033[J")); - } - - @Test - void inputEditRewritesOnlyChangedVisibleLine() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of("history", "> a|CURSOR|")); - io.output.setLength(0); - renderer.render(List.of("history", "> ab|CURSOR|")); - - assertEquals("\033[?2026h\033[2;1H\033[2K> ab\033[2;5H\033[?2026l", io.output.toString()); - } - - @Test - void overflowInputEditUsesPhysicalViewportRow() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 4; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of("one", "two", "three", "> a|CURSOR|", "status")); - io.output.setLength(0); - renderer.render(List.of("one", "two", "three", "> ab|CURSOR|", "status")); - - assertEquals("\033[?2026h\033[3;1H\033[2K> ab\033[3;5H\033[?2026l", io.output.toString()); - } - - @Test - void overflowCursorOnlyMoveUsesPhysicalViewportRow() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 4; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of("one", "two", "three", "> a|CURSOR|b", "status")); - io.output.setLength(0); - renderer.render(List.of("one", "two", "three", "> ab|CURSOR|", "status")); - - assertEquals("\033[3;5H", io.output.toString()); - } - - @Test - void overflowStatusLinePatchUsesPhysicalBottomRow() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 4; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of("one", "two", "three", "> input|CURSOR|", "status A")); - io.output.setLength(0); - renderer.render(List.of("one", "two", "three", "> input|CURSOR|", "status B")); - - assertEquals("\033[?2026h\033[4;1H\033[2Kstatus B\033[3;8H\033[?2026l", io.output.toString()); - } - - @Test - void middleLineChangePatchesFromFirstChangedLine() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of("one", "two", "three")); - io.output.setLength(0); - renderer.render(List.of("one", "TWO", "three")); - - assertEquals("\033[?2026h\033[2;1H\033[2KTWO\033[?2026l", io.output.toString()); - } - - @Test - void sequentialFramePatchAddressesEachPhysicalRowAndReturnsCursorToInput() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(TuiRenderFrame.fromTextLines(List.of("tool one", "tool two", "status-old"), 0)); - io.output.setLength(0); - renderer.render(TuiRenderFrame.fromTextLines(List.of( - "tool one", - "tool two", - "status-updated", - "> input|CURSOR|", - "status" - ), 2)); - - String output = io.output.toString(); - assertTrue(output.contains("\033[3;1H\033[2Kstatus-updated")); - assertTrue(output.contains("\033[4;1H\033[2K> input")); - assertTrue(output.contains("\033[5;1H\033[2Kstatus")); - assertTrue(output.endsWith("\033[4;8H\033[?2026l")); - } + private static final String SYNC_START = "\033[?2026h"; + private static final String SYNC_END = "\033[?2026l"; + private static final String FULL_CLEAR = "\033[2J\033[H"; @Test - void tailChangePatchesLastLineWithoutAppendingDuplicateTranscript() throws Exception { + void rejectsFramesTallerThanTerminalWithoutWritingPartialOutput() { RecordingTerminalIo io = new RecordingTerminalIo(); + io.height = 5; TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - renderer.render(List.of("one", "two")); - io.output.setLength(0); - renderer.render(List.of("one", "TWO")); - - assertEquals("\033[?2026h\033[2;1H\033[2KTWO\033[?2026l", io.output.toString()); - } - - @Test - void bottomActivityChangeAfterOverflowPatchesVisibleRowsWithoutAppendingToScrollback() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 4; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of("one", "two", "three", "> |CURSOR|", "status A")); - io.output.setLength(0); - renderer.render(List.of("one", "two", "three", "> draft|CURSOR|", "status A")); - - assertEquals("\033[?2026h\033[3;1H\033[2K> draft\033[3;8H\033[?2026l", io.output.toString()); - } - - @Test - void transcriptAppendThatOverflowsTerminalScrollsLinearTail() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 4; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); + assertThrows(IllegalArgumentException.class, () -> renderer.render( + IntStream.rangeClosed(1, io.height + 1).mapToObj(String::valueOf).toList() + )); - renderer.render(TuiRenderFrame.fromTextLines(List.of("one", "two", "> |CURSOR|", "status"), 2)); - io.output.setLength(0); - renderer.render(TuiRenderFrame.fromTextLines(List.of("one", "two", "three", "> |CURSOR|", "status"), 2)); - - String output = io.output.toString(); - assertTrue(output.contains("\r\n")); - assertFalse(output.contains("\033[1;2r")); - assertTrue(output.contains("\033[2Kthree")); - assertTrue(output.contains("\033[2K> ")); - assertTrue(output.contains("\033[2Kstatus")); - assertTrue(output.endsWith("\033[3;3H\033[?2026l")); + assertEquals("", io.output.toString()); } @Test - void transcriptInsertBeforeTailRewritesLinearTail() throws Exception { + void firstFrameClearsAlternateScreenAndUsesAbsoluteRows() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 8; TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - renderer.render(TuiRenderFrame.fromTextLines(List.of( - "assistant old", - "──", - "> draft|CURSOR|", - "──", - "session PLAN" - ), 3)); - io.output.setLength(0); - renderer.render(TuiRenderFrame.fromTextLines(List.of( - "assistant old", - "tool running read", - "──", - "> draft|CURSOR|", - "──", - "session PLAN" - ), 3)); - - String output = io.output.toString(); - assertTrue(output.contains("\033[2;1H\033[2Ktool running read")); - assertTrue(output.contains("\033[3;1H\033[2K──")); - assertTrue(output.contains("\033[4;1H\033[2K> draft")); - assertTrue(output.contains("\033[5;1H\033[2K──")); - assertTrue(output.contains("\033[6;1H\033[2Ksession PLAN")); - assertTrue(output.endsWith("\033[4;8H\033[?2026l")); - } - - @Test - void transcriptAppendAfterOverflowScrollsLinearTail() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 4; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); + renderer.render(List.of("hello", "world|CURSOR|")); - renderer.render(TuiRenderFrame.fromTextLines(List.of("one", "two", "three", "> |CURSOR|", "status"), 2)); - io.output.setLength(0); - renderer.render(TuiRenderFrame.fromTextLines(List.of("one", "two", "three", "four", "> |CURSOR|", "status"), 2)); - - String output = io.output.toString(); - assertTrue(output.contains("\r\n")); - assertFalse(output.contains("\033[1;2r")); - assertTrue(output.contains("\033[2Kfour")); - assertTrue(output.contains("\033[2K> ")); - assertTrue(output.contains("\033[2Kstatus")); - assertTrue(output.endsWith("\033[3;3H\033[?2026l")); + assertEquals( + SYNC_START + + FULL_CLEAR + + "\033[1;1Hhello" + + "\033[2;1Hworld" + + "\033[2;6H" + + SYNC_END, + io.output.toString() + ); } @Test - void transcriptAppendWithChromeScrollsLinearTail() throws Exception { + void fixedFramePatchUsesPhysicalRowsWithoutTerminalScrolling() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); io.height = 6; TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(TuiRenderFrame.fromTextLines(List.of( - "assistant old", - "tools: read x1 (Ctrl+O details)", - "──", - "> |CURSOR|", - "──", - "session PLAN" - ), 3)); + renderer.render(List.of("history-1", "history-2", "separator", "live-old", "> |CURSOR|", "status")); io.output.setLength(0); - renderer.render(TuiRenderFrame.fromTextLines(List.of( - "assistant old", - "tools: read x1 (Ctrl+O details)", - "done write write {content=问题:50米洗车店我该开车去还是走路去", - " writing", - " written bytes 洗车店.md", - "assistant done", - "──", - "> |CURSOR|", - "──", - "session PLAN" - ), 3)); - - String output = io.output.toString(); - assertTrue(output.contains("\r\n")); - assertFalse(output.contains("\033[1;3r")); - assertTrue(output.contains("\033[2K")); - assertTrue(output.contains("\033[2K> ")); - assertTrue(output.contains("\033[2Ksession PLAN")); - } - @Test - void toolProgressAppendAfterOverflowScrollsLinearTail() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 5; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); + renderer.render(List.of("history-2", "history-3", "separator", "live-new", "> |CURSOR|", "status")); - renderer.render(TuiRenderFrame.fromTextLines(List.of( - "tool running bash: test", - " old detail", - "──", - "> |CURSOR|", - "status" - ), 3)); - io.output.setLength(0); - renderer.render(TuiRenderFrame.fromTextLines(List.of( - "tool running bash: test", - " old detail", - " new detail", - "──", - "> |CURSOR|", - "status" - ), 3)); - - String output = io.output.toString(); - assertTrue(output.contains("new detail")); - assertTrue(output.contains("\r\n")); - assertTrue(output.contains("\033[2K> ")); - assertTrue(output.contains("\033[2Kstatus")); + assertEquals( + SYNC_START + + "\033[1;1H\033[2Khistory-2" + + "\033[2;1H\033[2Khistory-3" + + "\033[4;1H\033[2Klive-new" + + "\033[5;3H" + + SYNC_END, + io.output.toString() + ); + assertFalse(io.output.toString().contains("\r\n")); + assertFalse(io.output.toString().contains("\n")); + assertFalse(io.output.toString().contains(FULL_CLEAR)); + assertFalse(io.output.toString().contains("\033[6;1H")); } @Test - void viewportScrollPatchDoesNotRewriteRowsThatScrolledOutOfView() throws Exception { + void cursorOnlyChangeMovesHardwareCursorWithoutRewritingLine() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 5; TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of( - "tool done glob: Glob", - " matched AGENTS.md", - "──", - "> |CURSOR|", - "──", - "session PLAN" - )); + renderer.render(List.of("status", "> a|CURSOR|bc")); io.output.setLength(0); - renderer.render(List.of( - "tool done glob: Glob", - " matched AGENTS.md", - "tool done read: Read", - " File: AGENTS.md", - "assistant answer", - "──", - "> |CURSOR|", - "──", - "session PLAN" - )); - String output = io.output.toString(); - assertFalse(output.contains("tool done glob: Glob")); - assertFalse(output.contains("matched AGENTS.md")); - assertTrue(output.contains("tool done read: Read")); - assertTrue(output.contains("assistant answer")); - } + renderer.render(List.of("status", "> ab|CURSOR|c")); - @Test - void chromeGrowthWithoutTranscriptAppendDoesNotScrollTerminal() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 5; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(TuiRenderFrame.fromTextLines(List.of( - "line1", - "line2", - "line3", - "line4", - "> |CURSOR|", - "status" - ), 2)); - io.output.setLength(0); - renderer.render(TuiRenderFrame.fromTextLines(List.of( - "line1", - "line2", - "line3", - "line4", - "> wrapped input", - "continuation|CURSOR|", - "status" - ), 3)); - - String output = io.output.toString(); - assertFalse(output.contains("\r\n")); - assertFalse(output.contains("\n")); - assertTrue(output.contains("\033[3;1H\033[2K> wrapped input")); - assertTrue(output.contains("\033[4;1H\033[2Kcontinuation")); - assertTrue(output.contains("\033[5;1H\033[2Kstatus")); + assertEquals("\033[2;5H", io.output.toString()); } @Test - void contentShrinkPatchesVisibleRowsWithoutClearingTerminalScrollback() throws Exception { + void shorterFrameClearsTrailingOldRows() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of("one", "two", "three")); + renderer.render(List.of("one", "two", "three|CURSOR|")); io.output.setLength(0); - renderer.render(List.of("one")); - assertTrue(io.output.toString().startsWith("\033[?2026h\033[1;1H\033[2Kone")); - assertTrue(io.output.toString().contains("\033[3;1H\033[2K")); - assertTrue(io.output.toString().endsWith("\033[?2026l")); - assertFalse(io.output.toString().contains("\033[2J\033[H")); - } - - @Test - void transientRuntimeLineRemovalPatchesBottomChromeWithoutFullRedraw() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 6; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of( - "test 1", - "test 2", - "test 3", - "test 4", - "test 5", - "test 6", - "test 7", - "test 8", - "test 9", - "test 10", - "test 11", - "test 12", - "test 13", - "test 14", - "test 15", - "test 16", - "test 17", - "test 18", - "test 19", - "test 20", - "· turn running abc", - "──", - "> |CURSOR|", - "──", - "session running" - )); - io.output.setLength(0); - renderer.render(List.of( - "test 1", - "test 2", - "test 3", - "test 4", - "test 5", - "test 6", - "test 7", - "test 8", - "test 9", - "test 10", - "test 11", - "test 12", - "test 13", - "test 14", - "test 15", - "test 16", - "test 17", - "test 18", - "test 19", - "test 20", - "──", - "> |CURSOR|", - "──", - "session PLAN" - )); + renderer.render(List.of("one", "two|CURSOR|")); assertEquals( - "\033[?2026h" - + "\033[1;1H\033[2Ktest 19" - + "\033[2;1H\033[2Ktest 20" - + "\033[3;1H\033[2K──" - + "\033[4;1H\033[2K> " - + "\033[5;1H\033[2K──" - + "\033[6;1H\033[2Ksession PLAN" - + "\033[4;3H" - + "\033[?2026l", + SYNC_START + + "\033[3;1H\033[2K" + + "\033[2;4H" + + SYNC_END, io.output.toString() ); - assertFalse(io.output.toString().contains("\033[2J\033[H")); } @Test - void shrinkAfterLargeExpandedFrameUsesCurrentTailViewport() throws Exception { + void resizeClearsAndRendersWholeCurrentFrame() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 5; TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of( - "tool header", - " line 1", - " line 2", - " line 3", - " line 4", - " line 5", - " line 6", - "──", - "> |CURSOR|", - "status" - )); + renderer.render(List.of("one", "two|CURSOR|")); io.output.setLength(0); - renderer.render(List.of( - "tools: bash x1 (Ctrl+O details)", - "──", - "> |CURSOR|", - "status" - )); - - String output = io.output.toString(); - assertTrue(output.contains("\033[1;1H\033[2Ktools: bash x1 (Ctrl+O details)")); - assertTrue(output.contains("\033[2;1H\033[2K──")); - assertTrue(output.contains("\033[3;1H\033[2K> ")); - assertTrue(output.contains("\033[4;1H\033[2Kstatus")); - assertFalse(output.contains("line 6")); - } - - @Test - void widthOrHeightChangeUsesFullRenderWithoutClearingTerminalScrollback() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); + io.width = 40; - renderer.render(List.of("one")); - io.output.setLength(0); - io.width = 100; - renderer.render(List.of("one")); + renderer.render(List.of("one", "two|CURSOR|")); - assertEquals("\033[?2026h\033[2J\033[Hone\033[?2026l", io.output.toString()); + assertTrue(io.output.toString().startsWith(SYNC_START + FULL_CLEAR)); + assertTrue(io.output.toString().contains("\033[1;1Hone")); + assertTrue(io.output.toString().contains("\033[2;1Htwo")); + assertTrue(io.output.toString().endsWith("\033[2;4H" + SYNC_END)); } @Test - void changeAbovePreviousViewportUsesFullRender() throws Exception { + void terminalWritesTruncatePhysicalLinesToCurrentWidth() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 2; + io.width = 5; TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - renderer.render(List.of("one", "two", "three")); - io.output.setLength(0); - renderer.render(List.of("ONE", "two", "three")); + renderer.render(TuiRenderFrame.fromTextLines(List.of("abcdefgh"))); - assertEquals("\033[?2026h\033[2J\033[Htwo\nthree\033[?2026l", io.output.toString()); + assertTrue(io.output.toString().contains("\033[1;1Habcd…")); + assertFalse(io.output.toString().contains("abcdef")); } @Test - void firstFrameOnlyWritesVisibleViewportRows() throws Exception { + void unchangedFrameWithoutCursorProducesNoOutput() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 3; TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); + renderer.render(List.of("unchanged")); + io.output.setLength(0); - renderer.render(List.of("one", "two", "three", "four|CURSOR|")); + renderer.render(List.of("unchanged")); - assertEquals("two\nthree\nfour\033[3;5H", io.output.toString()); + assertEquals("", io.output.toString()); } private static final class RecordingTerminalIo implements TerminalIo { @@ -608,7 +171,7 @@ public int height() { } @Override - public AutoCloseable onResize(Runnable callback) throws IOException { + public AutoCloseable onResize(Runnable callback) { return () -> { }; } @@ -619,8 +182,4 @@ public AutoCloseable onInterrupt(Runnable callback) { }; } } - - private static String stripAnsi(String value) { - return value.replaceAll("\\u001B\\[[0-9;?]*[A-Za-z]", ""); - } } diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java index de9a4d3b..59b1d73b 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java @@ -466,8 +466,8 @@ void toolLifecycleDetailsRespectCollapsedAndExpandedDisplayBudgets() { true ); - assertTrue(collapsed.transcriptLineCount() <= 5); - assertTrue(expanded.transcriptLineCount() <= 40); + assertTrue(renderedContentLines(collapsed).size() <= 5); + assertTrue(renderedContentLines(expanded).size() <= 40); assertTrue(collapsed.lines().stream().anyMatch(line -> line.contains("earlier lines"))); assertTrue(expanded.lines().stream().anyMatch(line -> line.contains("earlier lines"))); } @@ -503,11 +503,22 @@ private static List renderedTranscript(TuiViewModel view) { List.of(), false ); - return frame.lines().subList(0, frame.transcriptLineCount()).stream() + return renderedContentLines(frame).stream() .map(line -> line.replaceAll("\\u001B\\[[;\\d]*m", "")) .toList(); } + private static List renderedContentLines(TuiRenderFrame frame) { + int inputStart = java.util.stream.IntStream.range(0, frame.lines().size()) + .filter(index -> frame.lines().get(index).contains("─".repeat(10))) + .findFirst() + .orElse(frame.lines().size() - 1); + return frame.lines().subList(0, inputStart).stream() + .filter(line -> !line.isBlank()) + .filter(line -> !line.contains("┄")) + .toList(); + } + private static PermissionDecision decision( PermissionBehavior behavior, String message, diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java index cc8a375b..70592817 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java @@ -17,7 +17,7 @@ public static void main(String[] args) throws Exception { Terminal terminal = TerminalBuilder.builder().system(true).build(); TerminalIo io = new JLineTerminalIo(terminal); try (TerminalSession session = TerminalSession.open(io)) { - TerminalFrameRenderer frameRenderer = new TerminalFrameRenderer(io, session::updateRenderedRows); + TerminalFrameRenderer frameRenderer = new TerminalFrameRenderer(io); TuiRenderer renderer = new TuiRenderer(); TuiScreen screen = new TuiScreen(Math.max(1, io.height() - 4)); TuiLayout layout = new TuiLayout(io.width(), io.height()); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java index c011c9b0..4c064e1e 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java @@ -1160,8 +1160,7 @@ void narrowWrappedToolTitleStillLeavesAnOmissionMarkerWithinFiveRows() { } private void assertFrameFitsHeightWithOmissionMarker(TuiRenderFrame frame, int height) { - assertTrue(frame.lines().size() <= height); - assertTrue(frame.transcriptLineCount() <= height - frame.chromeLineCount()); + assertEquals(height, frame.lines().size()); assertTrue(frame.lines().stream() .anyMatch(line -> line.contains("more lines") || line.contains("earlier lines"))); } From 0927a99bab06b5324c37ea549d33d6280bc4ba52 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 21:16:52 +0800 Subject: [PATCH 37/58] fix(tui): preserve bounded history across live redraws --- .../lypi/transport/tui/JLineTuiTransport.java | 12 +- .../JLineTuiTransportRenderPipelineTest.java | 129 ++++++++++++++++++ .../transport/tui/JLineTuiTransportTest.java | 21 +-- .../lypi/transport/tui/TuiInputLoopTest.java | 12 +- 4 files changed, 158 insertions(+), 16 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java index b3f05e44..b744eae1 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java @@ -20,6 +20,7 @@ import java.io.UncheckedIOException; import java.time.Clock; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.function.LongSupplier; import java.util.function.Supplier; @@ -719,10 +720,12 @@ public void render(TuiRenderFrame frame) { } } }; + int initialWidth = safeWidth(width > 0 ? width : io.width()); + int initialHeight = safeHeight(height > 1 ? height : io.height()); JLineTuiTransport transport = new JLineTuiTransport( frameSink, - width, - height, + initialWidth, + initialHeight, state, inputSource, submitHandler, @@ -795,6 +798,11 @@ public void attach(EventBus events, SessionRuntimeState state) { synchronized (uiMonitor) { closeSubscription(); attachedEvents = events; + String previousSessionId = runtimeState == null ? null : runtimeState.sessionId(); + String nextSessionId = state == null ? null : state.sessionId(); + if (screen != null && !Objects.equals(previousSessionId, nextSessionId)) { + screen.reset(); + } runtimeState = state; if (reducer != null) { reducer.configureRuntimeState(state); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java index 91090300..0bba2476 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java @@ -148,6 +148,108 @@ void reattachToResumedStateClearsOldProgressAndScrollsOnlyNewTranscript() throws assertFalse(pageDownFrame.contains("old-progress"), pageDownFrame); } + @Test + void changingSessionClearsHistoryScrollOffsetBeforeFirstFrame() throws Exception { + RecordingEventBus events = new RecordingEventBus(); + List> frames = new ArrayList<>(); + QueueInputSource input = new QueueInputSource(); + JLineTuiTransport transport = JLineTuiTransport.withInput( + frames::add, + 80, + 8, + input, + new RecordingSubmitHandler() + ); + transport.attach(events, completedRuntimeState("ses_old", 40)); + transport.renderCurrentFrameUnderUiLock(); + for (int index = 0; index < 20; index++) { + input.add("\033[5~"); + } + transport.drainInputForTest(); + assertTrue(frames.getLast().getLast().contains("↑")); + + transport.attach(events, completedRuntimeState("ses_new", 5)); + transport.renderCurrentFrameUnderUiLock(); + + String firstNewFrame = String.join("\n", frames.getLast()); + assertTrue(firstNewFrame.contains("resume complete"), firstNewFrame); + assertFalse(firstNewFrame.contains("new history"), firstNewFrame); + assertFalse(frames.getLast().getLast().contains("↑")); + } + + @Test + void pageUpChangesRestoredHistoryWithoutMovingLiveToolOrBottomChrome() throws Exception { + RecordingEventBus events = new RecordingEventBus(); + List> frames = new ArrayList<>(); + QueueInputSource input = new QueueInputSource(); + JLineTuiTransport transport = JLineTuiTransport.withInput( + frames::add, + 80, + 10, + input, + new RecordingSubmitHandler() + ); + transport.attach(events, historyRuntimeState("ses_1", 30)); + events.emit(new ToolStartEvent( + "ses_1", + "live-tool", + "bash", + Instant.parse("2026-06-09T00:00:00Z") + )); + transport.flushPendingFrameForTest(); + List tailFrame = frames.getLast(); + + input.add("\033[5~"); + transport.drainInputForTest(); + List pageUpFrame = frames.getLast(); + + assertTrue(tailFrame.contains("history-line-30")); + assertFalse(pageUpFrame.contains("history-line-30")); + assertTrue(pageUpFrame.stream().anyMatch(line -> line.startsWith("running $"))); + assertEquals( + tailFrame.stream().filter(line -> line.startsWith("running $")).findFirst(), + pageUpFrame.stream().filter(line -> line.startsWith("running $")).findFirst() + ); + assertEquals( + tailFrame.stream().filter(line -> line.contains(INPUT_BACKGROUND)).findFirst(), + pageUpFrame.stream().filter(line -> line.contains(INPUT_BACKGROUND)).findFirst() + ); + assertEquals(10, tailFrame.size()); + assertEquals(10, pageUpFrame.size()); + assertTrue(tailFrame.stream().anyMatch(line -> line.contains("┄"))); + assertTrue(pageUpFrame.stream().anyMatch(line -> line.contains("┄"))); + assertTrue(tailFrame.getLast().contains("ses_1")); + assertTrue(pageUpFrame.getLast().contains("ses_1")); + } + + @Test + void restoredHistoryCanScrollBackOnlyThroughLatestFiveHundredPhysicalLines() throws Exception { + RecordingEventBus events = new RecordingEventBus(); + List> frames = new ArrayList<>(); + QueueInputSource input = new QueueInputSource(); + JLineTuiTransport transport = JLineTuiTransport.withInput( + frames::add, + 80, + 8, + input, + new RecordingSubmitHandler() + ); + transport.attach(events, historyRuntimeState("ses_1", 510)); + transport.renderCurrentFrameUnderUiLock(); + for (int index = 0; index < 200; index++) { + input.add("\033[5~"); + } + for (int batch = 0; batch < 7; batch++) { + transport.drainInputForTest(); + } + + List topFrame = frames.getLast(); + assertTrue(topFrame.contains("history-line-11"), String.join("\n", topFrame)); + for (int index = 1; index <= 10; index++) { + assertFalse(topFrame.contains("history-line-" + index)); + } + } + @Test void toolProgressBurstReducesImmediatelyAndCoalescesTerminalFrames() { RecordingEventBus events = new RecordingEventBus(); @@ -985,6 +1087,33 @@ private static SessionRuntimeState completedRuntimeState(String sessionId, int h ); } + private static SessionRuntimeState historyRuntimeState(String sessionId, int historyMessages) { + SessionRuntimeState base = TestRuntimeStates.basic(sessionId); + List transcript = java.util.stream.IntStream.rangeClosed(1, historyMessages) + .mapToObj(index -> message( + "history-line-" + index, + MessageRole.ASSISTANT, + MessageKind.TEXT, + new TextContentBlock("history-line-" + index) + )) + .toList(); + return new SessionRuntimeState( + base.sessionId(), + base.cwd(), + base.currentBranchLeafId(), + base.model(), + base.thinkingLevel(), + base.agentMode(), + base.permissionRuntimeState(), + base.budget(), + transcript, + false, + false, + false, + false + ); + } + private static AgentMessage message( String id, MessageRole role, diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java index 01bafa9f..952ca314 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java @@ -109,7 +109,7 @@ void openAssemblesTerminalSessionRendererInputAndEventSubscription() throws Exce transport.flushPendingFrameForTest(); assertTrue(io.rawModeEntered); - assertFalse(io.output.toString().contains("\033[?1049h")); + assertTrue(io.output.toString().contains("\033[?1049h")); assertTrue(io.output.toString().contains("\033[?2026h\033[2J\033[H")); assertTrue(io.output.toString().contains("error: boom")); assertTrue(io.output.toString().contains("> ")); @@ -118,7 +118,7 @@ void openAssemblesTerminalSessionRendererInputAndEventSubscription() throws Exce transport.close(); assertTrue(io.rawModeRestored); - assertFalse(io.output.toString().contains("\033[?1049l")); + assertTrue(io.output.toString().contains("\033[?1049l")); } @Test @@ -145,7 +145,7 @@ void openRendersInitialFrameFromRuntimeState() throws Exception { } @Test - void openPipelineKeepsLongTranscriptInOutputStreamWithoutAlternateScreenOrRepeatedHomeClear() throws Exception { + void openPipelineKeepsLongTranscriptInFixedAlternateScreenFrames() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); io.height = 5; RecordingEventBus events = new RecordingEventBus(); @@ -179,11 +179,12 @@ void openPipelineKeepsLongTranscriptInOutputStreamWithoutAlternateScreenOrRepeat String output = io.output.toString(); assertTrue(output.contains("line 0")); assertTrue(output.contains("line 7")); - assertFalse(output.contains("\033[?1049h")); + assertTrue(output.contains("\033[?1049h")); assertFalse(output.contains("\033[?1049l")); assertFalse(output.contains("\033[H\033[J")); transport.close(); + assertTrue(io.output.toString().contains("\033[?1049l")); } @Test @@ -228,7 +229,7 @@ void openPipelineKeepsBottomChromeInViewportWithoutScrollbackOverflow() throws E } @Test - void closeAfterOverflowMovesPromptBelowPhysicalViewport() throws Exception { + void closeAfterOverflowRestoresOriginalScreenWithoutWritingPromptNewline() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); io.height = 3; RecordingEventBus events = new RecordingEventBus(); @@ -261,7 +262,8 @@ void closeAfterOverflowMovesPromptBelowPhysicalViewport() throws Exception { transport.close(); - assertTrue(io.output.toString().endsWith("\033[3;1H\n")); + assertTrue(io.output.toString().endsWith("\033[>4m\033[?2004l\033[?1049l\033[?25h")); + assertFalse(io.output.toString().endsWith("\n")); } @Test @@ -322,7 +324,7 @@ void openClosesTerminalSessionWhenInitialFrameRenderFails() { )); assertTrue(io.rawModeRestored); - assertFalse(io.output.toString().contains("\033[?1049l")); + assertTrue(io.output.toString().contains("\033[?1049l")); } @Test @@ -348,7 +350,10 @@ void openResizeCallbackReadsCurrentTerminalDimensions() throws Exception { String frame = io.output.toString(); String fullClear = "\033[2J\033[H"; String rendered = frame.substring(frame.indexOf(fullClear) + fullClear.length(), frame.indexOf("\033[?2026l")); - assertEquals(4, rendered.split("\n", -1).length); + for (int row = 1; row <= 6; row++) { + assertTrue(rendered.contains("\033[" + row + ";1H")); + } + assertFalse(rendered.contains("\n")); assertTrue(rendered.contains("> ")); transport.close(); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java index 2ef052b0..1aa68fb4 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java @@ -302,7 +302,7 @@ void ctrlCRequestsExitWhenInputIsEmptyAndNoToolIsRunning() { } @Test - void ctrlOTogglesToolOutputExpandedWithoutChangingDraft() { + void ctrlODoesNotExpandHistoricalToolOrChangeDraft() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); TuiViewModel view = new TuiViewModel( @@ -338,13 +338,13 @@ void ctrlOTogglesToolOutputExpandedWithoutChangingDraft() { assertEquals("draft", loop.draft()); assertTrue(frames.get(0).contains("tools: read x1 (Ctrl+O details)")); - assertTrue(frames.get(1).contains("done read src/Large.java:1-20")); + assertTrue(frames.get(1).contains("tools: read x1 (Ctrl+O details)")); assertTrue(!frames.get(1).contains("11 | line 11")); assertTrue(frames.get(2).contains("tools: read x1 (Ctrl+O details)")); } @Test - void toolOutputToggleAppliesToHistoricalAndRunningToolBlocksTogether() { + void toolOutputToggleAppliesOnlyToRunningToolBlock() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); TuiViewModel view = new TuiViewModel( @@ -391,7 +391,7 @@ void toolOutputToggleAppliesToHistoricalAndRunningToolBlocksTogether() { loop.acceptKey(TerminalKey.CTRL_O); loop.acceptKey(TerminalKey.CTRL_O); - assertTrue(frames.get(0).contains("done read src/Large.java:1-20")); + assertTrue(frames.get(0).contains("tools: read x1 (Ctrl+O details)")); assertTrue(!frames.get(0).contains("11 | line 11")); assertTrue(frames.get(0).contains("stdout: line 1")); assertTrue(frames.get(1).contains("tools: read x1 (Ctrl+O details)")); @@ -401,7 +401,7 @@ void toolOutputToggleAppliesToHistoricalAndRunningToolBlocksTogether() { } @Test - void expandToolsActionTogglesToolOutputExpanded() { + void expandToolsActionLeavesHistoricalToolCollapsed() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); TuiViewModel view = new TuiViewModel( @@ -433,7 +433,7 @@ void expandToolsActionTogglesToolOutputExpanded() { loop.acceptKey(TerminalKey.EXPAND_TOOLS); - assertTrue(frames.getFirst().contains("done read src/Large.java:1-20")); + assertTrue(frames.getFirst().contains("tools: read x1 (Ctrl+O details)")); assertTrue(!frames.getFirst().contains("11 | line 11")); } From 7feb32c0f69952e5bfad84777e3760cb7ce6336f Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 21:26:43 +0800 Subject: [PATCH 38/58] test(tui): verify alternate-screen history split --- .../lypi/transport/tui/TuiFramePtyProbe.java | 40 +++++-- .../src/test/resources/run-tui-frame-pty.sh | 101 ++++++++++++++---- .../src/test/resources/run-tui-smoke.sh | 27 +++-- 3 files changed, 131 insertions(+), 37 deletions(-) diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java index 70592817..3340715e 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java @@ -1,9 +1,12 @@ package cn.lypi.transport.tui; import cn.lypi.contracts.tui.StatusBarState; +import cn.lypi.contracts.tui.TuiMessageBlock; import cn.lypi.contracts.tui.TuiToolBlock; import cn.lypi.contracts.tui.TuiToolState; import cn.lypi.contracts.tui.TuiViewModel; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Optional; import org.jline.terminal.Terminal; @@ -14,6 +17,11 @@ private TuiFramePtyProbe() { } public static void main(String[] args) throws Exception { + if (args.length != 2) { + throw new IllegalArgumentException("expected ready and exit file paths"); + } + Path readyFile = Path.of(args[0]); + Path exitFile = Path.of(args[1]); Terminal terminal = TerminalBuilder.builder().system(true).build(); TerminalIo io = new JLineTerminalIo(terminal); try (TerminalSession session = TerminalSession.open(io)) { @@ -24,22 +32,32 @@ public static void main(String[] args) throws Exception { frameRenderer.render(renderer.renderFrame(view("status-old"), screen, layout, "", 0)); frameRenderer.render(renderer.renderFrame(view("status-updated"), screen, layout, "input", 5)); - Thread.sleep(100); + Files.writeString(readyFile, "ready"); + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(20); + while (!Files.exists(exitFile)) { + if (System.nanoTime() >= deadline) { + throw new IllegalStateException("timed out waiting for PTY exit signal"); + } + Thread.sleep(25); + } } } private static TuiViewModel view(String runtimeLine) { return new TuiViewModel( - List.of(new TuiToolBlock( - "tool:pty", - "message:pty", - "tool-use:pty", - "bash", - TuiToolState.RUNNING, - "tool one\ntool two", - "", - true - )), + List.of( + new TuiMessageBlock("history:pty", "message:history", "assistant", "history stable", false), + new TuiToolBlock( + "tool:pty", + "message:pty", + "tool-use:pty", + "bash", + TuiToolState.RUNNING, + "tool one\ntool two", + "", + true + ) + ), new StatusBarState("status", "", "", ""), runtimeLine, List.of(), diff --git a/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh b/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh index 2f55c215..788787ca 100755 --- a/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh +++ b/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh @@ -23,40 +23,48 @@ mvn -q -pl lypi-transport-tui dependency:build-classpath \ -Dmdep.outputFile="$TMP_DIR/dependency-classpath.txt" DEPENDENCY_CLASSPATH="$(<"$TMP_DIR/dependency-classpath.txt")" PROBE_CLASSPATH="$ROOT/lypi-transport-tui/target/test-classes:$ROOT/lypi-transport-tui/target/classes:$ROOT/lypi-contracts/target/classes:$DEPENDENCY_CLASSPATH" +READY_FILE="$TMP_DIR/ready" +EXIT_FILE="$TMP_DIR/exit" printf -v PTY_COMMAND \ - 'TERM=xterm-256color java -cp %q cn.lypi.transport.tui.TuiFramePtyProbe; status=$?; printf "\nLYPI_TUI_FRAME_EXIT=%%s\n" "$status"; sleep 30' \ - "$PROBE_CLASSPATH" + 'printf "SHELL_SENTINEL\n"; TERM=xterm-256color java -cp %q cn.lypi.transport.tui.TuiFramePtyProbe %q %q; status=$?; printf "\nLYPI_TUI_FRAME_EXIT=%%s\n" "$status"; sleep 30' \ + "$PROBE_CLASSPATH" "$READY_FILE" "$EXIT_FILE" tmux -L "$TMUX_SOCKET" new-session -d -x 60 -y 12 "$PTY_COMMAND" -CAPTURE="$TMP_DIR/pane.txt" -probe_finished=false +ALTERNATE_CAPTURE="$TMP_DIR/alternate-pane.txt" +probe_ready=false for _ in $(seq 1 100); do - tmux -L "$TMUX_SOCKET" capture-pane -p -S - >"$CAPTURE" - if grep -Fq "LYPI_TUI_FRAME_EXIT=" "$CAPTURE"; then - probe_finished=true + if [[ -f "$READY_FILE" ]]; then + probe_ready=true break fi sleep 0.05 done -if [[ "$probe_finished" != true ]]; then - echo "tui frame PTY probe did not finish" >&2 - cat "$CAPTURE" >&2 +tmux -L "$TMUX_SOCKET" capture-pane -p >"$ALTERNATE_CAPTURE" +if [[ "$probe_ready" != true ]]; then + echo "tui frame PTY probe did not become ready" >&2 + cat "$ALTERNATE_CAPTURE" >&2 exit 1 fi -if ! grep -Fxq "LYPI_TUI_FRAME_EXIT=0" "$CAPTURE"; then - echo "tui frame PTY probe failed" >&2 - cat "$CAPTURE" >&2 +if grep -Fq "SHELL_SENTINEL" "$ALTERNATE_CAPTURE"; then + echo "primary screen leaked into alternate-screen capture" >&2 + cat "$ALTERNATE_CAPTURE" >&2 exit 1 fi -if grep -Fq "status-old" "$CAPTURE"; then +if grep -Fq "status-old" "$ALTERNATE_CAPTURE"; then echo "stale status-old remained in final pane" >&2 - cat "$CAPTURE" >&2 + cat "$ALTERNATE_CAPTURE" >&2 + exit 1 +fi +if [[ "$(wc -l <"$ALTERNATE_CAPTURE")" -ne 12 ]]; then + echo "alternate-screen frame height does not match tmux pane" >&2 + cat "$ALTERNATE_CAPTURE" >&2 exit 1 fi expected_lines=( + 'history stable' 'running $ tool one' 'tool two' '· status-updated' @@ -65,19 +73,76 @@ expected_lines=( ) previous_line=0 for expected in "${expected_lines[@]}"; do - mapfile -t matches < <(grep -nxF "$expected" "$CAPTURE" || true) + mapfile -t matches < <(grep -nxF "$expected" "$ALTERNATE_CAPTURE" || true) if [[ "${#matches[@]}" -ne 1 ]]; then echo "expected exactly one final pane line: $expected" >&2 - cat "$CAPTURE" >&2 + cat "$ALTERNATE_CAPTURE" >&2 exit 1 fi current_line="${matches[0]%%:*}" if (( current_line <= previous_line )); then echo "final pane line order is incorrect at: $expected" >&2 - cat "$CAPTURE" >&2 + cat "$ALTERNATE_CAPTURE" >&2 exit 1 fi previous_line="$current_line" done +mapfile -t separators < <(grep -nE '^┄+$' "$ALTERNATE_CAPTURE" || true) +if [[ "${#separators[@]}" -ne 1 ]]; then + echo "expected exactly one history/live separator" >&2 + cat "$ALTERNATE_CAPTURE" >&2 + exit 1 +fi +separator_line="${separators[0]%%:*}" +history_line="$(grep -nxF 'history stable' "$ALTERNATE_CAPTURE")" +history_line="${history_line%%:*}" +running_line="$(grep -nxF 'running $ tool one' "$ALTERNATE_CAPTURE")" +running_line="${running_line%%:*}" +if (( history_line >= separator_line || running_line <= separator_line )); then + echo "history/live regions are ordered incorrectly" >&2 + cat "$ALTERNATE_CAPTURE" >&2 + exit 1 +fi +if [[ "$(grep -nxF '> input|' "$ALTERNATE_CAPTURE")" != 10:* ]]; then + echo "input is not on the fixed bottom content row" >&2 + cat "$ALTERNATE_CAPTURE" >&2 + exit 1 +fi +if [[ "$(grep -nxF 'status' "$ALTERNATE_CAPTURE")" != 12:* ]]; then + echo "status is not on the bottom pane row" >&2 + cat "$ALTERNATE_CAPTURE" >&2 + exit 1 +fi + +: >"$EXIT_FILE" +PRIMARY_CAPTURE="$TMP_DIR/primary-pane.txt" +probe_finished=false +for _ in $(seq 1 100); do + tmux -L "$TMUX_SOCKET" capture-pane -p >"$PRIMARY_CAPTURE" + if grep -Fq "LYPI_TUI_FRAME_EXIT=" "$PRIMARY_CAPTURE"; then + probe_finished=true + break + fi + sleep 0.05 +done + +if [[ "$probe_finished" != true ]] || ! grep -Fxq "LYPI_TUI_FRAME_EXIT=0" "$PRIMARY_CAPTURE"; then + echo "tui frame PTY probe did not close cleanly" >&2 + cat "$PRIMARY_CAPTURE" >&2 + exit 1 +fi +if ! grep -Fxq "SHELL_SENTINEL" "$PRIMARY_CAPTURE"; then + echo "primary screen was not restored" >&2 + cat "$PRIMARY_CAPTURE" >&2 + exit 1 +fi +for tui_line in 'history stable' 'running $ tool one' '· status-updated' '> input|'; do + if grep -Fq "$tui_line" "$PRIMARY_CAPTURE"; then + echo "TUI content remained after leaving alternate screen: $tui_line" >&2 + cat "$PRIMARY_CAPTURE" >&2 + exit 1 + fi +done + echo "tui frame PTY passed" diff --git a/lypi-transport-tui/src/test/resources/run-tui-smoke.sh b/lypi-transport-tui/src/test/resources/run-tui-smoke.sh index bfad451e..108c4682 100644 --- a/lypi-transport-tui/src/test/resources/run-tui-smoke.sh +++ b/lypi-transport-tui/src/test/resources/run-tui-smoke.sh @@ -42,18 +42,29 @@ PTY_OUTPUT="$TMP_DIR/pty-output.log" printf -v PTY_COMMAND 'TERM=xterm-256color java -cp %q TuiPtyProbe' "$TMP_DIR:$PROBE_CLASSPATH" timeout 15 script -q -e -c "$PTY_COMMAND" "$PTY_OUTPUT" >/dev/null -for expected in \ - $'\033[?2004h' \ - $'\033[?25l' \ - "LYPI_TUI_PTY_OPEN" \ - $'\033[?25h' \ - $'\033[?2004l' \ +expected_sequences=( + $'\033[?1049h' + $'\033[?2004h' + $'\033[?25l' + "LYPI_TUI_PTY_OPEN" + $'\033[?2004l' + $'\033[?1049l' + $'\033[?25h' "LYPI_TUI_PTY_CLOSED" -do - if ! grep -Fq "$expected" "$PTY_OUTPUT"; then +) +previous_offset=-1 +for expected in "${expected_sequences[@]}"; do + match="$(LC_ALL=C grep -aFbo -m1 "$expected" "$PTY_OUTPUT" || true)" + if [[ -z "$match" ]]; then echo "missing expected PTY smoke output: $(printf '%q' "$expected")" >&2 exit 1 fi + offset="${match%%:*}" + if (( offset <= previous_offset )); then + echo "PTY mode sequence is out of order at: $(printf '%q' "$expected")" >&2 + exit 1 + fi + previous_offset="$offset" done bash "$ROOT/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh" From aa3466aa04ec4a828e9505bb65f173d967e301ae Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 21:30:56 +0800 Subject: [PATCH 39/58] docs(tui): record alternate-screen rendering invariants --- .codex/skills/lypi-transport-tui/SKILL.md | 96 +++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .codex/skills/lypi-transport-tui/SKILL.md diff --git a/.codex/skills/lypi-transport-tui/SKILL.md b/.codex/skills/lypi-transport-tui/SKILL.md new file mode 100644 index 00000000..62b319cc --- /dev/null +++ b/.codex/skills/lypi-transport-tui/SKILL.md @@ -0,0 +1,96 @@ +--- +name: lypi-transport-tui +description: Use when changing ly-pi terminal UI, JLine input, TUI event reduction, rendering, slash commands, overlays, permission prompts, diff display, file mentions, or Skill mentions. +--- + +# ly-pi Transport TUI + +## Core Rule + +The TUI adapts input and renders state. Durable behavior belongs in session, agent core, tool runtime, resource runtime or contracts. + +## Main Responsibilities + +- Open a JLine terminal transport and attach to `AgentEvent`. +- Convert semantic events into `TuiViewModel`. +- Route user input, slash commands and compact commands. +- Parse file and Skill mentions for user input. +- Render message, thinking, tool, error, permission, diff and status views. +- Forward submissions to `AgentCorePort` through `TurnRequest`. + +## Key Code + +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/RuntimeTuiSubmitHandler.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptPartitioner.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiContentLayout.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/SlashCommandRouter.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/PermissionOverlay.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/DiffOverlay.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/SkillMentionParser.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/FileMentionPicker.java` + +## Event Flow + +1. `JLineTuiTransport.open()` enters the alternate screen and wires terminal IO, input, reducer and rendering. +2. `RuntimeTuiSubmitHandler.submitUserInput()` routes slash commands and resolves Skill mentions. +3. Normal input becomes `TurnRequest` and executes asynchronously. +4. `TuiEventReducer.reduce()` projects message, tool, permission, retry, compact, interrupt and session events. +5. `TuiTranscriptPartitioner` splits the stable prefix from the live tail without storing durable state. +6. `TuiRenderer` renders bounded history, live content and bottom chrome into one terminal-height frame. +7. `TerminalFrameRenderer` diffs physical rows by absolute screen position; it never sends transcript content to terminal scrollback. + +## Invariants + +- Reducers should not invent durable transcript content. They project events into display state. +- The stable transcript prefix becomes history. The first streaming, active, pending or running block starts the live tail. +- `TuiScreen` retains at most the latest 500 rendered history lines; session transcript and JSONL remain untouched. +- PageUp and PageDown scroll only history. Live content and bottom chrome stay on fixed rows. +- Historical tools always use their completed collapsed rendering. Ctrl+O affects tools in the live region only. +- Frames produced by `TuiRenderer` match terminal height. `TerminalFrameRenderer` rejects positive-height overflow instead of cropping it. +- First render and resize clear the alternate screen; normal redraws patch changed physical rows without `CRLF` scrolling. +- `TerminalSession` must pair DECSET/DECRST 1049 and restore terminal modes on close or partial open failure. +- Session id changes reset the history projection; resize reprojects the same session at the new width. +- Slash commands that change durable state should route through session/runtime contracts, not UI-only variables. +- Permission prompts display decision context and should clear on decision or interrupt. +- Skill mentions are resolved from the current `SkillIndex`, then passed into `TurnRequest`. +- Long-running work should not hold the UI lock while executing core logic. + +## Tests To Check + +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalFrameRendererTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContentLayoutTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportConcurrencyTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/RuntimeTuiSubmitHandlerTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/PermissionOverlayTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/DiffOverlayTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/FileMentionPickerTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/SlashCommandRouterTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/SkillMentionParserTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java` +- `lypi-transport-tui/src/test/resources/run-tui-smoke.sh` + +## Before Editing + +- Decide whether the change is view projection, rendering, input handling or runtime behavior. +- If it changes data shape, update `lypi-contracts` view model records first. +- Prefer event-driven state updates over querying internals from the UI layer. +- For rendering changes, test narrow width, multiline input, streaming updates, history/live allocation and terminal-height bounds. +- For terminal lifecycle changes, test partial open failure and a real PTY before relying on control-sequence unit tests alone. +- For slash commands, test consumed, prompt-rewrite and state-change paths. + +After using this Skill, reverse-check that the change did not move business state into TUI classes. From b5a0d193abac03e3b50fbf6734f6055f4bb23159 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 21:54:15 +0800 Subject: [PATCH 40/58] fix(tui): reconcile terminal size before redraw --- .../lypi/transport/tui/JLineTuiTransport.java | 42 +++++++++++++++---- .../transport/tui/JLineTuiTransportTest.java | 27 ++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java index b744eae1..27e5b142 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java @@ -44,6 +44,7 @@ public final class JLineTuiTransport implements TuiTransport, AutoCloseable { private final TerminalInputPump inputPump; private final TuiInputLoop inputLoop; private final TerminalSession terminalSession; + private final TerminalIo terminalIo; private final DiffViewProvider diffViewProvider; private final Clock clock; private final TuiRuntimeTicker runtimeTicker; @@ -65,6 +66,7 @@ public JLineTuiTransport(Runnable renderer) { this.inputPump = null; this.inputLoop = null; this.terminalSession = null; + this.terminalIo = null; this.diffViewProvider = NOOP_DIFF_VIEW_PROVIDER; this.clock = Clock.systemUTC(); this.runtimeTicker = new TuiRuntimeTicker(RUNTIME_TICK_INTERVAL_MILLIS, MAX_DIFF_PATCH_BYTES); @@ -98,6 +100,7 @@ private JLineTuiTransport( this.inputPump = null; this.inputLoop = null; this.terminalSession = terminalSession; + this.terminalIo = null; this.diffViewProvider = NOOP_DIFF_VIEW_PROVIDER; this.clock = Clock.systemUTC(); this.runtimeTicker = new TuiRuntimeTicker(RUNTIME_TICK_INTERVAL_MILLIS, MAX_DIFF_PATCH_BYTES); @@ -113,6 +116,7 @@ private JLineTuiTransport( TerminalInputSource inputSource, TuiSubmitHandler submitHandler, TerminalSession terminalSession, + TerminalIo terminalIo, Supplier slashPickerSupplier, DiffViewProvider diffViewProvider, ResumeSessionController resumeController, @@ -126,6 +130,7 @@ private JLineTuiTransport( inputSource, submitHandler, terminalSession, + terminalIo, slashPickerSupplier, diffViewProvider, resumeController, @@ -142,6 +147,7 @@ private JLineTuiTransport( TerminalInputSource inputSource, TuiSubmitHandler submitHandler, TerminalSession terminalSession, + TerminalIo terminalIo, Supplier slashPickerSupplier, DiffViewProvider diffViewProvider, ResumeSessionController resumeController, @@ -172,6 +178,7 @@ private JLineTuiTransport( ); this.inputPump = new TerminalInputPump(inputSource, new KeyMapper(), inputLoop); this.terminalSession = terminalSession; + this.terminalIo = terminalIo; this.diffViewProvider = diffViewProvider == null ? NOOP_DIFF_VIEW_PROVIDER : diffViewProvider; this.clock = clock == null ? Clock.systemUTC() : clock; this.runtimeTicker = new TuiRuntimeTicker(RUNTIME_TICK_INTERVAL_MILLIS, MAX_DIFF_PATCH_BYTES); @@ -599,6 +606,7 @@ static JLineTuiTransport withInput( submitHandler, null, null, + null, NOOP_DIFF_VIEW_PROVIDER, null, null @@ -622,6 +630,7 @@ static JLineTuiTransport withInput( submitHandler, null, null, + null, NOOP_DIFF_VIEW_PROVIDER, null, null, @@ -730,6 +739,7 @@ public void render(TuiRenderFrame frame) { inputSource, submitHandler, session, + io, slashPickerSupplier, diffViewProvider, resumeController, @@ -1001,17 +1011,34 @@ private void resize(int width, int height) { if (reducer == null) { return; } - int safeWidth = safeWidth(width); - int safeHeight = safeHeight(height); - layout = new TuiLayout(safeWidth, safeHeight); - screen.updateViewportHeight(Math.max(0, safeHeight - 2)); - if (inputLoop != null) { - inputLoop.updateViewport(screen, layout); - } + updateViewport(width, height); redrawScheduler.renderNow(this::renderCurrentFrame); } } + private void updateViewport(int width, int height) { + int safeWidth = safeWidth(width); + int safeHeight = safeHeight(height); + layout = new TuiLayout(safeWidth, safeHeight); + screen.updateViewportHeight(Math.max(0, safeHeight - 2)); + if (inputLoop != null) { + inputLoop.updateViewport(screen, layout); + } + } + + private void reconcileTerminalSize() { + if (terminalIo == null || layout == null) { + return; + } + int currentWidth = terminalIo.width(); + int currentHeight = terminalIo.height(); + int resolvedWidth = currentWidth > 0 ? currentWidth : layout.width(); + int resolvedHeight = currentHeight > 1 ? currentHeight : layout.height(); + if (layout.width() != resolvedWidth || layout.height() != resolvedHeight) { + updateViewport(resolvedWidth, resolvedHeight); + } + } + private void handleInterruptSignal() { synchronized (uiMonitor) { uiLockEntries++; @@ -1025,6 +1052,7 @@ private void handleInterruptSignal() { private void renderCurrentFrame() { lastRenderHeldUiLock = Thread.holdsLock(uiMonitor); try { + reconcileTerminalSize(); if (renderer != null) { renderer.run(); return; diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java index 952ca314..5d9613f3 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java @@ -359,6 +359,33 @@ void openResizeCallbackReadsCurrentTerminalDimensions() throws Exception { transport.close(); } + @Test + void renderReconcilesTerminalSizeWhenResizeSignalIsDelayed() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(); + io.height = 6; + RecordingEventBus events = new RecordingEventBus(); + + JLineTuiTransport transport = JLineTuiTransport.open( + runtimeState(), + events, + io, + () -> Optional.empty(), + new RecordingSubmitHandler(), + 40, + 6 + ); + io.output.setLength(0); + + io.height = 3; + events.emit(new ErrorEvent("ses_1", "err_1", "boom", Instant.parse("2026-06-09T00:00:00Z"))); + + assertDoesNotThrow(transport::flushPendingFrameForTest); + assertTrue(io.output.toString().contains("\033[3;1H")); + assertFalse(io.output.toString().contains("\033[4;1H")); + + transport.close(); + } + @Test void openWithSlashCommandsExecutesSlashInputWithoutStartingTurn() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); From 1e1e14c16b1b9399a9d56e33ec4f444321029a30 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 22:46:49 +0800 Subject: [PATCH 41/58] fix(tui): render leading streaming delta --- .../lypi/transport/tui/JLineTuiTransport.java | 16 ++ .../JLineTuiTransportRenderPipelineTest.java | 181 +++++++++++++++++- 2 files changed, 189 insertions(+), 8 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java index 27e5b142..70b1f4ef 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java @@ -1,9 +1,12 @@ package cn.lypi.transport.tui; +import cn.lypi.contracts.context.ContentBlockKind; +import cn.lypi.contracts.context.MessageRole; import cn.lypi.contracts.event.AgentEvent; import cn.lypi.contracts.event.EventBus; import cn.lypi.contracts.event.EventFilter; import cn.lypi.contracts.event.EventSubscription; +import cn.lypi.contracts.event.MessageDeltaEvent; import cn.lypi.contracts.runtime.AgentCorePort; import cn.lypi.contracts.runtime.CompactionRuntimePort; import cn.lypi.contracts.runtime.ResourceRuntimePort; @@ -844,7 +847,20 @@ void reduceAndRequestRenderUnderUiLock(AgentEvent event) { runtimeTicker.refreshDiffAfterToolEnd(event, runtimeState, reducer, diffViewProvider); syncInputLoopToolState(reducer.view()); redrawScheduler.request(); + if (visibleStreamingDelta(event)) { + redrawScheduler.renderIfDue(this::renderCurrentFrame); + } + } + } + + private static boolean visibleStreamingDelta(AgentEvent event) { + if (!(event instanceof MessageDeltaEvent delta) + || delta.role() != MessageRole.ASSISTANT + || delta.delta().isEmpty()) { + return false; } + return delta.blockKind() == ContentBlockKind.TEXT + || delta.blockKind() == ContentBlockKind.THINKING; } private void requestRenderUnderUiLock() { diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java index 0bba2476..813c3bff 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java @@ -39,8 +39,11 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; import org.junit.jupiter.api.Test; class JLineTuiTransportRenderPipelineTest { @@ -289,7 +292,33 @@ void toolProgressBurstReducesImmediatelyAndCoalescesTerminalFrames() { } @Test - void eventCallbackReducesBeforeUiLoopRendersUnderUiLock() { + void providerBurstRendersLeadingTextDeltaBeforeFinalFrame() { + RecordingEventBus events = new RecordingEventBus(); + AtomicLong now = new AtomicLong(); + List> frames = new ArrayList<>(); + JLineTuiTransport transport = JLineTuiTransport.withRenderer( + frames::add, + 80, + 8, + now::get, + TuiRedrawScheduler.DEFAULT_FRAME_INTERVAL_NANOS + ); + transport.attach(events, TestRuntimeStates.basic("ses_1")); + + events.emit(textDelta("first", false)); + assertTrue(String.join("\n", frames.getLast()).contains("first")); + assertFalse(String.join("\n", frames.getLast()).contains("first-final")); + + events.emit(textDelta("-final", true)); + assertEquals(1, frames.size()); + + now.addAndGet(TuiRedrawScheduler.DEFAULT_FRAME_INTERVAL_NANOS); + assertTrue(transport.renderPendingFrameIfDueForTest()); + assertTrue(String.join("\n", frames.getLast()).contains("first-final")); + } + + @Test + void visibleDeltaReducesAndRendersUnderOneUiLockEntry() { RecordingEventBus events = new RecordingEventBus(); List frames = new ArrayList<>(); JLineTuiTransport transport = JLineTuiTransport.withRenderer(lines -> frames.add(String.join("\n", lines)), 40, 5); @@ -307,12 +336,9 @@ void eventCallbackReducesBeforeUiLoopRendersUnderUiLock() { java.util.Map.of(), Instant.parse("2026-06-09T00:00:00Z") )); - assertTrue(frames.isEmpty()); - transport.flushPendingFrameForTest(); - assertEquals(1, frames.size()); assertEquals("Done", frames.getFirst().lines().findFirst().orElseThrow()); - assertEquals(2, transport.uiLockEntryCountForTest()); + assertEquals(1, transport.uiLockEntryCountForTest()); } @Test @@ -485,7 +511,7 @@ void resizeRerendersCurrentViewWithUpdatedDimensionsUnderUiLock() { assertEquals(2, frames.size()); frames.getLast().forEach(line -> assertEquals(line, AnsiWidth.truncate(line, 8))); - assertEquals(3, transport.uiLockEntryCountForTest()); + assertEquals(2, transport.uiLockEntryCountForTest()); } @Test @@ -742,6 +768,118 @@ void runUntilExitReturnsWhenCtrlCRequestsExit() throws Exception { assertEquals(true, transport.exitRequestedForTest()); } + @Test + void runLoopRendersIntermediateMessageDeltaBeforeFinalDelta() throws Exception { + RecordingEventBus events = new RecordingEventBus(); + List> frames = new CopyOnWriteArrayList<>(); + QueueInputSource input = new QueueInputSource(); + JLineTuiTransport transport = JLineTuiTransport.withInput( + frame -> frames.add(List.copyOf(frame)), + 80, + 8, + input, + new RecordingSubmitHandler() + ); + transport.attach(events, TestRuntimeStates.basic("ses_1")); + AtomicReference loopFailure = new AtomicReference<>(); + Thread runner = Thread.ofVirtual().start(() -> { + try { + transport.runUntilExit(); + } catch (Throwable failure) { + loopFailure.set(failure); + } + }); + + try { + events.emit(new MessageDeltaEvent( + "ses_1", + "msg_stream", + MessageRole.ASSISTANT, + MessageKind.TEXT, + "block_stream", + ContentBlockKind.TEXT, + "stream-first", + false, + Map.of(), + Instant.parse("2026-06-09T00:00:00Z") + )); + + assertTrue(awaitFrame(frames, frame -> String.join("\n", frame).contains("stream-first"))); + + events.emit(new MessageDeltaEvent( + "ses_1", + "msg_stream", + MessageRole.ASSISTANT, + MessageKind.TEXT, + "block_stream", + ContentBlockKind.TEXT, + "-final", + true, + Map.of(), + Instant.parse("2026-06-09T00:00:01Z") + )); + + assertTrue(awaitFrame(frames, frame -> String.join("\n", frame).contains("stream-first-final"))); + } finally { + input.add("\u0003"); + runner.join(1_000L); + } + + assertFalse(runner.isAlive()); + assertEquals(null, loopFailure.get()); + } + + @Test + void runLoopPageUpScrollsHistoryBuiltFromCurrentSessionEvents() throws Exception { + RecordingEventBus events = new RecordingEventBus(); + List> frames = new CopyOnWriteArrayList<>(); + QueueInputSource input = new QueueInputSource(); + JLineTuiTransport transport = JLineTuiTransport.withInput( + frame -> frames.add(List.copyOf(frame)), + 80, + 8, + input, + new RecordingSubmitHandler() + ); + transport.attach(events, TestRuntimeStates.basic("ses_1")); + for (int index = 1; index <= 30; index++) { + events.emit(new MessageDeltaEvent( + "ses_1", + "msg_" + index, + MessageRole.ASSISTANT, + MessageKind.TEXT, + "block_" + index, + ContentBlockKind.TEXT, + "event-history-" + index, + true, + Map.of(), + Instant.parse("2026-06-09T00:00:00Z") + )); + } + transport.renderCurrentFrameUnderUiLock(); + assertTrue(String.join("\n", frames.getLast()).contains("event-history-30")); + + AtomicReference loopFailure = new AtomicReference<>(); + Thread runner = Thread.ofVirtual().start(() -> { + try { + transport.runUntilExit(); + } catch (Throwable failure) { + loopFailure.set(failure); + } + }); + + try { + input.add("\033[5~"); + assertTrue(awaitFrame(frames, frame -> !String.join("\n", frame).contains("event-history-30"))); + } finally { + input.add("\u0003"); + runner.join(1_000L); + } + + assertFalse(runner.isAlive()); + assertEquals(null, loopFailure.get()); + } + @Test void runUntilExitReturnsAfterInterruptSignalRequestsExitWhileInputReadIsWaiting() throws Exception { RecordingEventBus events = new RecordingEventBus(); @@ -1131,6 +1269,21 @@ private static AgentMessage message( ); } + private static MessageDeltaEvent textDelta(String delta, boolean isFinal) { + return new MessageDeltaEvent( + "ses_1", + "msg_stream", + MessageRole.ASSISTANT, + MessageKind.TEXT, + "block_stream", + ContentBlockKind.TEXT, + delta, + isFinal, + Map.of(), + Instant.parse("2026-06-09T00:00:00Z") + ); + } + private static TuiToolBlock tool(JLineTuiTransport transport, String toolUseId) { return transport.viewForTest().blocks().stream() .filter(TuiToolBlock.class::isInstance) @@ -1147,12 +1300,12 @@ private QueueInputSource(String... chunks) { this.chunks = new ArrayDeque<>(List.of(chunks)); } - private void add(String chunk) { + private synchronized void add(String chunk) { chunks.addLast(chunk); } @Override - public Optional read() { + public synchronized Optional read() { return Optional.ofNullable(chunks.pollFirst()); } } @@ -1190,6 +1343,18 @@ private static String inputContent(String content) { return INPUT_BACKGROUND + content + ANSI_RESET; } + private static boolean awaitFrame(List> frames, Predicate> predicate) + throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + while (System.nanoTime() < deadline) { + if (frames.stream().anyMatch(predicate)) { + return true; + } + Thread.sleep(5L); + } + return frames.stream().anyMatch(predicate); + } + private static long transcriptRows(List frame) { return frame.stream().filter(line -> line.startsWith("line ")).count(); } From 0c9dadf2db8114ce00e4c9f64a4103c47c0768da Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 22:57:01 +0800 Subject: [PATCH 42/58] fix(tui): route mouse wheel to transcript history --- .../transport/tui/KeyBindingRegistry.java | 2 + .../java/cn/lypi/transport/tui/KeyMapper.java | 27 +++++++++- .../cn/lypi/transport/tui/TerminalKey.java | 2 + .../lypi/transport/tui/TerminalSession.java | 10 ++++ .../transport/tui/KeyBindingRegistryTest.java | 2 + .../cn/lypi/transport/tui/KeyMapperTest.java | 11 ++++ .../transport/tui/TerminalSessionTest.java | 11 ++-- .../lypi/transport/tui/TuiInputLoopTest.java | 50 +++++++++++++++++++ 8 files changed, 110 insertions(+), 5 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyBindingRegistry.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyBindingRegistry.java index 4a348e15..a10e594b 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyBindingRegistry.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyBindingRegistry.java @@ -26,6 +26,8 @@ static KeyBindingRegistry defaults() { bindings.put(TerminalKey.WORD_RIGHT, TerminalInputAction.MOVE_WORD_RIGHT); bindings.put(TerminalKey.UP, TerminalInputAction.PREVIOUS_HISTORY); bindings.put(TerminalKey.DOWN, TerminalInputAction.NEXT_HISTORY); + bindings.put(TerminalKey.MOUSE_WHEEL_UP, TerminalInputAction.SCROLL_TRANSCRIPT_UP); + bindings.put(TerminalKey.MOUSE_WHEEL_DOWN, TerminalInputAction.SCROLL_TRANSCRIPT_DOWN); bindings.put(TerminalKey.PAGE_UP, TerminalInputAction.SCROLL_TRANSCRIPT_UP); bindings.put(TerminalKey.PAGE_DOWN, TerminalInputAction.SCROLL_TRANSCRIPT_DOWN); bindings.put(TerminalKey.CTRL_O, TerminalInputAction.TOGGLE_TOOL_OUTPUT_EXPANDED); diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyMapper.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyMapper.java index d7792c5f..9270a60f 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyMapper.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyMapper.java @@ -1,8 +1,12 @@ package cn.lypi.transport.tui; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; final class KeyMapper { + private static final Pattern SGR_MOUSE = Pattern.compile("\\033\\[<(\\d+);\\d+;\\d+([Mm])"); + Optional map(String sequence) { return switch (sequence) { case "\u0003" -> Optional.of(TerminalKey.CTRL_C); @@ -27,10 +31,31 @@ Optional map(String sequence) { case "\033[5~" -> Optional.of(TerminalKey.PAGE_UP); case "\033[6~" -> Optional.of(TerminalKey.PAGE_DOWN); case "\033[?u", "\033[65;129u", "\033[27;7;65u" -> Optional.empty(); - default -> filterKittyReleaseOrRepeat(sequence); + default -> mapSgrMouse(sequence).or(() -> filterKittyReleaseOrRepeat(sequence)); }; } + private Optional mapSgrMouse(String sequence) { + Matcher matcher = SGR_MOUSE.matcher(sequence); + if (!matcher.matches()) { + return Optional.empty(); + } + if (!"M".equals(matcher.group(2))) { + return Optional.of(TerminalKey.OTHER); + } + try { + int button = Integer.parseInt(matcher.group(1)); + if ((button & 64) == 0) { + return Optional.of(TerminalKey.OTHER); + } + return Optional.of((button & 1) == 0 + ? TerminalKey.MOUSE_WHEEL_UP + : TerminalKey.MOUSE_WHEEL_DOWN); + } catch (NumberFormatException ignored) { + return Optional.of(TerminalKey.OTHER); + } + } + private Optional filterKittyReleaseOrRepeat(String sequence) { if (sequence != null && sequence.matches("\\033\\[[0-9]+;(129|130)u")) { return Optional.empty(); diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalKey.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalKey.java index 4eb04d07..9c7dff92 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalKey.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalKey.java @@ -21,6 +21,8 @@ public enum TerminalKey { WORD_RIGHT, UP, DOWN, + MOUSE_WHEEL_UP, + MOUSE_WHEEL_DOWN, PAGE_UP, PAGE_DOWN, EXPAND_TOOLS, diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java index 2c6a5151..706daf76 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java @@ -7,6 +7,10 @@ public final class TerminalSession implements AutoCloseable { static final String ENTER_ALTERNATE_SCREEN = "\033[?1049h"; static final String LEAVE_ALTERNATE_SCREEN = "\033[?1049l"; + static final String ENABLE_MOUSE_TRACKING = "\033[?1000h"; + static final String DISABLE_MOUSE_TRACKING = "\033[?1000l"; + static final String ENABLE_SGR_MOUSE = "\033[?1006h"; + static final String DISABLE_SGR_MOUSE = "\033[?1006l"; static final String ENABLE_BRACKETED_PASTE = "\033[?2004h"; static final String DISABLE_BRACKETED_PASTE = "\033[?2004l"; static final String HIDE_CURSOR = "\033[?25l"; @@ -66,6 +70,8 @@ static TerminalSession open(TerminalIo io, Runnable resizeCallback, Runnable int interruptHandler = io.onInterrupt(interruptCallback); io.write(ENTER_ALTERNATE_SCREEN); alternateScreenEntered = true; + io.write(ENABLE_MOUSE_TRACKING); + io.write(ENABLE_SGR_MOUSE); io.write(ENABLE_BRACKETED_PASTE); io.write(HIDE_CURSOR); io.write(ENABLE_MODIFY_OTHER_KEYS); @@ -86,6 +92,8 @@ public void close() throws Exception { try { io.write(DISABLE_MODIFY_OTHER_KEYS); io.write(DISABLE_BRACKETED_PASTE); + io.write(DISABLE_SGR_MOUSE); + io.write(DISABLE_MOUSE_TRACKING); io.write(LEAVE_ALTERNATE_SCREEN); io.write(SHOW_CURSOR); io.flush(); @@ -114,6 +122,8 @@ private static void restoreAfterOpenFailure( if (alternateScreenEntered) { writeStaticQuietly(io, DISABLE_MODIFY_OTHER_KEYS); writeStaticQuietly(io, DISABLE_BRACKETED_PASTE); + writeStaticQuietly(io, DISABLE_SGR_MOUSE); + writeStaticQuietly(io, DISABLE_MOUSE_TRACKING); writeStaticQuietly(io, LEAVE_ALTERNATE_SCREEN); writeStaticQuietly(io, SHOW_CURSOR); flushStaticQuietly(io); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java index 13de97e4..289d0f1e 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java @@ -14,6 +14,8 @@ void defaultBindingsCoverPiStyleEditingAndThinkingShortcuts() { assertEquals(TerminalInputAction.DELETE_NEXT_WORD, registry.actionFor(TerminalKey.ALT_DELETE)); assertEquals(TerminalInputAction.TOGGLE_TOOL_OUTPUT_EXPANDED, registry.actionFor(TerminalKey.CTRL_O)); assertEquals(TerminalInputAction.INSERT_NEWLINE, registry.actionFor(TerminalKey.MODIFIED_ENTER)); + assertEquals(TerminalInputAction.SCROLL_TRANSCRIPT_UP, registry.actionFor(TerminalKey.MOUSE_WHEEL_UP)); + assertEquals(TerminalInputAction.SCROLL_TRANSCRIPT_DOWN, registry.actionFor(TerminalKey.MOUSE_WHEEL_DOWN)); assertEquals(TerminalInputAction.SCROLL_TRANSCRIPT_UP, registry.actionFor(TerminalKey.PAGE_UP)); assertEquals(TerminalInputAction.SCROLL_TRANSCRIPT_DOWN, registry.actionFor(TerminalKey.PAGE_DOWN)); } diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyMapperTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyMapperTest.java index 0adb3620..091ae287 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyMapperTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyMapperTest.java @@ -43,6 +43,17 @@ void mapsLineEditingAndYankKeys() { assertEquals(TerminalKey.ALT_Y, mapper.map("\033y").orElseThrow()); } + @Test + void mapsSgrWheelPressesAndIgnoresOtherMouseEvents() { + KeyMapper mapper = new KeyMapper(); + + assertEquals(TerminalKey.MOUSE_WHEEL_UP, mapper.map("\033[<64;40;12M").orElseThrow()); + assertEquals(TerminalKey.MOUSE_WHEEL_DOWN, mapper.map("\033[<65;40;12M").orElseThrow()); + assertEquals(TerminalKey.OTHER, mapper.map("\033[<0;40;12M").orElseThrow()); + assertEquals(TerminalKey.OTHER, mapper.map("\033[<0;40;12m").orElseThrow()); + assertEquals(TerminalKey.OTHER, mapper.map("\033[<32;40;12M").orElseThrow()); + } + @Test void filtersTerminalProtocolResponsesAndReleaseRepeatEvents() { KeyMapper mapper = new KeyMapper(); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java index e0ff76bb..fa552c92 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java @@ -17,7 +17,7 @@ void openEntersInteractiveTerminalModesAndCloseRestoresThem() throws Exception { assertTrue(io.rawModeEntered); assertEquals( - "\033[?1049h\033[?2004h\033[?25l\033[>4;2m", + "\033[?1049h\033[?1000h\033[?1006h\033[?2004h\033[?25l\033[>4;2m", io.output.toString() ); @@ -25,8 +25,8 @@ void openEntersInteractiveTerminalModesAndCloseRestoresThem() throws Exception { assertTrue(io.rawModeRestored); assertEquals( - "\033[?1049h\033[?2004h\033[?25l\033[>4;2m" - + "\033[>4m\033[?2004l\033[?1049l\033[?25h", + "\033[?1049h\033[?1000h\033[?1006h\033[?2004h\033[?25l\033[>4;2m" + + "\033[>4m\033[?2004l\033[?1006l\033[?1000l\033[?1049l\033[?25h", io.output.toString() ); } @@ -74,7 +74,10 @@ void openFailureLeavesAlternateScreenAndRestoresTerminalResources() { assertThrows(IOException.class, () -> TerminalSession.open(io)); - assertEquals("\033[?1049h\033[>4m\033[?2004l\033[?1049l\033[?25h", io.output.toString()); + assertEquals( + "\033[?1049h\033[>4m\033[?2004l\033[?1006l\033[?1000l\033[?1049l\033[?25h", + io.output.toString() + ); assertTrue(io.rawModeRestored); assertTrue(io.resizeHandlerRestored); assertTrue(io.interruptHandlerRestored); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java index 1aa68fb4..f75bab43 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java @@ -180,6 +180,56 @@ void pageKeysScrollTranscriptWithoutChangingDraftOrCursor() { assertTrue(frames.getLast().contains("line10")); } + @Test + void mouseWheelScrollsTranscriptWhileArrowUpStillBrowsesInputHistory() { + RecordingSubmitHandler submit = new RecordingSubmitHandler(); + List> frames = new ArrayList<>(); + List blocks = java.util.stream.IntStream.rangeClosed(1, 30) + .mapToObj(index -> (TuiBlock) new TuiMessageBlock( + "b" + index, + "m" + index, + "assistant", + "history-" + index, + false + )) + .toList(); + TuiViewModel view = new TuiViewModel( + blocks, + new StatusBarState("ses_1", "gpt-5.4", "ready", "default"), + List.of(), + Optional.empty(), + Optional.empty() + ); + TuiInputLoop loop = new TuiInputLoop( + submit, + lines -> frames.add(List.copyOf(lines)), + new TuiRenderer(), + new TuiScreen(2), + new TuiLayout(40, 6), + () -> view + ); + loop.acceptText("previous prompt"); + loop.acceptKey(TerminalKey.ENTER); + loop.acceptText("draft"); + int cursor = loop.cursor(); + + loop.acceptKey(TerminalKey.MOUSE_WHEEL_UP); + + assertEquals("draft", loop.draft()); + assertEquals(cursor, loop.cursor()); + assertTrue(frames.getLast().contains("history-28")); + assertTrue(frames.getLast().contains("history-29")); + assertFalse(frames.getLast().contains("history-30")); + + loop.acceptKey(TerminalKey.CTRL_U); + loop.acceptKey(TerminalKey.UP); + + assertEquals("previous prompt", loop.draft()); + assertTrue(frames.getLast().contains("history-28")); + assertTrue(frames.getLast().contains("history-29")); + assertFalse(frames.getLast().contains("history-30")); + } + @Test void backspaceDeletesPreviousCharacterAndRerendersInput() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); From e094b5f64a8077dbce97bff5e69321cecd203d1c Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 15 Jul 2026 23:09:31 +0800 Subject: [PATCH 43/58] test(tui): cover streaming and wheel interaction in PTY --- .../transport/tui/JLineTuiTransportTest.java | 6 +- .../transport/tui/TuiInteractionPtyProbe.java | 121 +++++++++++++++++ .../test/resources/run-tui-interaction-pty.sh | 123 ++++++++++++++++++ .../src/test/resources/run-tui-smoke.sh | 5 + 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInteractionPtyProbe.java create mode 100644 lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java index 5d9613f3..7d16d37d 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java @@ -262,7 +262,9 @@ void closeAfterOverflowRestoresOriginalScreenWithoutWritingPromptNewline() throw transport.close(); - assertTrue(io.output.toString().endsWith("\033[>4m\033[?2004l\033[?1049l\033[?25h")); + assertTrue(io.output.toString().endsWith( + "\033[>4m\033[?2004l\033[?1006l\033[?1000l\033[?1049l\033[?25h" + )); assertFalse(io.output.toString().endsWith("\n")); } @@ -902,7 +904,7 @@ public int height() { private static final class FailingInitialFrameTerminalIo implements TerminalIo { private final StringBuilder output = new StringBuilder(); private boolean rawModeRestored; - private int writesUntilFailure = 4; + private int writesUntilFailure = 6; @Override public AutoCloseable enterRawMode() { diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInteractionPtyProbe.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInteractionPtyProbe.java new file mode 100644 index 00000000..2da9b32d --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInteractionPtyProbe.java @@ -0,0 +1,121 @@ +package cn.lypi.transport.tui; + +import cn.lypi.contracts.context.ContentBlockKind; +import cn.lypi.contracts.context.MessageKind; +import cn.lypi.contracts.context.MessageRole; +import cn.lypi.contracts.event.AgentEvent; +import cn.lypi.contracts.event.EventBus; +import cn.lypi.contracts.event.EventConsumer; +import cn.lypi.contracts.event.EventEnvelope; +import cn.lypi.contracts.event.EventFilter; +import cn.lypi.contracts.event.EventSubscription; +import cn.lypi.contracts.event.MessageDeltaEvent; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; + +public final class TuiInteractionPtyProbe { + private TuiInteractionPtyProbe() { + } + + public static void main(String[] args) throws Exception { + if (args.length != 1) { + throw new IllegalArgumentException("expected control directory path"); + } + Path controlDirectory = Path.of(args[0]); + ProbeEventBus events = new ProbeEventBus(); + Terminal terminal = TerminalBuilder.builder().system(true).build(); + TerminalIo io = new JLineTerminalIo(terminal); + try (JLineTuiTransport transport = JLineTuiTransport.open( + TestRuntimeStates.basic("ses_1"), + events, + io, + new JLineTerminalInputSource(terminal), + new NoopSubmitHandler(), + terminal.getWidth(), + terminal.getHeight() + )) { + for (int index = 1; index <= 30; index++) { + events.emit(message("msg_" + index, "block_" + index, "history-" + index, true)); + } + transport.flushPendingFrameForTest(); + Files.writeString(controlDirectory.resolve("ready"), "ready"); + + Thread emitter = Thread.ofVirtual().start(() -> { + try { + await(controlDirectory.resolve("emit-first")); + events.emit(message("msg_stream", "block_stream", "stream-first", false)); + await(controlDirectory.resolve("emit-final")); + events.emit(message("msg_stream", "block_stream", "-final", true)); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + }); + transport.runUntilExit(); + emitter.join(); + } + } + + private static MessageDeltaEvent message(String messageId, String blockId, String delta, boolean isFinal) { + return new MessageDeltaEvent( + "ses_1", + messageId, + MessageRole.ASSISTANT, + MessageKind.TEXT, + blockId, + ContentBlockKind.TEXT, + delta, + isFinal, + Map.of(), + Instant.now() + ); + } + + private static void await(Path signal) throws Exception { + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15); + while (!Files.exists(signal)) { + if (System.nanoTime() >= deadline) { + throw new IllegalStateException("timed out waiting for " + signal.getFileName()); + } + Thread.sleep(10L); + } + } + + private static final class ProbeEventBus implements EventBus { + private final AtomicLong sequence = new AtomicLong(); + private volatile EventConsumer consumer; + + @Override + public void publish(AgentEvent event) { + emit(event); + } + + @Override + public EventSubscription subscribe(EventFilter filter, EventConsumer consumer) { + this.consumer = consumer; + return () -> this.consumer = null; + } + + private void emit(AgentEvent event) { + EventConsumer current = consumer; + if (current != null) { + long next = sequence.incrementAndGet(); + current.accept(new EventEnvelope("evt_" + next, "ses_1", next, event)); + } + } + } + + private static final class NoopSubmitHandler implements TuiSubmitHandler { + @Override + public void submitUserInput(String input) { + } + + @Override + public void requestInterrupt(String reason) { + } + } +} diff --git a/lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh b/lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh new file mode 100644 index 00000000..c3975429 --- /dev/null +++ b/lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +cd "$ROOT" + +if ! command -v tmux >/dev/null 2>&1; then + echo "tmux is required for TUI interaction PTY" >&2 + exit 1 +fi + +mvn -q -pl lypi-transport-tui -am test-compile + +TMP_DIR="$(mktemp -d)" +TMUX_SOCKET="lypi-tui-interaction-$$" +cleanup() { + tmux -L "$TMUX_SOCKET" kill-server >/dev/null 2>&1 || true + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +mvn -q -pl lypi-transport-tui dependency:build-classpath \ + -Dmdep.outputFile="$TMP_DIR/dependency-classpath.txt" +DEPENDENCY_CLASSPATH="$(<"$TMP_DIR/dependency-classpath.txt")" +PROBE_CLASSPATH="$ROOT/lypi-transport-tui/target/test-classes:$ROOT/lypi-transport-tui/target/classes:$ROOT/lypi-contracts/target/classes:$DEPENDENCY_CLASSPATH" +CONTROL_DIR="$TMP_DIR/control" +mkdir -p "$CONTROL_DIR" + +printf -v PTY_COMMAND \ + 'TERM=xterm-256color java -cp %q cn.lypi.transport.tui.TuiInteractionPtyProbe %q; printf "\nLYPI_TUI_INTERACTION_EXIT=%%s\n" "$?"; sleep 20' \ + "$PROBE_CLASSPATH" "$CONTROL_DIR" +tmux -L "$TMUX_SOCKET" new-session -d -x 80 -y 12 "$PTY_COMMAND" + +PANE_CAPTURE="$TMP_DIR/pane.txt" +wait_for_file() { + local path="$1" + for _ in $(seq 1 200); do + [[ -f "$path" ]] && return 0 + sleep 0.05 + done + return 1 +} + +wait_for_pane_text() { + local expected="$1" + for _ in $(seq 1 200); do + tmux -L "$TMUX_SOCKET" capture-pane -p >"$PANE_CAPTURE" + grep -Fq -- "$expected" "$PANE_CAPTURE" && return 0 + sleep 0.05 + done + return 1 +} + +if ! wait_for_file "$CONTROL_DIR/ready" || ! wait_for_pane_text "history-30"; then + echo "TUI interaction probe did not render initial history" >&2 + cat "$PANE_CAPTURE" >&2 || true + exit 1 +fi + +tmux -L "$TMUX_SOCKET" send-keys -l "draft" +if ! wait_for_pane_text "> draft"; then + echo "TUI interaction probe did not render the input draft" >&2 + cat "$PANE_CAPTURE" >&2 + exit 1 +fi + +: >"$CONTROL_DIR/emit-first" +if ! wait_for_pane_text "stream-first"; then + echo "intermediate streaming frame was not visible before final delta" >&2 + cat "$PANE_CAPTURE" >&2 + exit 1 +fi +if grep -Fq -- "stream-first-final" "$PANE_CAPTURE"; then + echo "final delta appeared before its signal" >&2 + exit 1 +fi + +: >"$CONTROL_DIR/emit-final" +if ! wait_for_pane_text "stream-first-final"; then + echo "final streaming frame was not visible" >&2 + cat "$PANE_CAPTURE" >&2 + exit 1 +fi + +if ! grep -Fq -- "history-30" "$PANE_CAPTURE"; then + echo "latest history was not visible before mouse wheel input" >&2 + cat "$PANE_CAPTURE" >&2 + exit 1 +fi + +tmux -L "$TMUX_SOCKET" send-keys -l $'\033[<64;40;6M' +for _ in $(seq 1 200); do + tmux -L "$TMUX_SOCKET" capture-pane -p >"$PANE_CAPTURE" + if ! grep -Fq -- "history-30" "$PANE_CAPTURE"; then + break + fi + sleep 0.05 +done +if grep -Fq -- "history-30" "$PANE_CAPTURE"; then + echo "mouse wheel did not move the visible history window" >&2 + cat "$PANE_CAPTURE" >&2 + exit 1 +fi +if ! grep -Fq -- "> draft" "$PANE_CAPTURE"; then + echo "mouse wheel changed the input draft" >&2 + cat "$PANE_CAPTURE" >&2 + exit 1 +fi + +tmux -L "$TMUX_SOCKET" send-keys C-u +if ! wait_for_pane_text "> |"; then + echo "TUI interaction probe did not clear the draft before exit" >&2 + cat "$PANE_CAPTURE" >&2 + exit 1 +fi +tmux -L "$TMUX_SOCKET" send-keys C-c +if ! wait_for_pane_text "LYPI_TUI_INTERACTION_EXIT=0"; then + echo "TUI interaction probe did not exit cleanly" >&2 + cat "$PANE_CAPTURE" >&2 + exit 1 +fi + +echo "tui interaction PTY passed" diff --git a/lypi-transport-tui/src/test/resources/run-tui-smoke.sh b/lypi-transport-tui/src/test/resources/run-tui-smoke.sh index 108c4682..4aaae994 100644 --- a/lypi-transport-tui/src/test/resources/run-tui-smoke.sh +++ b/lypi-transport-tui/src/test/resources/run-tui-smoke.sh @@ -44,10 +44,14 @@ timeout 15 script -q -e -c "$PTY_COMMAND" "$PTY_OUTPUT" >/dev/null expected_sequences=( $'\033[?1049h' + $'\033[?1000h' + $'\033[?1006h' $'\033[?2004h' $'\033[?25l' "LYPI_TUI_PTY_OPEN" $'\033[?2004l' + $'\033[?1006l' + $'\033[?1000l' $'\033[?1049l' $'\033[?25h' "LYPI_TUI_PTY_CLOSED" @@ -68,5 +72,6 @@ for expected in "${expected_sequences[@]}"; do done bash "$ROOT/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh" +bash "$ROOT/lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh" echo "tui smoke passed" From 8826686069e50cf361667265917c30cc46abf316 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Thu, 16 Jul 2026 00:26:54 +0800 Subject: [PATCH 44/58] refactor(tui): run terminal session on normal screen --- .../lypi/transport/tui/TerminalSession.java | 35 +++++-------------- .../transport/tui/TerminalSessionTest.java | 17 ++++++--- 2 files changed, 21 insertions(+), 31 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java index 706daf76..98ac3318 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java @@ -5,12 +5,7 @@ import org.jline.terminal.TerminalBuilder; public final class TerminalSession implements AutoCloseable { - static final String ENTER_ALTERNATE_SCREEN = "\033[?1049h"; - static final String LEAVE_ALTERNATE_SCREEN = "\033[?1049l"; - static final String ENABLE_MOUSE_TRACKING = "\033[?1000h"; - static final String DISABLE_MOUSE_TRACKING = "\033[?1000l"; - static final String ENABLE_SGR_MOUSE = "\033[?1006h"; - static final String DISABLE_SGR_MOUSE = "\033[?1006l"; + static final String RESET_SCROLL_REGION = "\033[r"; static final String ENABLE_BRACKETED_PASTE = "\033[?2004h"; static final String DISABLE_BRACKETED_PASTE = "\033[?2004l"; static final String HIDE_CURSOR = "\033[?25l"; @@ -63,22 +58,17 @@ static TerminalSession open(TerminalIo io, Runnable resizeCallback, Runnable int AutoCloseable rawMode = null; AutoCloseable resizeHandler = null; AutoCloseable interruptHandler = null; - boolean alternateScreenEntered = false; try { rawMode = io.enterRawMode(); resizeHandler = io.onResize(resizeCallback); interruptHandler = io.onInterrupt(interruptCallback); - io.write(ENTER_ALTERNATE_SCREEN); - alternateScreenEntered = true; - io.write(ENABLE_MOUSE_TRACKING); - io.write(ENABLE_SGR_MOUSE); io.write(ENABLE_BRACKETED_PASTE); io.write(HIDE_CURSOR); io.write(ENABLE_MODIFY_OTHER_KEYS); io.flush(); return new TerminalSession(io, rawMode, resizeHandler, interruptHandler); } catch (IOException | RuntimeException exception) { - restoreAfterOpenFailure(io, alternateScreenEntered, interruptHandler, resizeHandler, rawMode); + restoreAfterOpenFailure(io, interruptHandler, resizeHandler, rawMode); throw exception; } } @@ -90,11 +80,9 @@ public void close() throws Exception { } closed = true; try { + io.write(RESET_SCROLL_REGION); io.write(DISABLE_MODIFY_OTHER_KEYS); io.write(DISABLE_BRACKETED_PASTE); - io.write(DISABLE_SGR_MOUSE); - io.write(DISABLE_MOUSE_TRACKING); - io.write(LEAVE_ALTERNATE_SCREEN); io.write(SHOW_CURSOR); io.flush(); } finally { @@ -114,20 +102,15 @@ private void closeQuietly(AutoCloseable closeable) { private static void restoreAfterOpenFailure( TerminalIo io, - boolean alternateScreenEntered, AutoCloseable interruptHandler, AutoCloseable resizeHandler, AutoCloseable rawMode ) { - if (alternateScreenEntered) { - writeStaticQuietly(io, DISABLE_MODIFY_OTHER_KEYS); - writeStaticQuietly(io, DISABLE_BRACKETED_PASTE); - writeStaticQuietly(io, DISABLE_SGR_MOUSE); - writeStaticQuietly(io, DISABLE_MOUSE_TRACKING); - writeStaticQuietly(io, LEAVE_ALTERNATE_SCREEN); - writeStaticQuietly(io, SHOW_CURSOR); - flushStaticQuietly(io); - } + writeStaticQuietly(io, RESET_SCROLL_REGION); + writeStaticQuietly(io, DISABLE_MODIFY_OTHER_KEYS); + writeStaticQuietly(io, DISABLE_BRACKETED_PASTE); + writeStaticQuietly(io, SHOW_CURSOR); + flushStaticQuietly(io); closeStaticQuietly(interruptHandler); closeStaticQuietly(resizeHandler); closeStaticQuietly(rawMode); @@ -137,7 +120,7 @@ private static void writeStaticQuietly(TerminalIo io, String value) { try { io.write(value); } catch (IOException | RuntimeException ignored) { - // NOTE: 打开失败时每个恢复序列都独立尝试,避免一次写失败阻断退出 alternate screen。 + // NOTE: 打开失败时每个恢复序列都独立尝试,避免一次写失败阻断终端恢复。 } } diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java index fa552c92..71c28721 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java @@ -1,6 +1,7 @@ package cn.lypi.transport.tui; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -17,16 +18,19 @@ void openEntersInteractiveTerminalModesAndCloseRestoresThem() throws Exception { assertTrue(io.rawModeEntered); assertEquals( - "\033[?1049h\033[?1000h\033[?1006h\033[?2004h\033[?25l\033[>4;2m", + "\033[?2004h\033[?25l\033[>4;2m", io.output.toString() ); + assertFalse(io.output.toString().contains("\033[?1049")); + assertFalse(io.output.toString().contains("\033[?1000")); + assertFalse(io.output.toString().contains("\033[?1006")); session.close(); assertTrue(io.rawModeRestored); assertEquals( - "\033[?1049h\033[?1000h\033[?1006h\033[?2004h\033[?25l\033[>4;2m" - + "\033[>4m\033[?2004l\033[?1006l\033[?1000l\033[?1049l\033[?25h", + "\033[?2004h\033[?25l\033[>4;2m" + + "\033[r\033[>4m\033[?2004l\033[?25h", io.output.toString() ); } @@ -69,15 +73,18 @@ void openRegistersInterruptHandlerAndCloseRestoresIt() throws Exception { } @Test - void openFailureLeavesAlternateScreenAndRestoresTerminalResources() { + void openFailureRestoresTerminalResourcesWithoutAlternateScreenModes() { FailingTerminalIo io = new FailingTerminalIo(2); assertThrows(IOException.class, () -> TerminalSession.open(io)); assertEquals( - "\033[?1049h\033[>4m\033[?2004l\033[?1006l\033[?1000l\033[?1049l\033[?25h", + "\033[?2004h\033[r\033[>4m\033[?2004l\033[?25h", io.output.toString() ); + assertFalse(io.output.toString().contains("\033[?1049")); + assertFalse(io.output.toString().contains("\033[?1000")); + assertFalse(io.output.toString().contains("\033[?1006")); assertTrue(io.rawModeRestored); assertTrue(io.resizeHandlerRestored); assertTrue(io.interruptHandlerRestored); From 819f93aa390222d65dc8bd7188d531627287ae29 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Thu, 16 Jul 2026 00:35:53 +0800 Subject: [PATCH 45/58] feat(tui): track inline terminal viewport --- .../cn/lypi/transport/tui/InlineViewport.java | 40 +++++++ .../tui/JLineTerminalInputSource.java | 17 ++- .../transport/tui/TerminalCursorProbe.java | 71 +++++++++++ .../lypi/transport/tui/TerminalPosition.java | 9 ++ .../transport/tui/InlineViewportTest.java | 20 ++++ .../tui/JLineTerminalInputSourceTest.java | 27 +++++ .../tui/TerminalCursorProbeTest.java | 112 ++++++++++++++++++ 7 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineViewport.java create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalPosition.java create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineViewportTest.java create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalCursorProbeTest.java diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineViewport.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineViewport.java new file mode 100644 index 00000000..ceae522d --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineViewport.java @@ -0,0 +1,40 @@ +package cn.lypi.transport.tui; + +record InlineViewport(int top, int surfaceHeight, int width, int terminalHeight) { + InlineViewport { + if (top < 0 || surfaceHeight < 1 || width < 1 || terminalHeight < 1) { + throw new IllegalArgumentException("inline viewport dimensions must be positive"); + } + if (top > terminalHeight - surfaceHeight) { + throw new IllegalArgumentException("inline viewport must fit within terminal height"); + } + } + + static InlineViewport at(TerminalPosition position, int width, int terminalHeight) { + if (width < 1 || terminalHeight < 1) { + throw new IllegalArgumentException("terminal dimensions must be positive"); + } + int top = Math.min(position.row(), terminalHeight - 1); + int initialHeight = Math.max(1, terminalHeight - top - 1); + return new InlineViewport(top, initialHeight, width, terminalHeight); + } + + InlineViewport withSurfaceHeight(int nextSurfaceHeight) { + if (nextSurfaceHeight < 1 || nextSurfaceHeight > terminalHeight) { + throw new IllegalArgumentException("surface height must fit within terminal height"); + } + int nextTop = Math.min(top, terminalHeight - nextSurfaceHeight); + return new InlineViewport(nextTop, nextSurfaceHeight, width, terminalHeight); + } + + InlineViewport resize(int nextWidth, int nextTerminalHeight) { + if (nextWidth < 1 || nextTerminalHeight < 1) { + throw new IllegalArgumentException("terminal dimensions must be positive"); + } + int nextSurfaceHeight = Math.min(surfaceHeight, nextTerminalHeight); + int highestVisibleTop = nextTerminalHeight - nextSurfaceHeight; + boolean bottomAligned = top + surfaceHeight == terminalHeight; + int nextTop = bottomAligned ? highestVisibleTop : Math.min(top, highestVisibleTop); + return new InlineViewport(nextTop, nextSurfaceHeight, nextWidth, nextTerminalHeight); + } +} diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTerminalInputSource.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTerminalInputSource.java index 5c41a7f8..9e1491a7 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTerminalInputSource.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTerminalInputSource.java @@ -10,17 +10,32 @@ final class JLineTerminalInputSource implements TerminalInputSource { private static final long ESCAPE_CONTINUATION_TIMEOUT_MILLIS = 10L; private final NonBlockingReader reader; + private String replayInput; JLineTerminalInputSource(Terminal terminal) { - this(terminal.reader()); + this(terminal, ""); + } + + JLineTerminalInputSource(Terminal terminal, String replayInput) { + this(terminal.reader(), replayInput); } JLineTerminalInputSource(NonBlockingReader reader) { + this(reader, ""); + } + + private JLineTerminalInputSource(NonBlockingReader reader, String replayInput) { this.reader = reader; + this.replayInput = replayInput == null ? "" : replayInput; } @Override public Optional read() throws IOException { + if (!replayInput.isEmpty()) { + String replay = replayInput; + replayInput = ""; + return Optional.of(replay); + } int first = reader.read(FIRST_CHARACTER_TIMEOUT_MILLIS); if (first == NonBlockingReader.READ_EXPIRED || first == NonBlockingReader.EOF) { return Optional.empty(); diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java new file mode 100644 index 00000000..f4f40637 --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java @@ -0,0 +1,71 @@ +package cn.lypi.transport.tui; + +import java.io.IOException; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jline.terminal.Terminal; +import org.jline.utils.NonBlockingReader; + +final class TerminalCursorProbe { + private static final Pattern CURSOR_POSITION_REPORT = Pattern.compile("\033\\[([1-9]\\d*);([1-9]\\d*)R"); + + private TerminalCursorProbe() { + } + + static CursorProbeResult query(Terminal terminal, Duration timeout) throws IOException { + Objects.requireNonNull(terminal, "terminal"); + Objects.requireNonNull(timeout, "timeout"); + if (timeout.isNegative()) { + throw new IllegalArgumentException("cursor probe timeout must be non-negative"); + } + + terminal.writer().write("\033[6n"); + terminal.flush(); + + long deadline = System.nanoTime() + timeout.toNanos(); + StringBuilder response = new StringBuilder(); + while (true) { + long remainingNanos = deadline - System.nanoTime(); + if (remainingNanos <= 0) { + break; + } + long remainingMillis = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNanos)); + int next = terminal.reader().read(remainingMillis); + if (next == NonBlockingReader.EOF) { + break; + } + if (next == NonBlockingReader.READ_EXPIRED) { + continue; + } + response.append((char) next); + CursorProbeResult parsed = parse(response.toString()); + if (parsed.position().isPresent()) { + return parsed; + } + } + return parse(response.toString()); + } + + static CursorProbeResult parse(String input) { + String value = input == null ? "" : input; + Matcher matcher = CURSOR_POSITION_REPORT.matcher(value); + if (!matcher.find()) { + return new CursorProbeResult(Optional.empty(), value); + } + int row = Integer.parseInt(matcher.group(1)) - 1; + int column = Integer.parseInt(matcher.group(2)) - 1; + String replayInput = value.substring(0, matcher.start()) + value.substring(matcher.end()); + return new CursorProbeResult(Optional.of(new TerminalPosition(column, row)), replayInput); + } +} + +record CursorProbeResult(Optional position, String replayInput) { + CursorProbeResult { + position = position == null ? Optional.empty() : position; + replayInput = replayInput == null ? "" : replayInput; + } +} diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalPosition.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalPosition.java new file mode 100644 index 00000000..dc97ecf3 --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalPosition.java @@ -0,0 +1,9 @@ +package cn.lypi.transport.tui; + +record TerminalPosition(int column, int row) { + TerminalPosition { + if (column < 0 || row < 0) { + throw new IllegalArgumentException("terminal position must be non-negative"); + } + } +} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineViewportTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineViewportTest.java new file mode 100644 index 00000000..6202fef8 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineViewportTest.java @@ -0,0 +1,20 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class InlineViewportTest { + @Test + void surfaceAndTerminalResizePreserveReachableViewportGeometry() { + InlineViewport viewport = InlineViewport.at(new TerminalPosition(0, 8), 80, 12); + + assertEquals(new InlineViewport(8, 3, 80, 12), viewport.withSurfaceHeight(3)); + assertEquals(new InlineViewport(6, 6, 80, 12), viewport.withSurfaceHeight(6)); + assertEquals(new InlineViewport(5, 3, 60, 8), viewport.resize(60, 8)); + assertEquals( + new InlineViewport(9, 3, 80, 12), + new InlineViewport(5, 3, 80, 8).resize(80, 12) + ); + } +} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTerminalInputSourceTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTerminalInputSourceTest.java index 03684996..0bb22cac 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTerminalInputSourceTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTerminalInputSourceTest.java @@ -4,7 +4,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.Proxy; import java.util.Optional; +import org.jline.terminal.Terminal; import org.jline.utils.NonBlockingReader; import org.junit.jupiter.api.Test; @@ -20,6 +24,29 @@ void readUsesBoundedPollTimeoutForFirstCharacter() throws IOException { assertEquals(10L, reader.lastTimeout); } + @Test + void readReplaysCursorProbeInputBeforePollingTerminal() throws IOException { + RecordingReader reader = new RecordingReader('x'); + JLineTerminalInputSource source = new JLineTerminalInputSource(terminal(reader), "typed"); + + assertEquals(Optional.of("typed"), source.read()); + assertEquals(Optional.of("x"), source.read()); + } + + private static Terminal terminal(NonBlockingReader reader) { + PrintWriter writer = new PrintWriter(new StringWriter()); + return (Terminal) Proxy.newProxyInstance( + Terminal.class.getClassLoader(), + new Class[] { Terminal.class }, + (proxy, method, arguments) -> switch (method.getName()) { + case "reader" -> reader; + case "writer" -> writer; + case "toString" -> "input-source-terminal"; + default -> throw new UnsupportedOperationException(method.getName()); + } + ); + } + private static final class RecordingReader extends NonBlockingReader { private final int result; private long lastTimeout = -1L; diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalCursorProbeTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalCursorProbeTest.java new file mode 100644 index 00000000..20f11039 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalCursorProbeTest.java @@ -0,0 +1,112 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.Proxy; +import java.time.Duration; +import java.util.Optional; +import org.jline.terminal.Terminal; +import org.jline.utils.NonBlockingReader; +import org.junit.jupiter.api.Test; + +class TerminalCursorProbeTest { + @Test + void parseExtractsFirstCursorPositionAndPreservesOtherInput() { + assertEquals( + new CursorProbeResult(Optional.of(new TerminalPosition(11, 6)), "typed"), + TerminalCursorProbe.parse("typed\033[7;12R") + ); + assertEquals( + new CursorProbeResult(Optional.empty(), "typed"), + TerminalCursorProbe.parse("typed") + ); + } + + @Test + void queryReturnsWithinDeadlineWhenTerminalDoesNotRespond() throws Exception { + StringWriter output = new StringWriter(); + Terminal terminal = terminal(new ExpiringReader(), output); + + long started = System.nanoTime(); + CursorProbeResult result = TerminalCursorProbe.query(terminal, Duration.ofMillis(25)); + long elapsedMillis = Duration.ofNanos(System.nanoTime() - started).toMillis(); + + assertTrue(result.position().isEmpty()); + assertEquals("", result.replayInput()); + assertEquals("\033[6n", output.toString()); + assertTrue(elapsedMillis < 250, () -> "probe took " + elapsedMillis + "ms"); + } + + @Test + void queryReturnsCursorPositionAndInputReadBeforeResponse() throws Exception { + StringWriter output = new StringWriter(); + Terminal terminal = terminal(new SequenceReader("typed\033[7;12R"), output); + + CursorProbeResult result = TerminalCursorProbe.query(terminal, Duration.ofMillis(25)); + + assertEquals( + new CursorProbeResult(Optional.of(new TerminalPosition(11, 6)), "typed"), + result + ); + assertEquals("\033[6n", output.toString()); + } + + private static Terminal terminal(NonBlockingReader reader, StringWriter output) { + PrintWriter writer = new PrintWriter(output); + return (Terminal) Proxy.newProxyInstance( + Terminal.class.getClassLoader(), + new Class[] { Terminal.class }, + (proxy, method, arguments) -> switch (method.getName()) { + case "reader" -> reader; + case "writer" -> writer; + case "flush" -> { + writer.flush(); + yield null; + } + case "toString" -> "probe-terminal"; + default -> throw new UnsupportedOperationException(method.getName()); + } + ); + } + + private static final class ExpiringReader extends NonBlockingReader { + @Override + protected int read(long timeout, boolean isPeek) { + return READ_EXPIRED; + } + + @Override + public int readBuffered(char[] buffer, int offset, int length, long timeout) { + return 0; + } + } + + private static final class SequenceReader extends NonBlockingReader { + private final String input; + private int index; + + private SequenceReader(String input) { + this.input = input; + } + + @Override + protected int read(long timeout, boolean isPeek) { + if (index >= input.length()) { + return READ_EXPIRED; + } + int next = input.charAt(index); + if (!isPeek) { + index++; + } + return next; + } + + @Override + public int readBuffered(char[] buffer, int offset, int length, long timeout) { + return 0; + } + } +} From 71df9da3659963bdbe5cf56cc85080f2110c4d64 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Thu, 16 Jul 2026 00:39:59 +0800 Subject: [PATCH 46/58] feat(tui): track committed transcript prefix --- .../tui/TuiTranscriptCommitLedger.java | 36 ++++++++++++ .../tui/TuiTranscriptCommitLedgerTest.java | 55 +++++++++++++++++++ .../tui/TuiTranscriptPartitionerTest.java | 18 ++++++ 3 files changed, 109 insertions(+) create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptCommitLedger.java create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptCommitLedgerTest.java diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptCommitLedger.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptCommitLedger.java new file mode 100644 index 00000000..441e99c4 --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptCommitLedger.java @@ -0,0 +1,36 @@ +package cn.lypi.transport.tui; + +import cn.lypi.contracts.tui.TuiBlock; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +final class TuiTranscriptCommitLedger { + private TuiProjectionKey projectionKey; + private final Set committedBlockIds = new LinkedHashSet<>(); + + List advance(TuiProjectionKey nextKey, List stablePrefix) { + Objects.requireNonNull(nextKey, "nextKey"); + List stable = List.copyOf(stablePrefix); + if (!nextKey.equals(projectionKey)) { + projectionKey = nextKey; + committedBlockIds.clear(); + } + List newlyCommitted = new ArrayList<>(); + for (TuiBlock block : stable) { + if (committedBlockIds.add(block.blockId())) { + newlyCommitted.add(block); + } + } + return List.copyOf(newlyCommitted); + } +} + +record TuiProjectionKey(String sessionId, String leafId) { + TuiProjectionKey { + sessionId = sessionId == null ? "" : sessionId; + leafId = leafId == null ? "" : leafId; + } +} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptCommitLedgerTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptCommitLedgerTest.java new file mode 100644 index 00000000..a1ebf9c5 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptCommitLedgerTest.java @@ -0,0 +1,55 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import cn.lypi.contracts.tui.TuiMessageBlock; +import java.util.List; +import org.junit.jupiter.api.Test; + +class TuiTranscriptCommitLedgerTest { + @Test + void returnsEachStableBlockOnlyOnceWithinProjection() { + TuiProjectionKey key = new TuiProjectionKey("ses_1", "leaf_1"); + TuiTranscriptCommitLedger ledger = new TuiTranscriptCommitLedger(); + TuiMessageBlock user = message("user"); + TuiMessageBlock assistant = message("assistant"); + TuiMessageBlock tool = message("tool"); + + assertEquals(List.of(user, assistant), ledger.advance(key, List.of(user, assistant))); + assertEquals(List.of(), ledger.advance(key, List.of(user, assistant))); + assertEquals(List.of(tool), ledger.advance(key, List.of(user, assistant, tool))); + } + + @Test + void projectionChangeStartsACommitEpoch() { + TuiTranscriptCommitLedger ledger = new TuiTranscriptCommitLedger(); + TuiMessageBlock user = message("user"); + + assertEquals( + List.of(user), + ledger.advance(new TuiProjectionKey("ses_1", "leaf_1"), List.of(user)) + ); + assertEquals( + List.of(user), + ledger.advance(new TuiProjectionKey("ses_2", "leaf_2"), List.of(user)) + ); + } + + @Test + void stablePrefixRegressionDoesNotRecommitBlocksOrBlockLaterCommits() { + TuiProjectionKey key = new TuiProjectionKey("ses_1", "leaf_1"); + TuiTranscriptCommitLedger ledger = new TuiTranscriptCommitLedger(); + TuiMessageBlock user = message("user"); + TuiMessageBlock assistant = message("assistant"); + TuiMessageBlock later = message("later"); + + assertEquals(List.of(user, assistant), ledger.advance(key, List.of(user, assistant))); + assertEquals(List.of(), ledger.advance(key, List.of(user))); + assertEquals(List.of(), ledger.advance(key, List.of(user, assistant))); + assertEquals(List.of(later), ledger.advance(key, List.of(user, assistant, later))); + } + + private TuiMessageBlock message(String blockId) { + return new TuiMessageBlock(blockId, "message-" + blockId, "assistant", blockId, false); + } +} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java index 6da083c0..99877b36 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java @@ -51,6 +51,24 @@ void treatsStreamingAndNonterminalOrActiveToolsAsLive() { assertLive(tool("active-done", TuiToolState.DONE, true)); } + @Test + void stablePrefixCanRegressWhenAPreviouslyStableBlockBecomesLive() { + TuiTranscriptPartitioner partitioner = new TuiTranscriptPartitioner(); + + TuiTranscriptPartition stable = partitioner.partition(List.of( + message("user", false), + message("assistant", false) + )); + TuiTranscriptPartition regressed = partitioner.partition(List.of( + message("user", false), + message("assistant", true) + )); + + assertEquals(List.of("user", "assistant"), blockIds(stable.history())); + assertEquals(List.of("user"), blockIds(regressed.history())); + assertEquals(List.of("assistant"), blockIds(regressed.live())); + } + private TuiMessageBlock message(String blockId, boolean streaming) { return new TuiMessageBlock(blockId, "message-" + blockId, "assistant", blockId, streaming); } From 6c02e3b325dbc75d943f48e7c86d690c96ebc6cb Mon Sep 17 00:00:00 2001 From: lyfmt Date: Thu, 16 Jul 2026 00:47:58 +0800 Subject: [PATCH 47/58] refactor(tui): split transcript from mutable surface --- .../transport/tui/TerminalFrameRenderer.java | 5 +- .../java/cn/lypi/transport/tui/TuiLayout.java | 23 ++++ .../cn/lypi/transport/tui/TuiRenderFrame.java | 2 + .../cn/lypi/transport/tui/TuiRenderer.java | 65 +++++++++- .../JLineTuiTransportRenderPipelineTest.java | 4 +- .../cn/lypi/transport/tui/TuiLayoutTest.java | 11 ++ .../lypi/transport/tui/TuiRendererTest.java | 112 +++++++++++++++++- 7 files changed, 209 insertions(+), 13 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java index 00d9df34..8f110ef2 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java @@ -6,7 +6,6 @@ import java.util.Optional; final class TerminalFrameRenderer { - static final String CURSOR_MARKER = "|CURSOR|"; private static final String SYNC_START = "\033[?2026h"; private static final String SYNC_END = "\033[?2026l"; private static final String FULL_CLEAR = "\033[2J\033[H"; @@ -134,13 +133,13 @@ private CursorFrame stripCursor(List lines) { for (int row = 0; row < lines.size(); row++) { TerminalLine line = lines.get(row); String text = line.text(); - int marker = text.indexOf(CURSOR_MARKER); + int marker = text.indexOf(TuiRenderFrame.CURSOR_MARKER); if (marker < 0) { stripped.add(line); continue; } String before = text.substring(0, marker); - String after = text.substring(marker + CURSOR_MARKER.length()); + String after = text.substring(marker + TuiRenderFrame.CURSOR_MARKER.length()); stripped.add(new TerminalLine(before + after)); cursor = new CursorPosition(row + 1, AnsiWidth.displayWidth(before) + 1); } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java index 90fb6558..381e3a64 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java @@ -18,6 +18,29 @@ int transcriptHeight() { return allocate(MIN_INPUT_CONTENT_HEIGHT + INPUT_BORDER_HEIGHT, 0, true).transcriptHeight(); } + int maxSurfaceHeight() { + return Math.max(1, height - 1); + } + + TuiRegionLayout allocateSurface(int desiredLiveHeight, int desiredInputHeight, int desiredOverlayHeight) { + int budget = maxSurfaceHeight(); + int inputHeight = 1; + int statusHeight = budget > 1 ? STATUS_BAR_HEIGHT : 0; + int remainingHeight = budget - inputHeight - statusHeight; + + int boundedOverlayHeight = Math.max(0, desiredOverlayHeight); + int overlayHeight = Math.min(boundedOverlayHeight, remainingHeight); + remainingHeight -= overlayHeight; + + int boundedInputHeight = Math.max(1, desiredInputHeight); + int additionalInputHeight = Math.min(boundedInputHeight - inputHeight, remainingHeight); + inputHeight += additionalInputHeight; + remainingHeight -= additionalInputHeight; + + int liveHeight = Math.min(Math.max(0, desiredLiveHeight), remainingHeight); + return new TuiRegionLayout(liveHeight, inputHeight, overlayHeight, statusHeight); + } + int transcriptHeight(int inputBlockHeight) { return allocate(inputBlockHeight, 0, true).transcriptHeight(); } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderFrame.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderFrame.java index a42e159d..994a2656 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderFrame.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderFrame.java @@ -3,6 +3,8 @@ import java.util.List; record TuiRenderFrame(List terminalLines) { + static final String CURSOR_MARKER = "|CURSOR|"; + TuiRenderFrame { terminalLines = List.copyOf(terminalLines); } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java index 83e19eb3..5d6c6151 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java @@ -29,6 +29,57 @@ final class TuiRenderer { private final ToolDisplayRendererRegistry toolDisplayRenderers = ToolDisplayRendererRegistry.defaults(); private final TuiTranscriptPartitioner transcriptPartitioner = new TuiTranscriptPartitioner(); + List renderCommittedBlocks(List blocks, int width) { + return renderTranscriptBlocks(blocks, width, false, Integer.MAX_VALUE).stream() + .map(TerminalLine::new) + .toList(); + } + + TuiRenderFrame renderSurface( + TuiViewModel view, + List liveBlocks, + TuiLayout layout, + String input, + int cursor, + List overlayLines, + boolean toolOutputExpanded + ) { + List fullLive = renderLiveLines( + view, + liveBlocks, + layout.width(), + toolOutputExpanded, + Integer.MAX_VALUE + ); + InputCandidate inputCandidate = compactRunning(view) + ? readonlyRuntimeInputCandidate("compact 正在进行...", layout.width()) + : measureInput(input, cursor, layout.width()); + OverlayBlock fullOverlay = combineOverlays( + permissionOverlay(view, layout.width()), + externalOverlay(overlayLines, layout.width()) + ); + TuiRegionLayout regions = layout.allocateSurface( + fullLive.size(), + inputCandidate.desiredHeight(), + fullOverlay.lines().size() + ); + List lines = new ArrayList<>(); + lines.addAll(tail(fullLive, regions.transcriptHeight())); + lines.addAll(inputCandidate.render(regions.inputHeight()).lines()); + lines.addAll(windowOverlay( + fullOverlay.lines(), + regions.overlayHeight(), + fullOverlay.selectedRow() + )); + if (regions.statusHeight() > 0) { + lines.add(ordinaryStatusLine(view.statusBar(), layout.width())); + } + if (lines.size() > layout.maxSurfaceHeight()) { + throw new IllegalStateException("rendered surface exceeds terminal budget"); + } + return TuiRenderFrame.fromTextLines(lines); + } + List render(TuiViewModel view, TuiScreen screen, TuiLayout layout, String input) { return renderFrame(view, screen, layout, input, -1).lines(); } @@ -604,14 +655,14 @@ private String inputContentLine(InputVisualLine line, String prefix) { private String insertCursor(String content, int cursorColumn) { if (cursorColumn <= 0) { - return TerminalFrameRenderer.CURSOR_MARKER + INPUT_CURSOR + content; + return TuiRenderFrame.CURSOR_MARKER + INPUT_CURSOR + content; } StringBuilder result = new StringBuilder(); int width = 0; boolean inserted = false; for (int index = 0; index < content.length();) { if (!inserted && width >= cursorColumn) { - result.append(TerminalFrameRenderer.CURSOR_MARKER).append(INPUT_CURSOR); + result.append(TuiRenderFrame.CURSOR_MARKER).append(INPUT_CURSOR); inserted = true; } int codePoint = content.codePointAt(index); @@ -621,7 +672,7 @@ private String insertCursor(String content, int cursorColumn) { index += Character.charCount(codePoint); } if (!inserted) { - result.append(TerminalFrameRenderer.CURSOR_MARKER).append(INPUT_CURSOR); + result.append(TuiRenderFrame.CURSOR_MARKER).append(INPUT_CURSOR); } return result.toString(); } @@ -646,6 +697,14 @@ private List topPaddedTail(List lines, int height) { return List.copyOf(padded); } + private List tail(List lines, int height) { + if (height <= 0 || lines.isEmpty()) { + return List.of(); + } + int start = Math.max(0, lines.size() - height); + return List.copyOf(lines.subList(start, lines.size())); + } + private List blankLines(int count) { if (count <= 0) { return List.of(); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java index 813c3bff..b96fb98e 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java @@ -747,8 +747,8 @@ void inputAfterResizeUsesUpdatedDimensions() throws Exception { transport.drainInputForTest(); frames.getLast().forEach(line -> assertEquals( - line.replace(TerminalFrameRenderer.CURSOR_MARKER, ""), - AnsiWidth.truncate(line.replace(TerminalFrameRenderer.CURSOR_MARKER, ""), 8) + line.replace(TuiRenderFrame.CURSOR_MARKER, ""), + AnsiWidth.truncate(line.replace(TuiRenderFrame.CURSOR_MARKER, ""), 8) )); } diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java index 7473702a..a694c2cb 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java @@ -7,6 +7,17 @@ import org.junit.jupiter.api.Test; class TuiLayoutTest { + @Test + void allocatesOnlyTheBoundedSurfaceRowsThatAreNeeded() { + TuiLayout layout = new TuiLayout(80, 12); + + TuiRegionLayout regions = layout.allocateSurface(1, 3, 2); + + assertEquals(11, layout.maxSurfaceHeight()); + assertEquals(new TuiRegionLayout(1, 3, 2, 1), regions); + assertEquals(7, regions.totalHeight()); + } + @Test void allocatesAllRegionsWithinTerminalHeight() { for (int height : new int[] {2, 3, 6, 24}) { diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java index 4c064e1e..33b61caa 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java @@ -33,6 +33,108 @@ class TuiRendererTest { private static final String INPUT_CURSOR = "\033[38;5;81m|\033[39m"; private static final String ANSI_RESET = "\033[0m"; + @Test + void rendersCommittedBlocksAsStandaloneHistoryLines() { + TuiRenderer renderer = new TuiRenderer(); + + List history = renderer.renderCommittedBlocks( + List.of(new TuiMessageBlock("a1", "m1", "assistant", "final", false)), + 80 + ).stream().map(TerminalLine::text).toList(); + + assertEquals(List.of("final"), history); + } + + @Test + void mutableSurfaceExcludesStableHistoryAndFitsBoundedHeight() { + TuiRenderer renderer = new TuiRenderer(); + TuiMessageBlock stable = new TuiMessageBlock( + "stable", "m1", "assistant", "stable history", false + ); + TuiMessageBlock streaming = new TuiMessageBlock( + "streaming", "m2", "assistant", "streaming", true + ); + TuiViewModel view = new TuiViewModel( + List.of(stable, streaming), + new StatusBarState("ses_1", "gpt-5.4", "running", "default"), + List.of(), + Optional.empty(), + Optional.empty() + ); + + TuiRenderFrame surface = renderer.renderSurface( + view, + List.of(streaming), + new TuiLayout(80, 12), + "draft", + 5, + List.of(), + false + ); + + assertFalse(surface.lines().stream().anyMatch(line -> line.contains("stable history"))); + assertTrue(surface.lines().stream().anyMatch(line -> line.contains("streaming"))); + assertTrue(surface.lines().stream().anyMatch(line -> line.contains("> draft"))); + assertTrue(surface.lines().getLast().contains("ses_1")); + assertTrue(surface.lines().size() <= 11); + } + + @Test + void mutableSurfaceKeepsOverlaysDiffAndSingleStatusLineVisible() { + PermissionPromptView prompt = new PermissionPromptView( + "perm_1", + "toolu_1", + "Need approval", + "bash:mvn test", + "allow_once", + "cancel", + List.of( + new PermissionOption( + "allow_once", PermissionOptionKind.ALLOW_ONCE, "Allow", "", Optional.empty(), Map.of() + ), + new PermissionOption( + "cancel", PermissionOptionKind.CANCEL, "Cancel", "", Optional.empty(), Map.of() + ) + ), + "cancel" + ); + TuiViewModel view = new TuiViewModel( + List.of(), + new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), + List.of(), + Optional.of(prompt), + Optional.of(new DiffView( + "1 file changed", + List.of(new GitDiffFileView( + Path.of("src/App.java"), GitDiffStatus.MODIFIED, "Modified", Map.of() + )), + "+new line", + false, + Map.of() + )) + ); + + TuiRenderFrame surface = new TuiRenderer().renderSurface( + view, + List.of(), + new TuiLayout(80, 24), + "/", + 1, + List.of(" slash: /model", " skill: @review", "> resume: ses_2"), + false + ); + String rendered = String.join("\n", surface.lines()); + + assertTrue(rendered.contains("permission toolu_1: Need approval")); + assertTrue(rendered.contains("> Cancel")); + assertTrue(rendered.contains("slash: /model")); + assertTrue(rendered.contains("skill: @review")); + assertTrue(rendered.contains("> resume: ses_2")); + assertTrue(rendered.contains("diff: 1 file changed")); + assertEquals(1, surface.lines().stream().filter(line -> line.contains("ses_1")).count()); + assertTrue(surface.lines().getLast().contains("ses_1")); + } + @Test void rendersFixedHeightHistoryAndLiveRegions() { TuiRenderer renderer = new TuiRenderer(); @@ -439,7 +541,7 @@ void visibleCursorDoesNotPushInputContentPastLayoutWidth() { List lines = renderer.render(view, screen, new TuiLayout(8, 4), "abcdef", 6); for (String line : lines) { - assertTrue(AnsiWidth.displayWidth(line.replace(TerminalFrameRenderer.CURSOR_MARKER, "")) <= 8); + assertTrue(AnsiWidth.displayWidth(line.replace(TuiRenderFrame.CURSOR_MARKER, "")) <= 8); } } @@ -467,7 +569,7 @@ void longInputIsWindowedWithoutRemovingLatestTranscriptLine() { assertFalse(lines.contains("line1")); assertFalse(lines.contains("line2")); assertFalse(lines.contains("line3")); - assertTrue(lines.stream().anyMatch(line -> line.contains(TerminalFrameRenderer.CURSOR_MARKER))); + assertTrue(lines.stream().anyMatch(line -> line.contains(TuiRenderFrame.CURSOR_MARKER))); } @Test @@ -511,7 +613,7 @@ void inputViewportShowsCursorRowsWhileKeepingOneTranscriptLine() { assertFalse(lines.stream().anyMatch(line -> line.contains("two"))); assertTrue(lines.stream().anyMatch(line -> line.contains("three"))); assertTrue(lines.stream().anyMatch(line -> line.contains("four") - && line.contains(TerminalFrameRenderer.CURSOR_MARKER))); + && line.contains(TuiRenderFrame.CURSOR_MARKER))); } @Test @@ -531,7 +633,7 @@ void minimumInputAndTranscriptFitInThreeLineTerminal() { assertTrue(lines.size() <= 3); assertTrue(lines.contains("history")); assertTrue(lines.stream().anyMatch(line -> line.contains("four") - && line.contains(TerminalFrameRenderer.CURSOR_MARKER))); + && line.contains(TuiRenderFrame.CURSOR_MARKER))); assertTrue(lines.getLast().contains("ses_1")); } @@ -657,7 +759,7 @@ void tallPermissionPromptKeepsSelectedOptionInputCursorAndStatusVisible() { assertTrue(lines.size() <= 6); assertTrue(lines.stream().anyMatch(line -> line.contains("> Option 6"))); - assertTrue(lines.stream().anyMatch(line -> line.contains(TerminalFrameRenderer.CURSOR_MARKER))); + assertTrue(lines.stream().anyMatch(line -> line.contains(TuiRenderFrame.CURSOR_MARKER))); assertTrue(lines.getLast().contains("ses_1")); assertFalse(lines.stream().anyMatch(line -> line.contains("Option 1"))); } From 60fc799cd4b5808da084d0937da98eb323da43e7 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Thu, 16 Jul 2026 01:08:17 +0800 Subject: [PATCH 48/58] feat(tui): render inline viewport above terminal scrollback --- .../transport/tui/InlineTerminalRenderer.java | 345 +++++++++++++++++ .../cn/lypi/transport/tui/TuiRenderBatch.java | 11 + .../tui/InlineTerminalRendererTest.java | 355 ++++++++++++++++++ 3 files changed, 711 insertions(+) create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderBatch.java create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java new file mode 100644 index 00000000..7ae4e9dc --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java @@ -0,0 +1,345 @@ +package cn.lypi.transport.tui; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +final class InlineTerminalRenderer { + private static final String SYNC_START = "\033[?2026h"; + private static final String SYNC_END = "\033[?2026l"; + private static final String RESET_SCROLL_REGION = "\033[r"; + private static final String CLEAR_LINE = "\033[2K"; + private static final String REVERSE_INDEX = "\033M"; + private static final TerminalLine EMPTY_LINE = new TerminalLine(""); + + private final TerminalIo io; + private InlineViewport viewport; + private InlineViewport renderedViewport; + private List previousSurface = List.of(); + private Optional previousCursor = Optional.empty(); + private boolean geometryDirty; + private boolean finished; + + InlineTerminalRenderer(TerminalIo io, InlineViewport viewport) { + this.io = java.util.Objects.requireNonNull(io, "io"); + this.viewport = java.util.Objects.requireNonNull(viewport, "viewport"); + } + + void render(TuiRenderBatch batch) throws IOException { + if (finished) { + throw new IllegalStateException("inline terminal renderer is finished"); + } + SurfaceFrame surface = stripCursor(batch.surface()); + if (surface.lines().isEmpty()) { + throw new IllegalArgumentException("mutable surface must contain at least one line"); + } + InlineViewport nextViewport = viewport.withSurfaceHeight(surface.lines().size()); + List history = prepareHistory(batch.historyLines(), nextViewport.width()); + + if (history.isEmpty() + && renderedViewport != null + && !geometryDirty + && sameGeometry(renderedViewport, nextViewport) + && previousSurface.equals(surface.lines())) { + if (!previousCursor.equals(surface.cursor())) { + moveCursor(surface.cursor(), nextViewport); + io.flush(); + } + viewport = nextViewport; + renderedViewport = nextViewport; + previousCursor = surface.cursor(); + return; + } + + InlineViewport finalViewport = writeRenderTransaction( + history, + surface, + nextViewport + ); + + viewport = finalViewport; + renderedViewport = finalViewport; + previousSurface = surface.lines(); + previousCursor = surface.cursor(); + geometryDirty = false; + } + + void resize(int width, int height) { + viewport = viewport.resize(width, height); + geometryDirty = true; + } + + void finish() throws IOException { + if (finished) { + return; + } + finished = true; + InlineViewport current = renderedViewport == null ? viewport : renderedViewport; + io.write(SYNC_START); + IOException failure = null; + try { + io.write(RESET_SCROLL_REGION); + clearRows(current.top(), current.surfaceHeight(), current.terminalHeight()); + moveTo(current.top() + 1, 1); + } catch (IOException exception) { + failure = exception; + } + try { + io.write(SYNC_END); + } catch (IOException exception) { + failure = combine(failure, exception); + } + try { + io.flush(); + } catch (IOException exception) { + failure = combine(failure, exception); + } + previousSurface = List.of(); + previousCursor = Optional.empty(); + if (failure != null) { + throw failure; + } + } + + private InlineViewport writeRenderTransaction( + List history, + SurfaceFrame surface, + InlineViewport nextViewport + ) throws IOException { + io.write(SYNC_START); + InlineViewport finalViewport = nextViewport; + IOException failure = null; + try { + boolean geometryChanged = renderedViewport == null + || geometryDirty + || !sameGeometry(renderedViewport, nextViewport); + if (geometryChanged && renderedViewport != null) { + scrollCommittedRowsForUpwardViewport(renderedViewport, nextViewport); + clearSurfaceUnion(renderedViewport, nextViewport); + } + finalViewport = insertHistory(history, nextViewport); + if (geometryChanged || renderedViewport == null || !sameGeometry(nextViewport, finalViewport)) { + drawFullSurface(surface.lines(), finalViewport); + } else { + drawSurfaceDiff(surface.lines(), finalViewport); + } + moveCursor(surface.cursor(), finalViewport); + } catch (IOException exception) { + failure = exception; + } + try { + io.write(SYNC_END); + } catch (IOException exception) { + failure = combine(failure, exception); + } + try { + io.flush(); + } catch (IOException exception) { + failure = combine(failure, exception); + } + if (failure != null) { + throw failure; + } + return finalViewport; + } + + private void scrollCommittedRowsForUpwardViewport( + InlineViewport previous, + InlineViewport next + ) throws IOException { + if (next.top() >= previous.top()) { + return; + } + int regionBottom = Math.min(previous.top(), next.terminalHeight()); + if (regionBottom < 1) { + return; + } + setScrollRegion(1, regionBottom); + moveTo(regionBottom, 1); + for (int index = 0; index < previous.top() - next.top(); index++) { + io.write("\r\n"); + } + io.write(RESET_SCROLL_REGION); + } + + private InlineViewport insertHistory(List history, InlineViewport current) throws IOException { + if (history.isEmpty()) { + return current; + } + int spaceBelow = current.terminalHeight() - current.top() - current.surfaceHeight(); + int scrollAmount = Math.min(history.size(), Math.max(0, spaceBelow)); + int prospectiveTop = current.top() + scrollAmount; + if (prospectiveTop < 2) { + return insertHistoryLinearly(history, current); + } + + int cursorTop = Math.max(0, current.top() - 1); + InlineViewport shifted = current; + if (scrollAmount > 0) { + setScrollRegion(current.top() + 1, current.terminalHeight()); + moveTo(current.top() + 1, 1); + for (int index = 0; index < scrollAmount; index++) { + io.write(REVERSE_INDEX); + } + io.write(RESET_SCROLL_REGION); + shifted = new InlineViewport( + prospectiveTop, + current.surfaceHeight(), + current.width(), + current.terminalHeight() + ); + } + + setScrollRegion(1, shifted.top()); + moveTo(cursorTop + 1, 1); + for (TerminalLine line : history) { + io.write("\r\n"); + writeLine(line, shifted.width()); + } + io.write(RESET_SCROLL_REGION); + return shifted; + } + + private InlineViewport insertHistoryLinearly(List history, InlineViewport current) throws IOException { + clearRows(current.top(), current.surfaceHeight(), current.terminalHeight()); + moveTo(current.top() + 1, 1); + for (int index = 0; index < history.size(); index++) { + if (index > 0) { + io.write("\r\n"); + } + io.write(CLEAR_LINE); + writeLine(history.get(index), current.width()); + } + for (int index = 0; index < current.surfaceHeight(); index++) { + io.write("\r\n"); + io.write(CLEAR_LINE); + } + int nextTop = Math.min( + current.terminalHeight() - current.surfaceHeight(), + current.top() + history.size() + ); + return new InlineViewport(nextTop, current.surfaceHeight(), current.width(), current.terminalHeight()); + } + + private void drawFullSurface(List lines, InlineViewport current) throws IOException { + for (int row = 0; row < lines.size(); row++) { + moveTo(current.top() + row + 1, 1); + io.write(CLEAR_LINE); + writeLine(lines.get(row), current.width()); + } + } + + private void drawSurfaceDiff(List lines, InlineViewport current) throws IOException { + int rows = Math.max(previousSurface.size(), lines.size()); + for (int row = 0; row < rows; row++) { + TerminalLine previous = lineAt(previousSurface, row); + TerminalLine next = lineAt(lines, row); + if (previous.equals(next)) { + continue; + } + moveTo(current.top() + row + 1, 1); + io.write(CLEAR_LINE); + if (row < lines.size()) { + writeLine(next, current.width()); + } + } + } + + private void clearSurfaceUnion(InlineViewport previous, InlineViewport next) throws IOException { + int top = Math.min(previous.top(), next.top()); + int bottom = Math.max( + previous.top() + previous.surfaceHeight(), + next.top() + next.surfaceHeight() + ); + clearRows(top, bottom - top, next.terminalHeight()); + } + + private void clearRows(int top, int height, int terminalHeight) throws IOException { + int first = Math.max(0, top); + int end = Math.min(terminalHeight, top + height); + for (int row = first; row < end; row++) { + moveTo(row + 1, 1); + io.write(CLEAR_LINE); + } + } + + private SurfaceFrame stripCursor(TuiRenderFrame frame) { + List lines = new ArrayList<>(frame.terminalLines().size()); + SurfaceCursor cursor = null; + int width = viewport.width(); + for (int row = 0; row < frame.terminalLines().size(); row++) { + String text = frame.terminalLines().get(row).text(); + int marker = text.indexOf(TuiRenderFrame.CURSOR_MARKER); + if (marker < 0) { + lines.add(new TerminalLine(AnsiWidth.truncate(text, width))); + continue; + } + String before = text.substring(0, marker); + String after = text.substring(marker + TuiRenderFrame.CURSOR_MARKER.length()); + lines.add(new TerminalLine(AnsiWidth.truncate(before + after, width))); + int column = Math.min(Math.max(0, width - 1), AnsiWidth.displayWidth(before)); + cursor = new SurfaceCursor(row, column); + } + return new SurfaceFrame(List.copyOf(lines), Optional.ofNullable(cursor)); + } + + private List prepareHistory(List history, int width) { + List lines = new ArrayList<>(history.size()); + for (TerminalLine line : history) { + if (line.text().contains(TuiRenderFrame.CURSOR_MARKER)) { + throw new IllegalArgumentException("history line must not contain cursor marker"); + } + lines.add(new TerminalLine(AnsiWidth.truncate(line.text(), width))); + } + return List.copyOf(lines); + } + + private void moveCursor(Optional cursor, InlineViewport current) throws IOException { + if (cursor.isEmpty()) { + return; + } + SurfaceCursor position = cursor.orElseThrow(); + moveTo(current.top() + position.row() + 1, position.column() + 1); + } + + private void writeLine(TerminalLine line, int width) throws IOException { + io.write(AnsiWidth.truncate(line.text(), width)); + } + + private void setScrollRegion(int top, int bottom) throws IOException { + if (top < 1 || bottom < top) { + throw new IllegalArgumentException("invalid scroll region " + top + ";" + bottom); + } + io.write("\033[" + top + ";" + bottom + "r"); + } + + private void moveTo(int row, int column) throws IOException { + io.write("\033[" + row + ";" + column + "H"); + } + + private TerminalLine lineAt(List lines, int row) { + return row < lines.size() ? lines.get(row) : EMPTY_LINE; + } + + private boolean sameGeometry(InlineViewport left, InlineViewport right) { + return left.top() == right.top() + && left.surfaceHeight() == right.surfaceHeight() + && left.width() == right.width() + && left.terminalHeight() == right.terminalHeight(); + } + + private IOException combine(IOException first, IOException next) { + if (first == null) { + return next; + } + first.addSuppressed(next); + return first; + } + + private record SurfaceFrame(List lines, Optional cursor) { + } + + private record SurfaceCursor(int row, int column) { + } +} diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderBatch.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderBatch.java new file mode 100644 index 00000000..c580d4c6 --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderBatch.java @@ -0,0 +1,11 @@ +package cn.lypi.transport.tui; + +import java.util.List; +import java.util.Objects; + +record TuiRenderBatch(List historyLines, TuiRenderFrame surface) { + TuiRenderBatch { + historyLines = List.copyOf(historyLines); + surface = Objects.requireNonNull(surface, "surface"); + } +} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java new file mode 100644 index 00000000..719896f6 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java @@ -0,0 +1,355 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.Test; + +class InlineTerminalRendererTest { + private static final String SYNC_START = "\033[?2026h"; + private static final String SYNC_END = "\033[?2026l"; + + @Test + void firstBatchCommitsHistoryAndSurfaceInOneSynchronizedFlush() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(80, 12); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(4, 2, 80, 12) + ); + + renderer.render(new TuiRenderBatch( + List.of(new TerminalLine("final answer")), + TuiRenderFrame.fromTextLines(List.of("> draft|CURSOR|", "status")) + )); + + String output = io.output.toString(); + assertTrue(output.startsWith(SYNC_START)); + assertTrue(output.endsWith(SYNC_END)); + assertEquals(1, occurrences(output, "final answer")); + assertEquals(1, io.flushCount); + assertFalse(output.contains("\033[2J")); + assertFalse(output.contains("\033[3J")); + assertFalse(output.contains("\033[?1049")); + } + + @Test + void cursorOnlyChangeMovesHardwareCursorWithoutRewritingSurface() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(80, 12); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(4, 2, 80, 12) + ); + renderer.render(new TuiRenderBatch( + List.of(new TerminalLine("final answer")), + TuiRenderFrame.fromTextLines(List.of("status", "> a|CURSOR|bc")) + )); + io.resetOutput(); + + renderer.render(new TuiRenderBatch( + List.of(), + TuiRenderFrame.fromTextLines(List.of("status", "> ab|CURSOR|c")) + )); + + assertEquals("\033[7;5H", io.output.toString()); + assertFalse(io.output.toString().contains("final answer")); + assertEquals(1, io.flushCount); + } + + @Test + void growingSurfaceScrollsOnlyCommittedRowsAboveOldSurface() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(80, 12); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(8, 3, 80, 12) + ); + renderer.render(new TuiRenderBatch( + List.of(), + TuiRenderFrame.fromTextLines(List.of("live", "> draft|CURSOR|", "status")) + )); + io.resetOutput(); + + renderer.render(new TuiRenderBatch( + List.of(), + TuiRenderFrame.fromTextLines(List.of( + "live-1", "live-2", "live-3", "live-4", "> draft|CURSOR|", "status" + )) + )); + + String output = io.output.toString(); + assertTrue(output.contains("\033[1;8r\033[8;1H\r\n\r\n\033[r")); + assertFalse(output.contains("\033[1;9r")); + } + + @Test + void shorterSurfaceClearsOldTailWithoutNaturalScrolling() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(80, 12); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(5, 3, 80, 12) + ); + renderer.render(new TuiRenderBatch( + List.of(), + TuiRenderFrame.fromTextLines(List.of("live", "> draft|CURSOR|", "status-old")) + )); + io.resetOutput(); + + renderer.render(new TuiRenderBatch( + List.of(), + TuiRenderFrame.fromTextLines(List.of("> draft|CURSOR|", "status-new")) + )); + + String output = io.output.toString(); + assertTrue(output.contains("\033[8;1H\033[2K")); + assertFalse(output.contains("\r\n")); + assertFalse(output.contains("status-old")); + } + + @Test + void topConstrainedViewportUsesLinearFallbackAndReservesSurfaceRows() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(20, 5); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(0, 2, 20, 5) + ); + renderer.render(new TuiRenderBatch( + List.of(), + TuiRenderFrame.fromTextLines(List.of("> old-input|CURSOR|", "old-status")) + )); + io.resetOutput(); + + renderer.render(new TuiRenderBatch( + List.of(new TerminalLine("history-1")), + TuiRenderFrame.fromTextLines(List.of("> new-input|CURSOR|", "new-status")) + )); + + String output = io.output.toString(); + assertFalse(output.contains("\033[1;1r")); + assertFalse(output.contains("old-input")); + assertFalse(output.contains("old-status")); + assertEquals(1, occurrences(output, "history-1")); + String afterHistory = output.substring(output.indexOf("history-1") + "history-1".length()); + assertTrue(occurrences(afterHistory, "\r\n\033[2K") >= 2); + assertTrue(output.contains("\033[2;1H\033[2K> new-input")); + assertTrue(output.contains("\033[3;1H\033[2Knew-status")); + } + + @Test + void standardHistoryScrollRegionEndsAboveShiftedSurface() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(80, 8); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(4, 2, 80, 8) + ); + + renderer.render(new TuiRenderBatch( + List.of(new TerminalLine("history-1"), new TerminalLine("history-2")), + TuiRenderFrame.fromTextLines(List.of("> draft|CURSOR|", "status")) + )); + + String output = io.output.toString(); + assertTrue(output.contains("\033[1;6r")); + assertFalse(output.contains("\033[1;7r")); + assertTrue(output.contains("\033[7;1H\033[2K> draft")); + assertTrue(output.contains("\033[8;1H\033[2Kstatus")); + } + + @Test + void fullWidthHistoryLineUsesOneLeadingCrLfWithoutExtraBlankLine() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(10, 4); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(2, 2, 10, 4) + ); + + renderer.render(new TuiRenderBatch( + List.of(new TerminalLine("1234567890")), + TuiRenderFrame.fromTextLines(List.of("> |CURSOR|", "status")) + )); + + String output = io.output.toString(); + assertEquals(1, occurrences(output, "\r\n")); + assertTrue(output.contains("\r\n1234567890")); + assertFalse(output.contains("123456789…")); + } + + @Test + void finishClearsSurfaceResetsRegionAndIsIdempotent() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(80, 12); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(4, 2, 80, 12) + ); + renderer.render(new TuiRenderBatch( + List.of(), + TuiRenderFrame.fromTextLines(List.of("> draft|CURSOR|", "status")) + )); + io.resetOutput(); + + renderer.finish(); + renderer.finish(); + + String output = io.output.toString(); + assertTrue(output.startsWith(SYNC_START + "\033[r")); + assertTrue(output.contains("\033[5;1H\033[2K")); + assertTrue(output.contains("\033[6;1H\033[2K")); + assertTrue(output.endsWith("\033[5;1H" + SYNC_END)); + assertEquals(1, io.flushCount); + } + + @Test + void resizeRoundTripMovesViewportWithoutRewritingHistory() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(80, 8); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(5, 3, 80, 8) + ); + TuiRenderFrame surface = TuiRenderFrame.fromTextLines(List.of( + "live", "> draft|CURSOR|", "status" + )); + renderer.render(new TuiRenderBatch(List.of(new TerminalLine("committed")), surface)); + io.resetOutput(); + + io.setDimensions(60, 6); + renderer.resize(60, 6); + renderer.render(new TuiRenderBatch(List.of(), surface)); + + String shrinkOutput = io.output.toString(); + assertTrue(shrinkOutput.contains("\033[1;5r\033[5;1H\r\n\r\n\033[r")); + assertFalse(shrinkOutput.contains("committed")); + assertFalse(shrinkOutput.contains("\033[2J")); + assertFalse(shrinkOutput.contains("\033[3J")); + + io.resetOutput(); + io.setDimensions(80, 8); + renderer.resize(80, 8); + renderer.render(new TuiRenderBatch(List.of(), surface)); + + String growOutput = io.output.toString(); + assertFalse(growOutput.contains("committed")); + assertFalse(growOutput.contains("\r\n")); + assertTrue(growOutput.contains("\033[6;1H\033[2Klive")); + assertTrue(growOutput.contains("\033[8;1H\033[2Kstatus")); + } + + @Test + void rejectsCursorMarkerInHistoryBeforeWritingTerminalOutput() { + RecordingTerminalIo io = new RecordingTerminalIo(80, 8); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(4, 2, 80, 8) + ); + + assertThrows(IllegalArgumentException.class, () -> renderer.render(new TuiRenderBatch( + List.of(new TerminalLine("invalid |CURSOR| history")), + TuiRenderFrame.fromTextLines(List.of("> draft|CURSOR|", "status")) + ))); + + assertEquals("", io.output.toString()); + assertEquals(0, io.flushCount); + } + + @Test + void finishEndsSynchronizedUpdateAndFlushesWhenSurfaceClearFails() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(80, 8); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(4, 2, 80, 8) + ); + renderer.render(new TuiRenderBatch( + List.of(), + TuiRenderFrame.fromTextLines(List.of("> draft|CURSOR|", "status")) + )); + io.resetOutput(); + io.failNextWriteOf("\033[2K"); + + assertThrows(IOException.class, renderer::finish); + renderer.finish(); + + assertTrue(io.output.toString().startsWith(SYNC_START + "\033[r")); + assertTrue(io.output.toString().endsWith(SYNC_END)); + assertEquals(1, io.flushCount); + } + + private int occurrences(String value, String needle) { + int count = 0; + int from = 0; + while ((from = value.indexOf(needle, from)) >= 0) { + count++; + from += needle.length(); + } + return count; + } + + private static final class RecordingTerminalIo implements TerminalIo { + private final StringBuilder output = new StringBuilder(); + private int width; + private int height; + private int flushCount; + private String failingValue; + + private RecordingTerminalIo(int width, int height) { + this.width = width; + this.height = height; + } + + @Override + public AutoCloseable enterRawMode() { + return () -> { + }; + } + + @Override + public void write(String value) throws IOException { + if (value.equals(failingValue)) { + failingValue = null; + throw new IOException("write failed"); + } + output.append(value); + } + + @Override + public void flush() { + flushCount++; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public AutoCloseable onResize(Runnable callback) { + return () -> { + }; + } + + @Override + public AutoCloseable onInterrupt(Runnable callback) { + return () -> { + }; + } + + private void resetOutput() { + output.setLength(0); + flushCount = 0; + } + + private void setDimensions(int width, int height) { + this.width = width; + this.height = height; + } + + private void failNextWriteOf(String value) { + failingValue = value; + } + } +} From 12874366cd4b223a25a1814f0d389074dec38751 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Thu, 16 Jul 2026 01:56:13 +0800 Subject: [PATCH 49/58] refactor(tui): coordinate transcript commits and inline surface --- .../java/cn/lypi/transport/tui/FrameSink.java | 13 +- .../transport/tui/InlineTerminalRenderer.java | 15 +- .../lypi/transport/tui/JLineTuiTransport.java | 367 +++++++++++---- .../transport/tui/TerminalFrameRenderer.java | 154 ------- .../lypi/transport/tui/TuiContentLayout.java | 47 -- .../cn/lypi/transport/tui/TuiInputLoop.java | 118 +---- .../java/cn/lypi/transport/tui/TuiLayout.java | 45 -- .../cn/lypi/transport/tui/TuiRenderer.java | 148 +----- .../java/cn/lypi/transport/tui/TuiScreen.java | 99 ---- .../tui/InlineTerminalRendererTest.java | 18 + .../JLineTuiTransportRenderPipelineTest.java | 270 +++++------ .../transport/tui/JLineTuiTransportTest.java | 228 +++++++++- .../tui/TerminalFrameRendererTest.java | 185 -------- .../transport/tui/TerminalInputPumpTest.java | 36 +- .../transport/tui/TuiContentLayoutTest.java | 56 --- .../tui/TuiContractEndToEndTest.java | 12 +- .../lypi/transport/tui/TuiFramePtyProbe.java | 55 ++- .../lypi/transport/tui/TuiInputLoopTest.java | 307 ++++++++----- .../cn/lypi/transport/tui/TuiLayoutTest.java | 19 +- .../lypi/transport/tui/TuiRendererTest.java | 430 ++++++++---------- .../cn/lypi/transport/tui/TuiScreenTest.java | 100 ---- 21 files changed, 1141 insertions(+), 1581 deletions(-) delete mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java delete mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiContentLayout.java delete mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java delete mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalFrameRendererTest.java delete mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContentLayoutTest.java delete mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/FrameSink.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/FrameSink.java index f15128e2..0dff16ed 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/FrameSink.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/FrameSink.java @@ -1,18 +1,9 @@ package cn.lypi.transport.tui; -import java.util.List; - @FunctionalInterface interface FrameSink { /** - * 渲染一帧终端行。 - */ - void render(List lines); - - /** - * 渲染已经过物理行校验的完整帧。 + * 提交一次 history + mutable surface 终端事务。 */ - default void render(TuiRenderFrame frame) { - render(frame.lines()); - } + void render(TuiRenderBatch batch); } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java index 7ae4e9dc..d2f447e9 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java @@ -118,8 +118,12 @@ private InlineViewport writeRenderTransaction( scrollCommittedRowsForUpwardViewport(renderedViewport, nextViewport); clearSurfaceUnion(renderedViewport, nextViewport); } + boolean linearHistoryInsertion = requiresLinearHistoryInsertion(history, nextViewport); finalViewport = insertHistory(history, nextViewport); - if (geometryChanged || renderedViewport == null || !sameGeometry(nextViewport, finalViewport)) { + if (geometryChanged + || renderedViewport == null + || linearHistoryInsertion + || !sameGeometry(nextViewport, finalViewport)) { drawFullSurface(surface.lines(), finalViewport); } else { drawSurfaceDiff(surface.lines(), finalViewport); @@ -201,6 +205,15 @@ private InlineViewport insertHistory(List history, InlineViewport return shifted; } + private boolean requiresLinearHistoryInsertion(List history, InlineViewport current) { + if (history.isEmpty()) { + return false; + } + int spaceBelow = current.terminalHeight() - current.top() - current.surfaceHeight(); + int scrollAmount = Math.min(history.size(), Math.max(0, spaceBelow)); + return current.top() + scrollAmount < 2; + } + private InlineViewport insertHistoryLinearly(List history, InlineViewport current) throws IOException { clearRows(current.top(), current.surfaceHeight(), current.terminalHeight()); moveTo(current.top() + 1, 1); diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java index 70b1f4ef..c5a40be8 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java @@ -17,14 +17,16 @@ import cn.lypi.contracts.tui.ResumeSessionController; import cn.lypi.contracts.tui.SessionRuntimeState; import cn.lypi.contracts.tui.SlashCommand; +import cn.lypi.contracts.tui.TuiBlock; import cn.lypi.contracts.tui.TuiToolBlock; import cn.lypi.contracts.tui.TuiViewModel; import java.io.IOException; import java.io.UncheckedIOException; import java.time.Clock; +import java.time.Duration; import java.util.List; -import java.util.Objects; import java.util.Optional; +import java.util.function.Consumer; import java.util.function.LongSupplier; import java.util.function.Supplier; import org.jline.terminal.Terminal; @@ -35,19 +37,22 @@ public final class JLineTuiTransport implements TuiTransport, AutoCloseable { private static final int MAX_INPUT_CHUNKS_PER_DRAIN = 32; private static final int MAX_DIFF_PATCH_BYTES = 64 * 1024; private static final long RUNTIME_TICK_INTERVAL_MILLIS = 1_000L; + private static final Duration CURSOR_PROBE_TIMEOUT = Duration.ofMillis(100); private static final DiffViewProvider NOOP_DIFF_VIEW_PROVIDER = (cwd, maxPatchBytes) -> Optional.empty(); private final Object uiMonitor = new Object(); private final Runnable renderer; private final TuiEventReducer reducer; private final TuiRenderer tuiRenderer; - private TuiScreen screen; + private final TuiTranscriptPartitioner transcriptPartitioner; + private final TuiTranscriptCommitLedger commitLedger; private TuiLayout layout; private final FrameSink frameSink; private final TerminalInputPump inputPump; private final TuiInputLoop inputLoop; private final TerminalSession terminalSession; private final TerminalIo terminalIo; + private final InlineTerminalRenderer inlineTerminalRenderer; private final DiffViewProvider diffViewProvider; private final Clock clock; private final TuiRuntimeTicker runtimeTicker; @@ -63,13 +68,15 @@ public JLineTuiTransport(Runnable renderer) { this.renderer = renderer; this.reducer = null; this.tuiRenderer = null; - this.screen = null; + this.transcriptPartitioner = null; + this.commitLedger = null; this.layout = null; this.frameSink = null; this.inputPump = null; this.inputLoop = null; this.terminalSession = null; this.terminalIo = null; + this.inlineTerminalRenderer = null; this.diffViewProvider = NOOP_DIFF_VIEW_PROVIDER; this.clock = Clock.systemUTC(); this.runtimeTicker = new TuiRuntimeTicker(RUNTIME_TICK_INTERVAL_MILLIS, MAX_DIFF_PATCH_BYTES); @@ -95,15 +102,17 @@ private JLineTuiTransport( this.renderer = null; this.reducer = new TuiEventReducer(); this.tuiRenderer = new TuiRenderer(); + this.transcriptPartitioner = new TuiTranscriptPartitioner(); + this.commitLedger = new TuiTranscriptCommitLedger(); int safeWidth = safeWidth(width); int safeHeight = safeHeight(height); - this.screen = new TuiScreen(Math.max(1, safeHeight - 2)); this.layout = new TuiLayout(safeWidth, safeHeight); this.frameSink = frameSink; this.inputPump = null; this.inputLoop = null; this.terminalSession = terminalSession; this.terminalIo = null; + this.inlineTerminalRenderer = null; this.diffViewProvider = NOOP_DIFF_VIEW_PROVIDER; this.clock = Clock.systemUTC(); this.runtimeTicker = new TuiRuntimeTicker(RUNTIME_TICK_INTERVAL_MILLIS, MAX_DIFF_PATCH_BYTES); @@ -120,6 +129,7 @@ private JLineTuiTransport( TuiSubmitHandler submitHandler, TerminalSession terminalSession, TerminalIo terminalIo, + InlineTerminalRenderer inlineTerminalRenderer, Supplier slashPickerSupplier, DiffViewProvider diffViewProvider, ResumeSessionController resumeController, @@ -134,6 +144,7 @@ private JLineTuiTransport( submitHandler, terminalSession, terminalIo, + inlineTerminalRenderer, slashPickerSupplier, diffViewProvider, resumeController, @@ -151,6 +162,7 @@ private JLineTuiTransport( TuiSubmitHandler submitHandler, TerminalSession terminalSession, TerminalIo terminalIo, + InlineTerminalRenderer inlineTerminalRenderer, Supplier slashPickerSupplier, DiffViewProvider diffViewProvider, ResumeSessionController resumeController, @@ -161,27 +173,26 @@ private JLineTuiTransport( this.redrawScheduler = new TuiRedrawScheduler(); this.reducer = TuiEventReducer.fromRuntimeState(state); this.tuiRenderer = new TuiRenderer(); + this.transcriptPartitioner = new TuiTranscriptPartitioner(); + this.commitLedger = new TuiTranscriptCommitLedger(); int safeWidth = safeWidth(width); int safeHeight = safeHeight(height); - this.screen = new TuiScreen(Math.max(1, safeHeight - 2)); this.layout = new TuiLayout(safeWidth, safeHeight); this.frameSink = frameSink; this.inputLoop = new TuiInputLoop( submitHandler, - frameSink, - tuiRenderer, - screen, + this::renderImmediateFrame, layout, reducer::view, slashPickerSupplier, resumeController, this::resumeRuntimeState, - skillIndexSupplier, - this::renderImmediateFrame + skillIndexSupplier ); this.inputPump = new TerminalInputPump(inputSource, new KeyMapper(), inputLoop); this.terminalSession = terminalSession; this.terminalIo = terminalIo; + this.inlineTerminalRenderer = inlineTerminalRenderer; this.diffViewProvider = diffViewProvider == null ? NOOP_DIFF_VIEW_PROVIDER : diffViewProvider; this.clock = clock == null ? Clock.systemUTC() : clock; this.runtimeTicker = new TuiRuntimeTicker(RUNTIME_TICK_INTERVAL_MILLIS, MAX_DIFF_PATCH_BYTES); @@ -239,19 +250,22 @@ public static JLineTuiTransport open( ResumeSessionController resumeController, List slashCommands ) throws IOException { - JLineTerminalIo io = new JLineTerminalIo(terminal); - return open( - state, + RuntimeTuiSubmitHandler submitHandler = new RuntimeTuiSubmitHandler( + state.sessionId(), core, events, - io, - new JLineTerminalInputSource(terminal), command -> Thread.ofVirtual().name("lypi-tui-turn-", 0).start(command), - slashCommands, + slashCommands + ); + return openTerminal( + state, + events, + terminal, + submitHandler, + null, diffViewProvider, resumeController, - terminal.getWidth(), - terminal.getHeight() + null ); } @@ -411,23 +425,40 @@ public static JLineTuiTransport open( ResourceRuntimePort resourceRuntime, CompactionRuntimePort compactionRuntime ) throws IOException { - JLineTerminalIo io = new JLineTerminalIo(terminal); - return open( - state, - core, - events, - io, - new JLineTerminalInputSource(terminal), - slashCommands, + SlashCommandRouter router = new SlashCommandRouter( + state.sessionId(), + state.cwd(), sessionManager, resourceRuntime, compactionRuntime, + newSessionController, + slashCommands + ); + JLineTuiTransport[] holder = new JLineTuiTransport[1]; + RuntimeTuiSubmitHandler submitHandler = new RuntimeTuiSubmitHandler( + state.sessionId(), + core, + events, + command -> Thread.ofVirtual().name("lypi-tui-turn-", 0).start(command), + router, + runtimeState -> { + if (holder[0] != null) { + holder[0].resumeRuntimeState(runtimeState); + } + } + ); + JLineTuiTransport transport = openTerminal( + state, + events, + terminal, + submitHandler, + () -> new SlashCommandPicker(router.commandNames()), diffViewProvider, resumeController, - newSessionController, - terminal.getWidth(), - terminal.getHeight() + () -> resourceRuntime.load(state.cwd()).skillIndex() ); + holder[0] = transport; + return transport; } public static JLineTuiTransport open( @@ -573,19 +604,27 @@ static JLineTuiTransport open( return open(state, core, events, io, inputSource, List.of(), sessionManager, resourceRuntime, null, width, height); } - static JLineTuiTransport withRenderer(FrameSink frameSink, int width, int height) { + static JLineTuiTransport withBatchRenderer(FrameSink frameSink, int width, int height) { return new JLineTuiTransport(frameSink, width, height); } static JLineTuiTransport withRenderer( - FrameSink frameSink, + Consumer> frameConsumer, + int width, + int height + ) { + return withBatchRenderer(legacyFrameSink(frameConsumer), width, height); + } + + static JLineTuiTransport withRenderer( + Consumer> frameConsumer, int width, int height, LongSupplier nanoTime, long frameIntervalNanos ) { return new JLineTuiTransport( - frameSink, + legacyFrameSink(frameConsumer), width, height, null, @@ -594,6 +633,22 @@ static JLineTuiTransport withRenderer( } static JLineTuiTransport withInput( + Consumer> frameConsumer, + int width, + int height, + TerminalInputSource inputSource, + TuiSubmitHandler submitHandler + ) { + return withBatchInput( + legacyFrameSink(frameConsumer), + width, + height, + inputSource, + submitHandler + ); + } + + static JLineTuiTransport withBatchInput( FrameSink frameSink, int width, int height, @@ -610,6 +665,7 @@ static JLineTuiTransport withInput( null, null, null, + null, NOOP_DIFF_VIEW_PROVIDER, null, null @@ -617,6 +673,24 @@ static JLineTuiTransport withInput( } static JLineTuiTransport withInput( + Consumer> frameConsumer, + int width, + int height, + TerminalInputSource inputSource, + TuiSubmitHandler submitHandler, + Clock clock + ) { + return withBatchInput( + legacyFrameSink(frameConsumer), + width, + height, + inputSource, + submitHandler, + clock + ); + } + + static JLineTuiTransport withBatchInput( FrameSink frameSink, int width, int height, @@ -634,6 +708,7 @@ static JLineTuiTransport withInput( null, null, null, + null, NOOP_DIFF_VIEW_PROVIDER, null, null, @@ -641,6 +716,85 @@ static JLineTuiTransport withInput( ); } + private static FrameSink legacyFrameSink(Consumer> frameConsumer) { + return batch -> { + List lines = new java.util.ArrayList<>( + batch.historyLines().size() + batch.surface().lines().size() + ); + batch.historyLines().stream().map(TerminalLine::text).forEach(lines::add); + lines.addAll(batch.surface().lines()); + frameConsumer.accept(List.copyOf(lines)); + }; + } + + private static JLineTuiTransport openTerminal( + SessionRuntimeState state, + EventBus events, + Terminal terminal, + TuiSubmitHandler submitHandler, + Supplier slashPickerSupplier, + DiffViewProvider diffViewProvider, + ResumeSessionController resumeController, + Supplier skillIndexSupplier + ) throws IOException { + JLineTerminalIo io = new JLineTerminalIo(terminal); + JLineTuiTransport[] holder = new JLineTuiTransport[1]; + TerminalSession session = null; + InlineTerminalRenderer terminalRenderer = null; + JLineTuiTransport transport = null; + try { + session = TerminalSession.open(io, () -> { + if (holder[0] != null) { + holder[0].resize(io.width(), io.height()); + } + }, () -> { + if (holder[0] != null) { + holder[0].handleInterruptSignal(); + } + }); + CursorProbeResult probe = TerminalCursorProbe.query(terminal, CURSOR_PROBE_TIMEOUT); + int initialWidth = safeWidth(io.width()); + int initialHeight = safeHeight(io.height()); + InlineViewport viewport; + if (probe.position().isPresent()) { + viewport = InlineViewport.at(probe.position().orElseThrow(), initialWidth, initialHeight); + } else { + io.write("\r\n"); + io.flush(); + viewport = initialViewport(initialWidth, initialHeight); + } + TerminalInputSource inputSource = new JLineTerminalInputSource(terminal, probe.replayInput()); + InlineTerminalRenderer nextRenderer = new InlineTerminalRenderer(io, viewport); + terminalRenderer = nextRenderer; + transport = new JLineTuiTransport( + terminalFrameSink(nextRenderer), + initialWidth, + initialHeight, + state, + inputSource, + submitHandler, + session, + io, + nextRenderer, + slashPickerSupplier, + diffViewProvider, + resumeController, + skillIndexSupplier + ); + holder[0] = transport; + transport.attach(events, state); + transport.renderCurrentFrameUnderUiLock(); + return transport; + } catch (IOException | RuntimeException exception) { + if (transport != null) { + closeAfterOpenFailure(transport, exception); + } else { + closeAfterOpenFailure(terminalRenderer, session, exception); + } + throw exception; + } + } + static JLineTuiTransport open( SessionRuntimeState state, EventBus events, @@ -716,49 +870,55 @@ static JLineTuiTransport open( holder[0].handleInterruptSignal(); } }); - TerminalFrameRenderer frameRenderer = new TerminalFrameRenderer(io); - FrameSink frameSink = new FrameSink() { - @Override - public void render(List lines) { - render(TuiRenderFrame.fromTextLines(lines)); - } - - @Override - public void render(TuiRenderFrame frame) { - try { - frameRenderer.render(frame); - } catch (IOException exception) { - throw new UncheckedIOException(exception); - } - } - }; - int initialWidth = safeWidth(width > 0 ? width : io.width()); - int initialHeight = safeHeight(height > 1 ? height : io.height()); - JLineTuiTransport transport = new JLineTuiTransport( - frameSink, - initialWidth, - initialHeight, - state, - inputSource, - submitHandler, - session, - io, - slashPickerSupplier, - diffViewProvider, - resumeController, - skillIndexSupplier - ); - holder[0] = transport; + InlineTerminalRenderer terminalRenderer = null; + JLineTuiTransport transport = null; try { + int initialWidth = safeWidth(width > 0 ? width : io.width()); + int initialHeight = safeHeight(height > 1 ? height : io.height()); + InlineTerminalRenderer nextRenderer = new InlineTerminalRenderer( + io, + initialViewport(initialWidth, initialHeight) + ); + terminalRenderer = nextRenderer; + transport = new JLineTuiTransport( + terminalFrameSink(nextRenderer), + initialWidth, + initialHeight, + state, + inputSource, + submitHandler, + session, + io, + nextRenderer, + slashPickerSupplier, + diffViewProvider, + resumeController, + skillIndexSupplier + ); + holder[0] = transport; transport.attach(events, state); transport.renderCurrentFrameUnderUiLock(); return transport; } catch (RuntimeException exception) { - closeAfterOpenFailure(transport, exception); + if (transport != null) { + closeAfterOpenFailure(transport, exception); + } else { + closeAfterOpenFailure(terminalRenderer, session, exception); + } throw exception; } } + private static FrameSink terminalFrameSink(InlineTerminalRenderer terminalRenderer) { + return batch -> { + try { + terminalRenderer.render(batch); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + }; + } + static JLineTuiTransport open( SessionRuntimeState state, EventBus events, @@ -811,11 +971,6 @@ public void attach(EventBus events, SessionRuntimeState state) { synchronized (uiMonitor) { closeSubscription(); attachedEvents = events; - String previousSessionId = runtimeState == null ? null : runtimeState.sessionId(); - String nextSessionId = state == null ? null : state.sessionId(); - if (screen != null && !Objects.equals(previousSessionId, nextSessionId)) { - screen.reset(); - } runtimeState = state; if (reducer != null) { reducer.configureRuntimeState(state); @@ -952,6 +1107,17 @@ public void close() throws Exception { failure = exception; } } + if (inlineTerminalRenderer != null) { + try { + inlineTerminalRenderer.finish(); + } catch (Exception exception) { + if (failure == null) { + failure = exception; + } else { + failure.addSuppressed(exception); + } + } + } if (terminalSession != null) { try { terminalSession.close(); @@ -982,7 +1148,7 @@ private void closeSubscription() { } } - private static void closeAfterOpenFailure(JLineTuiTransport transport, RuntimeException original) { + private static void closeAfterOpenFailure(JLineTuiTransport transport, Throwable original) { try { transport.close(); } catch (Exception closeFailure) { @@ -990,6 +1156,27 @@ private static void closeAfterOpenFailure(JLineTuiTransport transport, RuntimeEx } } + private static void closeAfterOpenFailure( + InlineTerminalRenderer terminalRenderer, + TerminalSession session, + Throwable original + ) { + if (terminalRenderer != null) { + try { + terminalRenderer.finish(); + } catch (Exception closeFailure) { + original.addSuppressed(closeFailure); + } + } + if (session != null) { + try { + session.close(); + } catch (Exception closeFailure) { + original.addSuppressed(closeFailure); + } + } + } + private void runUiMutation(Runnable mutation) { synchronized (uiMonitor) { uiLockEntries++; @@ -1036,9 +1223,11 @@ private void updateViewport(int width, int height) { int safeWidth = safeWidth(width); int safeHeight = safeHeight(height); layout = new TuiLayout(safeWidth, safeHeight); - screen.updateViewportHeight(Math.max(0, safeHeight - 2)); + if (inlineTerminalRenderer != null) { + inlineTerminalRenderer.resize(safeWidth, safeHeight); + } if (inputLoop != null) { - inputLoop.updateViewport(screen, layout); + inputLoop.updateLayout(layout); } } @@ -1079,11 +1268,20 @@ private void renderCurrentFrame() { reducer.observeRuntimeAt(clock.instant()); TuiViewModel view = reducer.view(); syncInputLoopToolState(view); - if (inputLoop != null) { - inputLoop.renderCurrentFrame(); - return; - } - frameSink.render(tuiRenderer.renderFrame(view, screen, layout, currentDraft(), currentCursor())); + TuiViewModel renderView = inputLoop == null ? view : inputLoop.viewForRender(); + TuiTranscriptPartition partition = transcriptPartitioner.partition(renderView.blocks()); + List newlyCommitted = commitLedger.advance(projectionKey(), partition.history()); + List historyLines = tuiRenderer.renderCommittedBlocks(newlyCommitted, layout.width()); + TuiRenderFrame surface = tuiRenderer.renderSurface( + renderView, + partition.live(), + layout, + currentDraft(), + currentCursor(), + inputLoop == null ? List.of() : inputLoop.overlayLines(), + inputLoop != null && inputLoop.toolOutputExpanded() + ); + frameSink.render(new TuiRenderBatch(historyLines, surface)); } catch (UncheckedIOException exception) { terminalIoFailed = true; throw exception; @@ -1136,6 +1334,13 @@ private int currentCursor() { return inputLoop == null ? -1 : inputLoop.cursor(); } + private TuiProjectionKey projectionKey() { + if (runtimeState == null) { + return new TuiProjectionKey("", ""); + } + return new TuiProjectionKey(runtimeState.sessionId(), runtimeState.currentBranchLeafId()); + } + private void syncInputLoopToolState(TuiViewModel view) { if (inputLoop == null) { return; @@ -1166,4 +1371,8 @@ private static int safeWidth(int width) { private static int safeHeight(int height) { return height > 1 ? height : DEFAULT_TERMINAL_HEIGHT; } + + private static InlineViewport initialViewport(int width, int height) { + return new InlineViewport(Math.max(0, height - 1), 1, width, height); + } } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java deleted file mode 100644 index 8f110ef2..00000000 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java +++ /dev/null @@ -1,154 +0,0 @@ -package cn.lypi.transport.tui; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -final class TerminalFrameRenderer { - private static final String SYNC_START = "\033[?2026h"; - private static final String SYNC_END = "\033[?2026l"; - private static final String FULL_CLEAR = "\033[2J\033[H"; - private static final String CLEAR_LINE = "\033[2K"; - private static final TerminalLine EMPTY_LINE = new TerminalLine(""); - - private final TerminalIo io; - private List previousLines = List.of(); - private int previousWidth; - private int previousHeight; - private boolean rendered; - - TerminalFrameRenderer(TerminalIo io) { - this.io = io; - } - - void render(List lines) throws IOException { - render(TuiRenderFrame.fromTextLines(lines)); - } - - void render(TuiRenderFrame renderFrame) throws IOException { - int width = io.width(); - int height = io.height(); - if (height > 0 && renderFrame.terminalLines().size() > height) { - throw new IllegalArgumentException("frame height exceeds terminal height"); - } - - CursorFrame frame = stripCursor(renderFrame.terminalLines()); - boolean firstFrame = !rendered; - boolean resized = !firstFrame && (previousWidth != width || previousHeight != height); - if (firstFrame || resized) { - writeFullFrame(frame); - } else { - writeDiff(frame); - } - previousLines = List.copyOf(frame.lines()); - previousWidth = width; - previousHeight = height; - rendered = true; - } - - private void writeFullFrame(CursorFrame frame) throws IOException { - io.write(SYNC_START); - io.write(FULL_CLEAR); - for (int row = 0; row < frame.lines().size(); row++) { - moveTo(row + 1, 1); - writeLine(frame.lines().get(row)); - } - moveCursor(frame.cursor()); - io.write(SYNC_END); - io.flush(); - } - - private void writeDiff(CursorFrame frame) throws IOException { - int firstChanged = firstChangedLine(frame.lines()); - int lastChanged = lastChangedLine(frame.lines()); - if (firstChanged >= 0) { - io.write(SYNC_START); - for (int row = firstChanged; row <= lastChanged; row++) { - TerminalLine previous = lineAt(previousLines, row); - TerminalLine current = lineAt(frame.lines(), row); - if (previous.equals(current)) { - continue; - } - moveTo(row + 1, 1); - io.write(CLEAR_LINE); - if (row < frame.lines().size()) { - writeLine(current); - } - } - moveCursor(frame.cursor()); - io.write(SYNC_END); - io.flush(); - return; - } - if (frame.cursor().isPresent()) { - moveCursor(frame.cursor()); - io.flush(); - } - } - - private int firstChangedLine(List lines) { - int maximum = Math.max(previousLines.size(), lines.size()); - for (int row = 0; row < maximum; row++) { - if (!lineAt(previousLines, row).equals(lineAt(lines, row))) { - return row; - } - } - return -1; - } - - private int lastChangedLine(List lines) { - int maximum = Math.max(previousLines.size(), lines.size()); - for (int row = maximum - 1; row >= 0; row--) { - if (!lineAt(previousLines, row).equals(lineAt(lines, row))) { - return row; - } - } - return -1; - } - - private TerminalLine lineAt(List lines, int row) { - return row < lines.size() ? lines.get(row) : EMPTY_LINE; - } - - private void moveCursor(Optional cursor) throws IOException { - if (cursor.isPresent()) { - CursorPosition position = cursor.orElseThrow(); - moveTo(position.row(), position.column()); - } - } - - private void moveTo(int row, int column) throws IOException { - io.write("\033[" + row + ";" + column + "H"); - } - - private void writeLine(TerminalLine line) throws IOException { - int width = io.width(); - io.write(width > 0 ? AnsiWidth.truncate(line.text(), width) : line.text()); - } - - private CursorFrame stripCursor(List lines) { - List stripped = new ArrayList<>(lines.size()); - CursorPosition cursor = null; - for (int row = 0; row < lines.size(); row++) { - TerminalLine line = lines.get(row); - String text = line.text(); - int marker = text.indexOf(TuiRenderFrame.CURSOR_MARKER); - if (marker < 0) { - stripped.add(line); - continue; - } - String before = text.substring(0, marker); - String after = text.substring(marker + TuiRenderFrame.CURSOR_MARKER.length()); - stripped.add(new TerminalLine(before + after)); - cursor = new CursorPosition(row + 1, AnsiWidth.displayWidth(before) + 1); - } - return new CursorFrame(List.copyOf(stripped), Optional.ofNullable(cursor)); - } - - private record CursorFrame(List lines, Optional cursor) { - } - - private record CursorPosition(int row, int column) { - } -} diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiContentLayout.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiContentLayout.java deleted file mode 100644 index 385a8c85..00000000 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiContentLayout.java +++ /dev/null @@ -1,47 +0,0 @@ -package cn.lypi.transport.tui; - -record TuiContentLayout(int historyHeight, int separatorHeight, int liveHeight) { - TuiContentLayout { - if (historyHeight < 0 || separatorHeight < 0 || liveHeight < 0) { - throw new IllegalArgumentException("content region heights must be non-negative"); - } - } - - static TuiContentLayout allocate(int height, boolean hasHistory, int desiredLiveHeight) { - if (height < 0) { - throw new IllegalArgumentException("height must be non-negative"); - } - if (desiredLiveHeight < 0) { - throw new IllegalArgumentException("desiredLiveHeight must be non-negative"); - } - if (height == 0) { - return new TuiContentLayout(0, 0, 0); - } - - boolean hasLive = desiredLiveHeight > 0; - if (!hasHistory) { - return hasLive - ? new TuiContentLayout(0, 0, height) - : new TuiContentLayout(height, 0, 0); - } - if (!hasLive) { - return new TuiContentLayout(height, 0, 0); - } - if (height == 1) { - return new TuiContentLayout(0, 0, 1); - } - if (height == 2) { - return new TuiContentLayout(1, 0, 1); - } - - int separatorHeight = 1; - int availableContentHeight = height - separatorHeight; - int liveHeight = Math.min(desiredLiveHeight, availableContentHeight / 2); - int historyHeight = availableContentHeight - liveHeight; - return new TuiContentLayout(historyHeight, separatorHeight, liveHeight); - } - - int totalHeight() { - return historyHeight + separatorHeight + liveHeight; - } -} diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java index cc207377..23804b40 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java @@ -15,9 +15,6 @@ final class TuiInputLoop { private final TuiSubmitHandler submitHandler; - private final FrameSink frameSink; - private final TuiRenderer renderer; - private TuiScreen screen; private TuiLayout layout; private final Supplier viewSupplier; private final InputEditor editor = new InputEditor(); @@ -25,7 +22,7 @@ final class TuiInputLoop { private final TerminalInputPolicy inputPolicy = new TerminalInputPolicy(); private final Supplier slashPickerSupplier; private final Supplier skillIndexSupplier; - private final Runnable immediateRender; + private final Runnable renderRequest; private final ResumeSessionController resumeController; private final ResumeOverlayController resumeOverlayController; private SlashCommandPicker slashPicker; @@ -42,115 +39,73 @@ final class TuiInputLoop { TuiInputLoop( TuiSubmitHandler submitHandler, - FrameSink frameSink, - TuiRenderer renderer, - TuiScreen screen, + Runnable renderRequest, TuiLayout layout ) { - this(submitHandler, frameSink, renderer, screen, layout, null); + this(submitHandler, renderRequest, layout, null); } TuiInputLoop( TuiSubmitHandler submitHandler, - FrameSink frameSink, - TuiRenderer renderer, - TuiScreen screen, + Runnable renderRequest, TuiLayout layout, Supplier viewSupplier ) { - this(submitHandler, frameSink, renderer, screen, layout, viewSupplier, null); + this(submitHandler, renderRequest, layout, viewSupplier, null); } TuiInputLoop( TuiSubmitHandler submitHandler, - FrameSink frameSink, - TuiRenderer renderer, - TuiScreen screen, + Runnable renderRequest, TuiLayout layout, Supplier viewSupplier, Supplier slashPickerSupplier ) { - this(submitHandler, frameSink, renderer, screen, layout, viewSupplier, slashPickerSupplier, null); + this(submitHandler, renderRequest, layout, viewSupplier, slashPickerSupplier, null); } TuiInputLoop( TuiSubmitHandler submitHandler, - FrameSink frameSink, - TuiRenderer renderer, - TuiScreen screen, + Runnable renderRequest, TuiLayout layout, Supplier viewSupplier, Supplier slashPickerSupplier, ResumeSessionController resumeController ) { - this(submitHandler, frameSink, renderer, screen, layout, viewSupplier, slashPickerSupplier, resumeController, null); + this(submitHandler, renderRequest, layout, viewSupplier, slashPickerSupplier, resumeController, null); } TuiInputLoop( TuiSubmitHandler submitHandler, - FrameSink frameSink, - TuiRenderer renderer, - TuiScreen screen, + Runnable renderRequest, TuiLayout layout, Supplier viewSupplier, Supplier slashPickerSupplier, ResumeSessionController resumeController, Consumer resumeStateConsumer ) { - this(submitHandler, frameSink, renderer, screen, layout, viewSupplier, slashPickerSupplier, resumeController, resumeStateConsumer, null); + this(submitHandler, renderRequest, layout, viewSupplier, slashPickerSupplier, resumeController, resumeStateConsumer, null); } TuiInputLoop( TuiSubmitHandler submitHandler, - FrameSink frameSink, - TuiRenderer renderer, - TuiScreen screen, + Runnable renderRequest, TuiLayout layout, Supplier viewSupplier, Supplier slashPickerSupplier, ResumeSessionController resumeController, Consumer resumeStateConsumer, Supplier skillIndexSupplier - ) { - this( - submitHandler, - frameSink, - renderer, - screen, - layout, - viewSupplier, - slashPickerSupplier, - resumeController, - resumeStateConsumer, - skillIndexSupplier, - null - ); - } - - TuiInputLoop( - TuiSubmitHandler submitHandler, - FrameSink frameSink, - TuiRenderer renderer, - TuiScreen screen, - TuiLayout layout, - Supplier viewSupplier, - Supplier slashPickerSupplier, - ResumeSessionController resumeController, - Consumer resumeStateConsumer, - Supplier skillIndexSupplier, - Runnable immediateRender ) { this.submitHandler = submitHandler; - this.frameSink = frameSink; - this.renderer = renderer; - this.screen = screen; this.layout = layout; this.viewSupplier = viewSupplier == null ? this::emptyView : viewSupplier; this.slashPickerSupplier = slashPickerSupplier == null ? () -> SlashCommandPicker.withTemplates(List.of()) : slashPickerSupplier; this.skillIndexSupplier = skillIndexSupplier == null ? () -> new SkillIndex(List.of(), List.of()) : skillIndexSupplier; - this.immediateRender = immediateRender; + this.renderRequest = renderRequest == null ? () -> { + } : renderRequest; this.resumeController = resumeController; this.resumeOverlayController = resumeController == null ? null : new ResumeOverlayController( resumeController, @@ -321,15 +276,7 @@ void acceptKey(TerminalKey key) { case MOVE_WORD_RIGHT -> editor.moveWordRight(); case PREVIOUS_HISTORY -> editor.previousHistory(); case NEXT_HISTORY -> editor.nextHistory(); - case SCROLL_TRANSCRIPT_UP -> { - if (transcriptScrollEnabled(prompt)) { - screen.scrollPageUp(); - } - } - case SCROLL_TRANSCRIPT_DOWN -> { - if (transcriptScrollEnabled(prompt)) { - screen.scrollPageDown(); - } + case SCROLL_TRANSCRIPT_UP, SCROLL_TRANSCRIPT_DOWN -> { } case TOGGLE_TOOL_OUTPUT_EXPANDED, EXPAND_TOOLS -> toolOutputExpanded = !toolOutputExpanded; default -> { @@ -350,8 +297,7 @@ boolean exitRequested() { return exitRequested; } - void updateViewport(TuiScreen screen, TuiLayout layout) { - this.screen = screen; + void updateLayout(TuiLayout layout) { this.layout = layout; } @@ -417,37 +363,17 @@ private TerminalInputContext inputContext(Optional prompt) ); } - private boolean transcriptScrollEnabled(Optional prompt) { - return prompt.isEmpty() && !resumeOverlayOpen() && !slashOverlayOpen() && !skillOverlayOpen(); - } - private void submitPermissionOption(PermissionPromptView prompt, String optionId) { submitHandler.submitPermissionOption(prompt.requestId(), prompt.toolUseId(), optionId); render(); } - void renderCurrentFrame() { - renderFrame(); - } - private void render() { - if (immediateRender != null) { - immediateRender.run(); - return; - } - renderFrame(); + renderRequest.run(); } - private void renderFrame() { - frameSink.render(renderer.renderFrame( - currentView(), - screen, - layout, - editor.text(), - editor.cursor(), - overlayLines(), - toolOutputExpanded - )); + TuiViewModel viewForRender() { + return currentView(); } private TuiViewModel currentView() { @@ -587,7 +513,7 @@ private List slashOverlayLines() { return lines; } - private List overlayLines() { + List overlayLines() { if (resumeOverlayController != null) { List resumeLines = resumeOverlayController.overlayLines(layout.width()); if (!resumeLines.isEmpty()) { @@ -640,6 +566,10 @@ private List skillMatches() { return new SkillMentionParser(skillIndexSupplier.get().skills()).matches(skillToken.prefix()); } + boolean toolOutputExpanded() { + return toolOutputExpanded; + } + private List skillOverlayLines() { if (!skillOverlayOpen()) { return List.of(); diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java index 381e3a64..e3246964 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java @@ -2,8 +2,6 @@ record TuiLayout(int width, int height) { private static final int STATUS_BAR_HEIGHT = 1; - private static final int INPUT_BORDER_HEIGHT = 2; - private static final int MIN_INPUT_CONTENT_HEIGHT = 1; TuiLayout { if (width <= 0) { @@ -14,10 +12,6 @@ record TuiLayout(int width, int height) { } } - int transcriptHeight() { - return allocate(MIN_INPUT_CONTENT_HEIGHT + INPUT_BORDER_HEIGHT, 0, true).transcriptHeight(); - } - int maxSurfaceHeight() { return Math.max(1, height - 1); } @@ -41,43 +35,4 @@ TuiRegionLayout allocateSurface(int desiredLiveHeight, int desiredInputHeight, i return new TuiRegionLayout(liveHeight, inputHeight, overlayHeight, statusHeight); } - int transcriptHeight(int inputBlockHeight) { - return allocate(inputBlockHeight, 0, true).transcriptHeight(); - } - - int maxInputBlockHeight() { - return allocate(Integer.MAX_VALUE, 0, false).inputHeight(); - } - - int maxInputContentHeight() { - int maxInputBlockHeight = maxInputBlockHeight(); - if (maxInputBlockHeight <= INPUT_BORDER_HEIGHT) { - return MIN_INPUT_CONTENT_HEIGHT; - } - return Math.max(MIN_INPUT_CONTENT_HEIGHT, maxInputBlockHeight - INPUT_BORDER_HEIGHT); - } - - TuiRegionLayout allocate(int desiredInputHeight, int desiredOverlayHeight, boolean hasTranscript) { - int inputHeight = 1; - int transcriptHeight = 0; - int overlayHeight = 0; - int remainingHeight = height - STATUS_BAR_HEIGHT - inputHeight; - - if (hasTranscript && remainingHeight > 0) { - transcriptHeight = 1; - remainingHeight--; - } - - int boundedOverlayHeight = Math.max(0, desiredOverlayHeight); - overlayHeight = Math.min(boundedOverlayHeight, remainingHeight); - remainingHeight -= overlayHeight; - - int boundedInputHeight = Math.max(1, desiredInputHeight); - int additionalInputHeight = Math.min(boundedInputHeight - inputHeight, remainingHeight); - inputHeight += additionalInputHeight; - remainingHeight -= additionalInputHeight; - - transcriptHeight += remainingHeight; - return new TuiRegionLayout(transcriptHeight, inputHeight, overlayHeight, STATUS_BAR_HEIGHT); - } } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java index 5d6c6151..0297a24b 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java @@ -23,11 +23,9 @@ final class TuiRenderer { private static final String INPUT_CURSOR = "\033[38;5;81m|\033[39m"; private static final String ANSI_RESET = "\033[0m"; private static final String INPUT_PREFIX = "> "; - private static final String CONTENT_SEPARATOR_CHARACTER = "┄"; private static final int THINKING_VISIBLE_LINE_LIMIT = 3; private final MarkdownRenderer markdownRenderer = new MarkdownRenderer(); private final ToolDisplayRendererRegistry toolDisplayRenderers = ToolDisplayRendererRegistry.defaults(); - private final TuiTranscriptPartitioner transcriptPartitioner = new TuiTranscriptPartitioner(); List renderCommittedBlocks(List blocks, int width) { return renderTranscriptBlocks(blocks, width, false, Integer.MAX_VALUE).stream() @@ -64,7 +62,7 @@ TuiRenderFrame renderSurface( fullOverlay.lines().size() ); List lines = new ArrayList<>(); - lines.addAll(tail(fullLive, regions.transcriptHeight())); + lines.addAll(tailPreservingOmissionMarker(fullLive, regions.transcriptHeight())); lines.addAll(inputCandidate.render(regions.inputHeight()).lines()); lines.addAll(windowOverlay( fullOverlay.lines(), @@ -80,105 +78,6 @@ TuiRenderFrame renderSurface( return TuiRenderFrame.fromTextLines(lines); } - List render(TuiViewModel view, TuiScreen screen, TuiLayout layout, String input) { - return renderFrame(view, screen, layout, input, -1).lines(); - } - - List render(TuiViewModel view, TuiScreen screen, TuiLayout layout, String input, int cursor) { - return renderFrame(view, screen, layout, input, cursor, List.of()).lines(); - } - - List render( - TuiViewModel view, - TuiScreen screen, - TuiLayout layout, - String input, - int cursor, - List overlayLines - ) { - return renderFrame(view, screen, layout, input, cursor, overlayLines).lines(); - } - - TuiRenderFrame renderFrame(TuiViewModel view, TuiScreen screen, TuiLayout layout, String input, int cursor) { - return renderFrame(view, screen, layout, input, cursor, List.of()); - } - - TuiRenderFrame renderFrame( - TuiViewModel view, - TuiScreen screen, - TuiLayout layout, - String input, - int cursor, - List overlayLines - ) { - return renderFrame(view, screen, layout, input, cursor, overlayLines, false); - } - - TuiRenderFrame renderFrame( - TuiViewModel view, - TuiScreen screen, - TuiLayout layout, - String input, - int cursor, - List overlayLines, - boolean toolOutputExpanded - ) { - TuiTranscriptPartition partition = transcriptPartitioner.partition(view.blocks()); - List fullHistory = renderTranscriptBlocks( - partition.history(), - layout.width(), - false, - Integer.MAX_VALUE - ); - List fullLive = renderLiveLines( - view, - partition.live(), - layout.width(), - toolOutputExpanded, - Integer.MAX_VALUE - ); - InputCandidate inputCandidate = compactRunning(view) - ? readonlyRuntimeInputCandidate("compact 正在进行...", layout.width()) - : measureInput(input, cursor, layout.width()); - OverlayBlock fullOverlay = combineOverlays( - permissionOverlay(view, layout.width()), - externalOverlay(overlayLines, layout.width()) - ); - TuiRegionLayout regions = layout.allocate( - inputCandidate.desiredHeight(), - fullOverlay.lines().size(), - !fullHistory.isEmpty() || !fullLive.isEmpty() - ); - TuiContentLayout content = TuiContentLayout.allocate( - regions.transcriptHeight(), - !fullHistory.isEmpty(), - fullLive.size() - ); - InputBlock inputBlock = inputCandidate.render(regions.inputHeight()); - List overlay = windowOverlay( - fullOverlay.lines(), - regions.overlayHeight(), - fullOverlay.selectedRow() - ); - screen.updateViewportHeight(content.historyHeight()); - screen.setTranscript(fullHistory); - List history = topPaddedTail(screen.visibleTranscript(), content.historyHeight()); - List live = topPaddedTail(fullLive, content.liveHeight()); - List lines = new ArrayList<>(); - lines.addAll(history); - if (content.separatorHeight() > 0) { - lines.add(contentSeparator(layout.width())); - } - lines.addAll(live); - lines.addAll(inputBlock.lines()); - lines.addAll(overlay); - lines.add(statusLine(view.statusBar(), screen, layout.width())); - if (lines.size() != layout.height()) { - throw new IllegalStateException("rendered frame must match terminal height"); - } - return TuiRenderFrame.fromTextLines(lines); - } - List renderTranscriptBlocks( List blocks, int width, @@ -476,19 +375,6 @@ private List wrapLogicalLine(String text, int width) { return lines; } - private String statusLine(StatusBarState status, TuiScreen screen, int width) { - if (screen.linesBelow() <= 0) { - return ordinaryStatusLine(status, width); - } - String unread = "↑ " + screen.linesBelow() + " lines"; - int unreadWidth = AnsiWidth.displayWidth(unread); - if (unreadWidth >= width) { - return AnsiWidth.truncate(unread, width); - } - String ordinary = ordinaryStatusLine(status, width - unreadWidth - 1); - return ordinary.isBlank() ? unread : ordinary + " " + unread; - } - private String ordinaryStatusLine(StatusBarState status, int width) { String permissionMode = singleLine(status.permissionMode()); String full = String.join( @@ -681,22 +567,6 @@ private String inputBorder(int width) { return INPUT_BORDER + "─".repeat(width) + ANSI_RESET; } - private String contentSeparator(int width) { - return INPUT_BORDER + CONTENT_SEPARATOR_CHARACTER.repeat(width) + ANSI_RESET; - } - - private List topPaddedTail(List lines, int height) { - if (height <= 0) { - return List.of(); - } - int start = Math.max(0, lines.size() - height); - List visible = lines.subList(start, lines.size()); - List padded = new ArrayList<>(height); - padded.addAll(blankLines(height - visible.size())); - padded.addAll(visible); - return List.copyOf(padded); - } - private List tail(List lines, int height) { if (height <= 0 || lines.isEmpty()) { return List.of(); @@ -705,11 +575,19 @@ private List tail(List lines, int height) { return List.copyOf(lines.subList(start, lines.size())); } - private List blankLines(int count) { - if (count <= 0) { - return List.of(); + private List tailPreservingOmissionMarker(List lines, int height) { + List visible = new ArrayList<>(tail(lines, height)); + if (visible.isEmpty() || visible.stream().anyMatch(this::isToolOmissionMarker)) { + return List.copyOf(visible); + } + String marker = lines.stream() + .filter(this::isToolOmissionMarker) + .findFirst() + .orElse(null); + if (marker != null) { + visible.set(0, marker); } - return java.util.Collections.nCopies(count, ""); + return List.copyOf(visible); } private String nullToEmpty(String value) { diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java deleted file mode 100644 index 1de81e28..00000000 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java +++ /dev/null @@ -1,99 +0,0 @@ -package cn.lypi.transport.tui; - -import java.util.List; - -final class TuiScreen { - static final int MAX_RETAINED_HISTORY_LINES = 500; - - private int viewportHeight; - private List transcript = List.of(); - private int linesBelow; - - TuiScreen(int viewportHeight) { - if (viewportHeight <= 0) { - throw new IllegalArgumentException("viewportHeight must be positive"); - } - this.viewportHeight = viewportHeight; - } - - void updateViewportHeight(int viewportHeight) { - if (viewportHeight < 0) { - throw new IllegalArgumentException("viewportHeight must be non-negative"); - } - this.viewportHeight = viewportHeight; - } - - void setTranscript(List transcript) { - List previousTranscript = this.transcript; - boolean followingTail = linesBelow == 0; - this.transcript = retainTail(transcript); - if (followingTail) { - linesBelow = 0; - return; - } - int overlap = maximumSuffixPrefixOverlap(previousTranscript, this.transcript); - int appendedLines = this.transcript.size() - overlap; - linesBelow = Math.min(maxLinesBelow(), linesBelow + appendedLines); - } - - void scrollUp(int lines) { - linesBelow = Math.min(maxLinesBelow(), linesBelow + Math.max(0, lines)); - } - - void scrollDown(int lines) { - linesBelow = Math.max(0, linesBelow - Math.max(0, lines)); - } - - void scrollPageUp() { - scrollUp(pageSize()); - } - - void scrollPageDown() { - scrollDown(pageSize()); - } - - List visibleTranscript() { - if (transcript.isEmpty() || viewportHeight == 0) { - return List.of(); - } - int end = Math.max(0, transcript.size() - linesBelow); - int start = Math.max(0, end - viewportHeight); - return List.copyOf(transcript.subList(start, end)); - } - - int linesBelow() { - return linesBelow; - } - - int retainedLineCount() { - return transcript.size(); - } - - void reset() { - transcript = List.of(); - linesBelow = 0; - } - - private List retainTail(List lines) { - int from = Math.max(0, lines.size() - MAX_RETAINED_HISTORY_LINES); - return List.copyOf(lines.subList(from, lines.size())); - } - - private int maximumSuffixPrefixOverlap(List previous, List current) { - int maximum = Math.min(previous.size(), current.size()); - for (int overlap = maximum; overlap > 0; overlap--) { - if (previous.subList(previous.size() - overlap, previous.size()).equals(current.subList(0, overlap))) { - return overlap; - } - } - return 0; - } - - private int maxLinesBelow() { - return Math.max(0, transcript.size() - viewportHeight); - } - - private int pageSize() { - return Math.max(1, viewportHeight - 1); - } -} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java index 719896f6..200043d3 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java @@ -137,6 +137,24 @@ void topConstrainedViewportUsesLinearFallbackAndReservesSurfaceRows() throws Exc assertTrue(output.contains("\033[3;1H\033[2Knew-status")); } + @Test + void linearFallbackRedrawsUnchangedSurfaceAfterClearingIt() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(20, 5); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(0, 2, 20, 5) + ); + TuiRenderFrame surface = TuiRenderFrame.fromTextLines(List.of("> draft|CURSOR|", "status")); + renderer.render(new TuiRenderBatch(List.of(), surface)); + io.resetOutput(); + + renderer.render(new TuiRenderBatch(List.of(new TerminalLine("history")), surface)); + + String output = io.output.toString(); + assertTrue(output.contains("\033[2;1H\033[2K> draft")); + assertTrue(output.contains("\033[3;1H\033[2Kstatus")); + } + @Test void standardHistoryScrollRegionEndsAboveShiftedSurface() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(80, 8); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java index b96fb98e..babdd287 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java @@ -54,8 +54,8 @@ class JLineTuiTransportRenderPipelineTest { @Test void completedRuntimeTranscriptRendersFinalToolStatesOnFirstFrame() { RecordingEventBus events = new RecordingEventBus(); - List> frames = new ArrayList<>(); - JLineTuiTransport transport = JLineTuiTransport.withRenderer(frames::add, 100, 12); + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchRenderer(sink, 100, 12); transport.attach(events, completedRuntimeState("ses_resumed", 0)); transport.renderCurrentFrameUnderUiLock(); @@ -69,7 +69,14 @@ void completedRuntimeTranscriptRendersFinalToolStatesOnFirstFrame() { assertFalse(bash.active()); assertTrue(bash.details().contains("command failed")); - String firstFrame = String.join("\n", frames.getFirst()); + TuiRenderBatch first = sink.batches.getFirst(); + String firstFrame = String.join( + "\n", + java.util.stream.Stream.concat( + first.historyLines().stream().map(TerminalLine::text), + first.surface().lines().stream() + ).toList() + ); assertTrue(firstFrame.contains("tools: read x1 (Ctrl+O details)"), firstFrame); assertTrue(firstFrame.contains("failed $ exit 1"), firstFrame); assertTrue(firstFrame.contains("command failed"), firstFrame); @@ -79,17 +86,10 @@ void completedRuntimeTranscriptRendersFinalToolStatesOnFirstFrame() { } @Test - void reattachToResumedStateClearsOldProgressAndScrollsOnlyNewTranscript() throws Exception { + void reattachToResumedStateClearsOldProgressAndDoesNotRecommitSameProjection() { RecordingEventBus events = new RecordingEventBus(); - List> frames = new ArrayList<>(); - QueueInputSource input = new QueueInputSource(); - JLineTuiTransport transport = JLineTuiTransport.withInput( - frames::add, - 80, - 8, - input, - new RecordingSubmitHandler() - ); + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchRenderer(sink, 80, 8); transport.attach(events, TestRuntimeStates.basic("ses_old")); events.emit(new ToolStartEvent("ses_old", "old-tool", "bash", Instant.parse("2026-06-09T00:00:00Z"))); events.emit(new ToolProgressEvent( @@ -104,6 +104,7 @@ void reattachToResumedStateClearsOldProgressAndScrollsOnlyNewTranscript() throws SessionRuntimeState resumed = completedRuntimeState("ses_new", 12); transport.attach(events, resumed); transport.renderCurrentFrameUnderUiLock(); + TuiRenderBatch resumedBatch = sink.batches.getLast(); assertFalse(transport.viewForTest().blocks().stream() .filter(TuiToolBlock.class::isInstance) @@ -115,6 +116,9 @@ void reattachToResumedStateClearsOldProgressAndScrollsOnlyNewTranscript() throws .anyMatch(block -> block.details().contains("old-progress"))); assertEquals(TuiToolState.DONE, tool(transport, "read-1").state()); assertEquals(TuiToolState.FAILED, tool(transport, "bash-1").state()); + assertTrue(historyText(resumedBatch).contains("new history 0")); + assertTrue(historyText(resumedBatch).contains("resume complete")); + assertFalse(surfaceText(resumedBatch).contains("old-progress")); events.emit(new ToolProgressEvent( "ses_new", @@ -128,65 +132,36 @@ void reattachToResumedStateClearsOldProgressAndScrollsOnlyNewTranscript() throws transport.attach(events, resumed); transport.renderCurrentFrameUnderUiLock(); - for (int index = 0; index < 10; index++) { - input.add("\033[6~"); - } - transport.drainInputForTest(); - assertTrue(String.join("\n", frames.getLast()).contains("resume complete")); - - for (int index = 0; index < 10; index++) { - input.add("\033[5~"); - } - transport.drainInputForTest(); - String pageUpFrame = String.join("\n", frames.getLast()); - assertTrue(pageUpFrame.contains("new history 0"), pageUpFrame); - assertFalse(pageUpFrame.contains("old-progress"), pageUpFrame); - - for (int index = 0; index < 10; index++) { - input.add("\033[6~"); - } - transport.drainInputForTest(); - String pageDownFrame = String.join("\n", frames.getLast()); - assertTrue(pageDownFrame.contains("resume complete"), pageDownFrame); - assertFalse(pageDownFrame.contains("old-progress"), pageDownFrame); + TuiRenderBatch sameProjectionBatch = sink.batches.getLast(); + assertTrue(sameProjectionBatch.historyLines().isEmpty()); + assertFalse(surfaceText(sameProjectionBatch).contains("old-progress")); } @Test - void changingSessionClearsHistoryScrollOffsetBeforeFirstFrame() throws Exception { + void changingSessionStartsNewTranscriptCommitEpoch() { RecordingEventBus events = new RecordingEventBus(); - List> frames = new ArrayList<>(); - QueueInputSource input = new QueueInputSource(); - JLineTuiTransport transport = JLineTuiTransport.withInput( - frames::add, - 80, - 8, - input, - new RecordingSubmitHandler() - ); + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchRenderer(sink, 80, 8); transport.attach(events, completedRuntimeState("ses_old", 40)); transport.renderCurrentFrameUnderUiLock(); - for (int index = 0; index < 20; index++) { - input.add("\033[5~"); - } - transport.drainInputForTest(); - assertTrue(frames.getLast().getLast().contains("↑")); + assertTrue(historyText(sink.batches.getLast()).contains("new history 0")); transport.attach(events, completedRuntimeState("ses_new", 5)); transport.renderCurrentFrameUnderUiLock(); - String firstNewFrame = String.join("\n", frames.getLast()); - assertTrue(firstNewFrame.contains("resume complete"), firstNewFrame); - assertFalse(firstNewFrame.contains("new history"), firstNewFrame); - assertFalse(frames.getLast().getLast().contains("↑")); + TuiRenderBatch firstNewBatch = sink.batches.getLast(); + assertTrue(historyText(firstNewBatch).contains("new history 0")); + assertTrue(historyText(firstNewBatch).contains("resume complete")); + assertFalse(surfaceText(firstNewBatch).contains("new history")); } @Test - void pageUpChangesRestoredHistoryWithoutMovingLiveToolOrBottomChrome() throws Exception { + void pageUpDoesNotRecommitHistoryOrMoveLiveSurface() throws Exception { RecordingEventBus events = new RecordingEventBus(); - List> frames = new ArrayList<>(); + RecordingFrameSink sink = new RecordingFrameSink(); QueueInputSource input = new QueueInputSource(); - JLineTuiTransport transport = JLineTuiTransport.withInput( - frames::add, + JLineTuiTransport transport = JLineTuiTransport.withBatchInput( + sink, 80, 10, input, @@ -200,57 +175,32 @@ void pageUpChangesRestoredHistoryWithoutMovingLiveToolOrBottomChrome() throws Ex Instant.parse("2026-06-09T00:00:00Z") )); transport.flushPendingFrameForTest(); - List tailFrame = frames.getLast(); + TuiRenderBatch initialBatch = sink.batches.getLast(); input.add("\033[5~"); transport.drainInputForTest(); - List pageUpFrame = frames.getLast(); + TuiRenderBatch pageUpBatch = sink.batches.getLast(); - assertTrue(tailFrame.contains("history-line-30")); - assertFalse(pageUpFrame.contains("history-line-30")); - assertTrue(pageUpFrame.stream().anyMatch(line -> line.startsWith("running $"))); - assertEquals( - tailFrame.stream().filter(line -> line.startsWith("running $")).findFirst(), - pageUpFrame.stream().filter(line -> line.startsWith("running $")).findFirst() - ); - assertEquals( - tailFrame.stream().filter(line -> line.contains(INPUT_BACKGROUND)).findFirst(), - pageUpFrame.stream().filter(line -> line.contains(INPUT_BACKGROUND)).findFirst() - ); - assertEquals(10, tailFrame.size()); - assertEquals(10, pageUpFrame.size()); - assertTrue(tailFrame.stream().anyMatch(line -> line.contains("┄"))); - assertTrue(pageUpFrame.stream().anyMatch(line -> line.contains("┄"))); - assertTrue(tailFrame.getLast().contains("ses_1")); - assertTrue(pageUpFrame.getLast().contains("ses_1")); + assertTrue(historyText(initialBatch).contains("history-line-30")); + assertTrue(pageUpBatch.historyLines().isEmpty()); + assertTrue(surfaceText(pageUpBatch).contains("running $")); + assertEquals(initialBatch.surface().lines(), pageUpBatch.surface().lines()); + assertTrue(pageUpBatch.surface().lines().size() <= 9); } @Test - void restoredHistoryCanScrollBackOnlyThroughLatestFiveHundredPhysicalLines() throws Exception { + void restoredHistoryCommitsAllPhysicalLinesWithoutApplicationTruncation() { RecordingEventBus events = new RecordingEventBus(); - List> frames = new ArrayList<>(); - QueueInputSource input = new QueueInputSource(); - JLineTuiTransport transport = JLineTuiTransport.withInput( - frames::add, - 80, - 8, - input, - new RecordingSubmitHandler() - ); + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchRenderer(sink, 80, 8); transport.attach(events, historyRuntimeState("ses_1", 510)); transport.renderCurrentFrameUnderUiLock(); - for (int index = 0; index < 200; index++) { - input.add("\033[5~"); - } - for (int batch = 0; batch < 7; batch++) { - transport.drainInputForTest(); - } - List topFrame = frames.getLast(); - assertTrue(topFrame.contains("history-line-11"), String.join("\n", topFrame)); - for (int index = 1; index <= 10; index++) { - assertFalse(topFrame.contains("history-line-" + index)); - } + TuiRenderBatch first = sink.batches.getLast(); + assertEquals(510, first.historyLines().size()); + assertTrue(historyText(first).contains("history-line-1")); + assertTrue(historyText(first).contains("history-line-510")); + assertFalse(surfaceText(first).contains("history-line-")); } @Test @@ -342,10 +292,10 @@ void visibleDeltaReducesAndRendersUnderOneUiLockEntry() { } @Test - void eventRenderingUsesTranscriptViewportInsteadOfReturningAnOversizedFrame() { + void eventRenderingCommitsStableLinesOutsideBoundedSurface() { RecordingEventBus events = new RecordingEventBus(); - List> frames = new ArrayList<>(); - JLineTuiTransport transport = JLineTuiTransport.withRenderer(frames::add, 40, 7); + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchRenderer(sink, 40, 7); transport.attach(events, TestRuntimeStates.basic("ses_1")); for (int i = 0; i < 6; i++) { @@ -364,14 +314,15 @@ void eventRenderingUsesTranscriptViewportInsteadOfReturningAnOversizedFrame() { } transport.flushPendingFrameForTest(); - List latest = frames.getLast(); - assertTrue(latest.size() <= 7); - assertFalse(latest.contains("line 0")); - assertFalse(latest.contains("line 2")); - assertTrue(latest.contains("line 3")); - assertTrue(latest.contains("line 5")); - assertEquals(inputContent("> "), inputLine(latest)); - assertTrue(latest.getLast().contains("ses_1")); + String committed = allHistoryText(sink); + TuiRenderBatch latest = sink.batches.getLast(); + assertTrue(committed.contains("line 0")); + assertTrue(committed.contains("line 5")); + assertTrue(latest.surface().lines().size() <= 6); + assertFalse(surfaceText(latest).contains("line 0")); + assertFalse(surfaceText(latest).contains("line 5")); + assertEquals(inputContent("> "), inputLine(latest.surface().lines())); + assertTrue(latest.surface().lines().getLast().contains("ses_1")); } @Test @@ -404,8 +355,8 @@ void eventRenderProjectsRuntimeStateIntoStatusBar() { @Test void eventPipelineRendersUserAndThinkingAsDistinctLines() { RecordingEventBus events = new RecordingEventBus(); - List> frames = new ArrayList<>(); - JLineTuiTransport transport = JLineTuiTransport.withRenderer(frames::add, 80, 7); + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchRenderer(sink, 80, 7); transport.attach(events, TestRuntimeStates.basic("ses_1")); events.emit(new MessageDeltaEvent( @@ -446,10 +397,10 @@ void eventPipelineRendersUserAndThinkingAsDistinctLines() { )); transport.flushPendingFrameForTest(); - List latest = frames.getLast(); - assertTrue(latest.contains("\033[38;5;81muser: 请修复 TUI\033[0m")); - assertTrue(latest.contains("\033[38;5;244mthinking: 分析路径\033[0m")); - assertTrue(latest.contains("已处理")); + String committed = allHistoryText(sink); + assertTrue(committed.contains("\033[38;5;81muser: 请修复 TUI\033[0m")); + assertTrue(committed.contains("\033[38;5;244mthinking: 分析路径\033[0m")); + assertTrue(committed.contains("已处理")); } @Test @@ -515,12 +466,12 @@ void resizeRerendersCurrentViewWithUpdatedDimensionsUnderUiLock() { } @Test - void resizePreservesScrolledTranscriptUntilPageDownReturnsToTail() throws Exception { + void resizeAndPageKeysDoNotReplayCommittedTranscript() throws Exception { RecordingEventBus events = new RecordingEventBus(); - List> frames = new ArrayList<>(); + RecordingFrameSink sink = new RecordingFrameSink(); QueueInputSource input = new QueueInputSource(); - JLineTuiTransport transport = JLineTuiTransport.withInput( - frames::add, + JLineTuiTransport transport = JLineTuiTransport.withBatchInput( + sink, 40, 6, input, @@ -542,31 +493,31 @@ void resizePreservesScrolledTranscriptUntilPageDownReturnsToTail() throws Except Instant.parse("2026-06-09T00:00:00Z") )); } + transport.flushPendingFrameForTest(); + assertTrue(allHistoryText(sink).contains("line 1")); + assertTrue(allHistoryText(sink).contains("line 10")); input.add("\033[5~"); transport.drainInputForTest(); - long rowsBeforeResize = transcriptRows(frames.getLast()); - assertTrue(frames.getLast().contains("line 9")); - assertFalse(frames.getLast().contains("line 10")); + assertTrue(sink.batches.getLast().historyLines().isEmpty()); transport.resizeForTest(40, 8); - assertTrue(transcriptRows(frames.getLast()) > rowsBeforeResize); - assertTrue(frames.getLast().contains("line 9")); - assertFalse(frames.getLast().contains("line 10")); + TuiRenderBatch resized = sink.batches.getLast(); + assertTrue(resized.historyLines().isEmpty()); + assertTrue(resized.surface().lines().size() <= 7); input.add("\033[6~"); transport.drainInputForTest(); - - assertTrue(frames.getLast().contains("line 10")); + assertTrue(sink.batches.getLast().historyLines().isEmpty()); } @Test - void inputRerenderPreservesCurrentTranscriptView() throws Exception { + void inputRerenderPreservesCommittedTranscriptWithoutReplayingIt() throws Exception { RecordingEventBus events = new RecordingEventBus(); - List> frames = new ArrayList<>(); - JLineTuiTransport transport = JLineTuiTransport.withInput( - frames::add, + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchInput( + sink, 40, 5, new QueueInputSource("draft"), @@ -588,8 +539,12 @@ void inputRerenderPreservesCurrentTranscriptView() throws Exception { )); transport.drainInputForTest(); - assertEquals("Done", frames.getLast().getFirst()); - assertEquals(inputContent("> draft|CURSOR|" + INPUT_CURSOR), inputLine(frames.getLast())); + assertEquals(1, occurrences(allHistoryText(sink), "Done")); + assertTrue(sink.batches.getLast().historyLines().isEmpty()); + assertEquals( + inputContent("> draft|CURSOR|" + INPUT_CURSOR), + inputLine(sink.batches.getLast().surface().lines()) + ); } @Test @@ -658,8 +613,8 @@ void eventRerenderPreservesCurrentDraftCursor() throws Exception { @Test void retryStatusRendersAsTransientTranscriptLineWithoutMovingStatusBar() { RecordingEventBus events = new RecordingEventBus(); - List> frames = new ArrayList<>(); - JLineTuiTransport transport = JLineTuiTransport.withRenderer(frames::add, 80, 6); + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchRenderer(sink, 80, 6); transport.attach(events, TestRuntimeStates.basic("ses_1")); events.emit(new MessageDeltaEvent( @@ -677,11 +632,12 @@ void retryStatusRendersAsTransientTranscriptLineWithoutMovingStatusBar() { events.emit(new RetryStartEvent("ses_1", 2, "rate limit", Instant.parse("2026-06-09T00:00:01Z"))); transport.flushPendingFrameForTest(); - List latest = frames.getLast(); - assertEquals("hello", latest.get(0)); - assertEquals("· retrying attempt 2 rate limit", latest.get(1)); - assertEquals(inputContent("> "), inputLine(latest)); - assertTrue(latest.getLast().contains("ses_1")); + TuiRenderBatch latest = sink.batches.getLast(); + assertEquals(1, occurrences(allHistoryText(sink), "hello")); + assertTrue(latest.historyLines().isEmpty()); + assertTrue(latest.surface().lines().contains("· retrying attempt 2 rate limit")); + assertEquals(inputContent("> "), inputLine(latest.surface().lines())); + assertTrue(latest.surface().lines().getLast().contains("ses_1")); } @Test @@ -1008,7 +964,7 @@ void idleTickRefreshesWorkingTurnElapsedTime() throws Exception { JLineTuiTransport transport = JLineTuiTransport.withInput( frames::add, 80, - 5, + 8, new QueueInputSource(), new RecordingSubmitHandler(), clock @@ -1293,6 +1249,15 @@ private static TuiToolBlock tool(JLineTuiTransport transport, String toolUseId) .orElseThrow(); } + private static final class RecordingFrameSink implements FrameSink { + private final List batches = new ArrayList<>(); + + @Override + public void render(TuiRenderBatch batch) { + batches.add(batch); + } + } + private static final class QueueInputSource implements TerminalInputSource { private final ArrayDeque chunks; @@ -1343,6 +1308,27 @@ private static String inputContent(String content) { return INPUT_BACKGROUND + content + ANSI_RESET; } + private static String historyText(TuiRenderBatch batch) { + return batch.historyLines().stream() + .map(TerminalLine::text) + .collect(java.util.stream.Collectors.joining("\n")); + } + + private static String surfaceText(TuiRenderBatch batch) { + return String.join("\n", batch.surface().lines()); + } + + private static String allHistoryText(RecordingFrameSink sink) { + return sink.batches.stream() + .flatMap(batch -> batch.historyLines().stream()) + .map(TerminalLine::text) + .collect(java.util.stream.Collectors.joining("\n")); + } + + private static int occurrences(String value, String token) { + return value.split(java.util.regex.Pattern.quote(token), -1).length - 1; + } + private static boolean awaitFrame(List> frames, Predicate> predicate) throws InterruptedException { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); @@ -1355,10 +1341,6 @@ private static boolean awaitFrame(List> frames, Predicate frame) { - return frame.stream().filter(line -> line.startsWith("line ")).count(); - } - private static final class RecordingSubmitHandler implements TuiSubmitHandler { private final List interruptReasons = new ArrayList<>(); private int interrupts; diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java index 7d16d37d..df143174 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java @@ -49,6 +49,9 @@ import cn.lypi.contracts.tui.SlashCommandHandler; import java.io.IOException; import java.io.UncheckedIOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.Proxy; import java.math.BigDecimal; import java.nio.file.Path; import java.time.Instant; @@ -57,6 +60,9 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import org.jline.terminal.Attributes; +import org.jline.terminal.Terminal; +import org.jline.utils.NonBlockingReader; import org.junit.jupiter.api.Test; class JLineTuiTransportTest { @@ -109,8 +115,9 @@ void openAssemblesTerminalSessionRendererInputAndEventSubscription() throws Exce transport.flushPendingFrameForTest(); assertTrue(io.rawModeEntered); - assertTrue(io.output.toString().contains("\033[?1049h")); - assertTrue(io.output.toString().contains("\033[?2026h\033[2J\033[H")); + assertFalse(io.output.toString().contains("\033[?1049")); + assertFalse(io.output.toString().contains("\033[2J")); + assertTrue(io.output.toString().contains("\033[?2026h")); assertTrue(io.output.toString().contains("error: boom")); assertTrue(io.output.toString().contains("> ")); assertTrue(io.output.toString().contains("ses_1")); @@ -118,7 +125,77 @@ void openAssemblesTerminalSessionRendererInputAndEventSubscription() throws Exce transport.close(); assertTrue(io.rawModeRestored); - assertTrue(io.output.toString().contains("\033[?1049l")); + assertFalse(io.output.toString().contains("\033[?1049")); + assertTrue(io.output.toString().endsWith( + TerminalSession.RESET_SCROLL_REGION + + TerminalSession.DISABLE_MODIFY_OTHER_KEYS + + TerminalSession.DISABLE_BRACKETED_PASTE + + TerminalSession.SHOW_CURSOR + )); + } + + @Test + void publicOpenUsesCursorProbeAnchorAndReplaysConcurrentInput() throws Exception { + StringWriter output = new StringWriter(); + RecordingTerminalState terminalState = new RecordingTerminalState(); + Terminal terminal = terminal(new SequenceReader("typed\033[3;4R"), output, terminalState); + + JLineTuiTransport transport = JLineTuiTransport.open( + runtimeState(), + new RecordingCore(), + new RecordingEventBus(), + terminal + ); + transport.drainInputForTest(); + + String written = output.toString(); + assertTrue(written.indexOf("\033[6n") > written.indexOf(TerminalSession.ENABLE_MODIFY_OTHER_KEYS)); + assertTrue(written.indexOf("\033[?2026h") > written.indexOf("\033[6n")); + assertTrue(written.contains("\033[3;1H")); + assertFalse(written.contains("\033[6n\r\n")); + assertEquals(5, transport.currentDraftLengthForTest()); + + transport.close(); + assertTrue(terminalState.rawModeRestored); + } + + @Test + void publicOpenWritesNewlineBeforeBottomRowFallback() throws Exception { + StringWriter output = new StringWriter(); + RecordingTerminalState terminalState = new RecordingTerminalState(); + Terminal terminal = terminal(new EofReader(), output, terminalState); + + JLineTuiTransport transport = JLineTuiTransport.open( + runtimeState(), + new RecordingCore(), + new RecordingEventBus(), + terminal + ); + + assertTrue(output.toString().contains("\033[6n\r\n\033[?2026h")); + + transport.close(); + assertTrue(terminalState.rawModeRestored); + } + + @Test + void publicOpenRestoresTerminalWhenCursorProbeReadFails() { + StringWriter output = new StringWriter(); + RecordingTerminalState terminalState = new RecordingTerminalState(); + Terminal terminal = terminal(new FailingReader(), output, terminalState); + + assertThrows(IOException.class, () -> JLineTuiTransport.open( + runtimeState(), + new RecordingCore(), + new RecordingEventBus(), + terminal + )); + + assertTrue(terminalState.rawModeRestored); + assertTrue(output.toString().contains(TerminalSession.RESET_SCROLL_REGION)); + assertTrue(output.toString().contains(TerminalSession.DISABLE_MODIFY_OTHER_KEYS)); + assertTrue(output.toString().contains(TerminalSession.DISABLE_BRACKETED_PASTE)); + assertTrue(output.toString().contains(TerminalSession.SHOW_CURSOR)); } @Test @@ -145,7 +222,7 @@ void openRendersInitialFrameFromRuntimeState() throws Exception { } @Test - void openPipelineKeepsLongTranscriptInFixedAlternateScreenFrames() throws Exception { + void openPipelineCommitsLongTranscriptWithoutAlternateScreenOrFullClear() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); io.height = 5; RecordingEventBus events = new RecordingEventBus(); @@ -179,12 +256,13 @@ void openPipelineKeepsLongTranscriptInFixedAlternateScreenFrames() throws Except String output = io.output.toString(); assertTrue(output.contains("line 0")); assertTrue(output.contains("line 7")); - assertTrue(output.contains("\033[?1049h")); - assertFalse(output.contains("\033[?1049l")); + assertTrue(output.contains("\r\n")); + assertFalse(output.contains("\033[?1049")); assertFalse(output.contains("\033[H\033[J")); + assertFalse(output.contains("\033[2J")); transport.close(); - assertTrue(io.output.toString().contains("\033[?1049l")); + assertFalse(io.output.toString().contains("\033[?1049")); } @Test @@ -221,15 +299,17 @@ void openPipelineKeepsBottomChromeInViewportWithoutScrollbackOverflow() throws E transport.flushPendingFrameForTest(); String output = io.output.toString(); - assertFalse(output.contains("\r\n")); - assertFalse(output.matches("(?s).*\\033\\[1;\\d+r.*")); + assertTrue(output.contains("\r\n")); + assertTrue(output.contains("line 0")); + assertTrue(output.contains("line 3")); + assertFalse(output.contains("\r\n\033[48;5;236m> ")); assertTrue(output.contains("\033[2K\033[48;5;236m> ")); transport.close(); } @Test - void closeAfterOverflowRestoresOriginalScreenWithoutWritingPromptNewline() throws Exception { + void closeAfterHistoryCommitClearsSurfaceAndRestoresModesWithoutPromptNewline() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); io.height = 3; RecordingEventBus events = new RecordingEventBus(); @@ -262,9 +342,15 @@ void closeAfterOverflowRestoresOriginalScreenWithoutWritingPromptNewline() throw transport.close(); + assertTrue(io.output.toString().contains("\033[?2026h")); + assertTrue(io.output.toString().contains("\033[2K")); assertTrue(io.output.toString().endsWith( - "\033[>4m\033[?2004l\033[?1006l\033[?1000l\033[?1049l\033[?25h" + TerminalSession.RESET_SCROLL_REGION + + TerminalSession.DISABLE_MODIFY_OTHER_KEYS + + TerminalSession.DISABLE_BRACKETED_PASTE + + TerminalSession.SHOW_CURSOR )); + assertFalse(io.output.toString().contains("\033[?1049")); assertFalse(io.output.toString().endsWith("\n")); } @@ -279,7 +365,7 @@ void toolEndRefreshesDiffViewFromProvider() throws Exception { false, Map.of("snapshotHash", "sha256:1") ))); - io.height = 8; + io.height = 12; JLineTuiTransport transport = JLineTuiTransport.open( runtimeState(), @@ -289,7 +375,7 @@ void toolEndRefreshesDiffViewFromProvider() throws Exception { new RecordingSubmitHandler(), diffProvider, 80, - 8 + 12 ); io.output.setLength(0); @@ -326,7 +412,11 @@ void openClosesTerminalSessionWhenInitialFrameRenderFails() { )); assertTrue(io.rawModeRestored); - assertTrue(io.output.toString().contains("\033[?1049l")); + assertTrue(io.output.toString().contains(TerminalSession.RESET_SCROLL_REGION)); + assertTrue(io.output.toString().contains(TerminalSession.DISABLE_MODIFY_OTHER_KEYS)); + assertTrue(io.output.toString().contains(TerminalSession.DISABLE_BRACKETED_PASTE)); + assertTrue(io.output.toString().contains(TerminalSession.SHOW_CURSOR)); + assertFalse(io.output.toString().contains("\033[?1049")); } @Test @@ -350,13 +440,12 @@ void openResizeCallbackReadsCurrentTerminalDimensions() throws Exception { io.resizeCallback.run(); String frame = io.output.toString(); - String fullClear = "\033[2J\033[H"; - String rendered = frame.substring(frame.indexOf(fullClear) + fullClear.length(), frame.indexOf("\033[?2026l")); - for (int row = 1; row <= 6; row++) { - assertTrue(rendered.contains("\033[" + row + ";1H")); - } - assertFalse(rendered.contains("\n")); - assertTrue(rendered.contains("> ")); + assertTrue(frame.startsWith("\033[?2026h")); + assertTrue(frame.endsWith("\033[?2026l")); + assertFalse(frame.contains("\033[2J")); + assertFalse(frame.contains("\n")); + assertTrue(frame.contains("> ")); + assertTrue(frame.contains("\033[6;1H")); transport.close(); } @@ -853,6 +942,103 @@ public Optional read() { } } + private static Terminal terminal( + NonBlockingReader reader, + StringWriter output, + RecordingTerminalState state + ) { + PrintWriter writer = new PrintWriter(output); + Attributes attributes = new Attributes(); + Map handlers = new java.util.EnumMap<>(Terminal.Signal.class); + Terminal.SignalHandler defaultHandler = signal -> { + }; + handlers.put(Terminal.Signal.WINCH, defaultHandler); + handlers.put(Terminal.Signal.INT, defaultHandler); + return (Terminal) Proxy.newProxyInstance( + Terminal.class.getClassLoader(), + new Class[] { Terminal.class }, + (proxy, method, arguments) -> switch (method.getName()) { + case "enterRawMode" -> { + state.rawModeEntered = true; + yield attributes; + } + case "setAttributes" -> { + state.rawModeRestored = true; + yield null; + } + case "handle" -> handlers.put( + (Terminal.Signal) arguments[0], + (Terminal.SignalHandler) arguments[1] + ); + case "reader" -> reader; + case "writer" -> writer; + case "flush" -> { + writer.flush(); + yield null; + } + case "getWidth" -> 40; + case "getHeight" -> 8; + case "toString" -> "transport-terminal"; + default -> throw new UnsupportedOperationException(method.getName()); + } + ); + } + + private static final class RecordingTerminalState { + private boolean rawModeEntered; + private boolean rawModeRestored; + } + + private static final class SequenceReader extends NonBlockingReader { + private final String input; + private int index; + + private SequenceReader(String input) { + this.input = input; + } + + @Override + protected int read(long timeout, boolean isPeek) { + if (index >= input.length()) { + return READ_EXPIRED; + } + int next = input.charAt(index); + if (!isPeek) { + index++; + } + return next; + } + + @Override + public int readBuffered(char[] buffer, int offset, int length, long timeout) { + return 0; + } + } + + private static final class EofReader extends NonBlockingReader { + @Override + protected int read(long timeout, boolean isPeek) { + return EOF; + } + + @Override + public int readBuffered(char[] buffer, int offset, int length, long timeout) { + return 0; + } + } + + private static final class FailingReader extends NonBlockingReader { + @Override + protected int read(long timeout, boolean isPeek) throws IOException { + throw new IOException("cursor probe failed"); + } + + @Override + public int readBuffered(char[] buffer, int offset, int length, long timeout) { + return 0; + } + } + private static final class RecordingTerminalIo implements TerminalIo { private final StringBuilder output = new StringBuilder(); private boolean rawModeEntered; diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalFrameRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalFrameRendererTest.java deleted file mode 100644 index ed962730..00000000 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalFrameRendererTest.java +++ /dev/null @@ -1,185 +0,0 @@ -package cn.lypi.transport.tui; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.util.List; -import java.util.stream.IntStream; -import org.junit.jupiter.api.Test; - -class TerminalFrameRendererTest { - private static final String SYNC_START = "\033[?2026h"; - private static final String SYNC_END = "\033[?2026l"; - private static final String FULL_CLEAR = "\033[2J\033[H"; - - @Test - void rejectsFramesTallerThanTerminalWithoutWritingPartialOutput() { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 5; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - assertThrows(IllegalArgumentException.class, () -> renderer.render( - IntStream.rangeClosed(1, io.height + 1).mapToObj(String::valueOf).toList() - )); - - assertEquals("", io.output.toString()); - } - - @Test - void firstFrameClearsAlternateScreenAndUsesAbsoluteRows() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of("hello", "world|CURSOR|")); - - assertEquals( - SYNC_START - + FULL_CLEAR - + "\033[1;1Hhello" - + "\033[2;1Hworld" - + "\033[2;6H" - + SYNC_END, - io.output.toString() - ); - } - - @Test - void fixedFramePatchUsesPhysicalRowsWithoutTerminalScrolling() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 6; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - renderer.render(List.of("history-1", "history-2", "separator", "live-old", "> |CURSOR|", "status")); - io.output.setLength(0); - - renderer.render(List.of("history-2", "history-3", "separator", "live-new", "> |CURSOR|", "status")); - - assertEquals( - SYNC_START - + "\033[1;1H\033[2Khistory-2" - + "\033[2;1H\033[2Khistory-3" - + "\033[4;1H\033[2Klive-new" - + "\033[5;3H" - + SYNC_END, - io.output.toString() - ); - assertFalse(io.output.toString().contains("\r\n")); - assertFalse(io.output.toString().contains("\n")); - assertFalse(io.output.toString().contains(FULL_CLEAR)); - assertFalse(io.output.toString().contains("\033[6;1H")); - } - - @Test - void cursorOnlyChangeMovesHardwareCursorWithoutRewritingLine() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - renderer.render(List.of("status", "> a|CURSOR|bc")); - io.output.setLength(0); - - renderer.render(List.of("status", "> ab|CURSOR|c")); - - assertEquals("\033[2;5H", io.output.toString()); - } - - @Test - void shorterFrameClearsTrailingOldRows() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - renderer.render(List.of("one", "two", "three|CURSOR|")); - io.output.setLength(0); - - renderer.render(List.of("one", "two|CURSOR|")); - - assertEquals( - SYNC_START - + "\033[3;1H\033[2K" - + "\033[2;4H" - + SYNC_END, - io.output.toString() - ); - } - - @Test - void resizeClearsAndRendersWholeCurrentFrame() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - renderer.render(List.of("one", "two|CURSOR|")); - io.output.setLength(0); - io.width = 40; - - renderer.render(List.of("one", "two|CURSOR|")); - - assertTrue(io.output.toString().startsWith(SYNC_START + FULL_CLEAR)); - assertTrue(io.output.toString().contains("\033[1;1Hone")); - assertTrue(io.output.toString().contains("\033[2;1Htwo")); - assertTrue(io.output.toString().endsWith("\033[2;4H" + SYNC_END)); - } - - @Test - void terminalWritesTruncatePhysicalLinesToCurrentWidth() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.width = 5; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(TuiRenderFrame.fromTextLines(List.of("abcdefgh"))); - - assertTrue(io.output.toString().contains("\033[1;1Habcd…")); - assertFalse(io.output.toString().contains("abcdef")); - } - - @Test - void unchangedFrameWithoutCursorProducesNoOutput() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - renderer.render(List.of("unchanged")); - io.output.setLength(0); - - renderer.render(List.of("unchanged")); - - assertEquals("", io.output.toString()); - } - - private static final class RecordingTerminalIo implements TerminalIo { - private final StringBuilder output = new StringBuilder(); - private int width = 80; - private int height = 24; - - @Override - public AutoCloseable enterRawMode() { - return () -> { - }; - } - - @Override - public void write(String value) { - output.append(value); - } - - @Override - public void flush() { - } - - @Override - public int width() { - return width; - } - - @Override - public int height() { - return height; - } - - @Override - public AutoCloseable onResize(Runnable callback) { - return () -> { - }; - } - - @Override - public AutoCloseable onInterrupt(Runnable callback) { - return () -> { - }; - } - } -} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalInputPumpTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalInputPumpTest.java index b10dc43e..96b17733 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalInputPumpTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalInputPumpTest.java @@ -18,10 +18,8 @@ void dispatchesTextAndMappedKeySequencesToInputLoop() throws IOException { RecordingSubmitHandler submit = new RecordingSubmitHandler(); TuiInputLoop loop = new TuiInputLoop( submit, - ignored -> { + () -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(20, 4) ); TerminalInputPump pump = new TerminalInputPump( @@ -41,10 +39,8 @@ void handlesSplitModifiedEnterSequenceAcrossRawInputChunks() throws IOException RecordingSubmitHandler submit = new RecordingSubmitHandler(); TuiInputLoop loop = new TuiInputLoop( submit, - ignored -> { + () -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(40, 4) ); TerminalInputPump pump = new TerminalInputPump( @@ -63,10 +59,8 @@ void dispatchesStandaloneEscapeToPermissionPromptInterrupt() throws IOException RecordingSubmitHandler submit = new RecordingSubmitHandler(); TuiInputLoop loop = new TuiInputLoop( submit, - ignored -> { + () -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(40, 4), TerminalInputPumpTest::permissionView ); @@ -87,10 +81,8 @@ void dispatchesBracketedPasteAsAtomicPaste() throws IOException { RecordingSubmitHandler submit = new RecordingSubmitHandler(); TuiInputLoop loop = new TuiInputLoop( submit, - ignored -> { + () -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(40, 4) ); TerminalInputPump pump = new TerminalInputPump( @@ -109,10 +101,8 @@ void assemblesBracketedPasteAcrossRawInputChunks() throws IOException { RecordingSubmitHandler submit = new RecordingSubmitHandler(); TuiInputLoop loop = new TuiInputLoop( submit, - ignored -> { + () -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(40, 4) ); TerminalInputPump pump = new TerminalInputPump( @@ -131,10 +121,8 @@ void dispatchesTextPasteAndRemainingKeyFromOneRawChunk() throws IOException { RecordingSubmitHandler submit = new RecordingSubmitHandler(); TuiInputLoop loop = new TuiInputLoop( submit, - ignored -> { + () -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(40, 4) ); TerminalInputPump pump = new TerminalInputPump( @@ -153,10 +141,8 @@ void dispatchesInputRemainingAfterPasteEndMarker() throws IOException { RecordingSubmitHandler submit = new RecordingSubmitHandler(); TuiInputLoop loop = new TuiInputLoop( submit, - ignored -> { + () -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(40, 4) ); TerminalInputPump pump = new TerminalInputPump( @@ -175,10 +161,8 @@ void flushesIncompleteBufferedSequenceWhenInputIsDrained() throws IOException { RecordingSubmitHandler submit = new RecordingSubmitHandler(); TuiInputLoop loop = new TuiInputLoop( submit, - ignored -> { + () -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(40, 4) ); TerminalInputPump pump = new TerminalInputPump( @@ -198,10 +182,8 @@ void keepsPendingPasteWhenInputIsTemporarilyDrained() throws IOException { RecordingSubmitHandler submit = new RecordingSubmitHandler(); TuiInputLoop loop = new TuiInputLoop( submit, - ignored -> { + () -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(40, 4) ); TerminalInputPump pump = new TerminalInputPump( diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContentLayoutTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContentLayoutTest.java deleted file mode 100644 index 35fbc65e..00000000 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContentLayoutTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package cn.lypi.transport.tui; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -class TuiContentLayoutTest { - @Test - void allocatesTinyBudgetsBetweenHistoryAndLiveRegions() { - assertLayout(TuiContentLayout.allocate(0, true, 20), 0, 0, 0); - assertLayout(TuiContentLayout.allocate(1, true, 20), 0, 0, 1); - assertLayout(TuiContentLayout.allocate(2, true, 20), 1, 0, 1); - assertLayout(TuiContentLayout.allocate(3, true, 20), 1, 1, 1); - } - - @Test - void capsLiveRegionAtHalfOfContentAfterSeparator() { - TuiContentLayout split = TuiContentLayout.allocate(10, true, 20); - - assertLayout(split, 5, 1, 4); - assertTrue(split.liveHeight() <= 4); - assertTrue(split.historyHeight() >= split.liveHeight()); - } - - @Test - void allocatesWholeBudgetToSingleOrEmptyRegion() { - assertLayout(TuiContentLayout.allocate(10, true, 0), 10, 0, 0); - assertLayout(TuiContentLayout.allocate(10, false, 3), 0, 0, 10); - assertLayout(TuiContentLayout.allocate(10, false, 0), 10, 0, 0); - } - - @Test - void usesOnlyDesiredLiveHeightWhenHistoryCanUseTheRemainder() { - assertLayout(TuiContentLayout.allocate(10, true, 1), 8, 1, 1); - } - - @Test - void rejectsNegativeInputs() { - assertThrows(IllegalArgumentException.class, () -> TuiContentLayout.allocate(-1, true, 1)); - assertThrows(IllegalArgumentException.class, () -> TuiContentLayout.allocate(1, true, -1)); - } - - private void assertLayout( - TuiContentLayout layout, - int expectedHistory, - int expectedSeparator, - int expectedLive - ) { - assertEquals(expectedHistory, layout.historyHeight()); - assertEquals(expectedSeparator, layout.separatorHeight()); - assertEquals(expectedLive, layout.liveHeight()); - assertEquals(expectedHistory + expectedSeparator + expectedLive, layout.totalHeight()); - } -} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java index 59b1d73b..32e17e4e 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java @@ -447,18 +447,18 @@ void toolLifecycleDetailsRespectCollapsedAndExpandedDisplayBudgets() { ); TuiRenderer renderer = new TuiRenderer(); - TuiRenderFrame collapsed = renderer.renderFrame( + TuiRenderFrame collapsed = renderer.renderSurface( view, - new TuiScreen(120), + view.blocks(), new TuiLayout(80, 120), "", -1, List.of(), false ); - TuiRenderFrame expanded = renderer.renderFrame( + TuiRenderFrame expanded = renderer.renderSurface( view, - new TuiScreen(120), + view.blocks(), new TuiLayout(80, 120), "", -1, @@ -494,9 +494,9 @@ private static MessageDeltaEvent delta( } private static List renderedTranscript(TuiViewModel view) { - TuiRenderFrame frame = new TuiRenderer().renderFrame( + TuiRenderFrame frame = new TuiRenderer().renderSurface( view, - new TuiScreen(20), + view.blocks(), new TuiLayout(80, 20), "", -1, diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java index 3340715e..f248406b 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java @@ -25,24 +25,59 @@ public static void main(String[] args) throws Exception { Terminal terminal = TerminalBuilder.builder().system(true).build(); TerminalIo io = new JLineTerminalIo(terminal); try (TerminalSession session = TerminalSession.open(io)) { - TerminalFrameRenderer frameRenderer = new TerminalFrameRenderer(io); + InlineTerminalRenderer terminalRenderer = new InlineTerminalRenderer( + io, + new InlineViewport(Math.max(0, io.height() - 1), 1, io.width(), io.height()) + ); TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(Math.max(1, io.height() - 4)); + TuiTranscriptPartitioner partitioner = new TuiTranscriptPartitioner(); + TuiTranscriptCommitLedger ledger = new TuiTranscriptCommitLedger(); TuiLayout layout = new TuiLayout(io.width(), io.height()); - frameRenderer.render(renderer.renderFrame(view("status-old"), screen, layout, "", 0)); - frameRenderer.render(renderer.renderFrame(view("status-updated"), screen, layout, "input", 5)); - Files.writeString(readyFile, "ready"); - long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(20); - while (!Files.exists(exitFile)) { - if (System.nanoTime() >= deadline) { - throw new IllegalStateException("timed out waiting for PTY exit signal"); + try { + render(terminalRenderer, renderer, partitioner, ledger, layout, view("status-old"), "", 0); + render(terminalRenderer, renderer, partitioner, ledger, layout, view("status-updated"), "input", 5); + Files.writeString(readyFile, "ready"); + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(20); + while (!Files.exists(exitFile)) { + if (System.nanoTime() >= deadline) { + throw new IllegalStateException("timed out waiting for PTY exit signal"); + } + Thread.sleep(25); } - Thread.sleep(25); + } finally { + terminalRenderer.finish(); } } } + private static void render( + InlineTerminalRenderer terminalRenderer, + TuiRenderer renderer, + TuiTranscriptPartitioner partitioner, + TuiTranscriptCommitLedger ledger, + TuiLayout layout, + TuiViewModel view, + String input, + int cursor + ) throws Exception { + TuiTranscriptPartition partition = partitioner.partition(view.blocks()); + List history = renderer.renderCommittedBlocks( + ledger.advance(new TuiProjectionKey("pty", "leaf"), partition.history()), + layout.width() + ); + TuiRenderFrame surface = renderer.renderSurface( + view, + partition.live(), + layout, + input, + cursor, + List.of(), + false + ); + terminalRenderer.render(new TuiRenderBatch(history, surface)); + } + private static TuiViewModel view(String runtimeLine) { return new TuiViewModel( List.of( diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java index f75bab43..5542bf8d 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java @@ -32,6 +32,8 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Supplier; import java.nio.file.Path; import java.time.Instant; import org.junit.jupiter.api.Test; @@ -41,15 +43,142 @@ class TuiInputLoopTest { private static final String INPUT_CURSOR = "\033[38;5;81m|\033[39m"; private static final String ANSI_RESET = "\033[0m"; + private static TuiInputLoop testLoop( + TuiSubmitHandler submitHandler, + Consumer> frameConsumer, + TuiLayout layout + ) { + return testLoop(submitHandler, frameConsumer, layout, null); + } + + private static TuiInputLoop testLoop( + TuiSubmitHandler submitHandler, + Consumer> frameConsumer, + TuiLayout layout, + Supplier viewSupplier + ) { + return testLoop(submitHandler, frameConsumer, layout, viewSupplier, null); + } + + private static TuiInputLoop testLoop( + TuiSubmitHandler submitHandler, + Consumer> frameConsumer, + TuiLayout layout, + Supplier viewSupplier, + Supplier slashPickerSupplier + ) { + return testLoop(submitHandler, frameConsumer, layout, viewSupplier, slashPickerSupplier, null); + } + + private static TuiInputLoop testLoop( + TuiSubmitHandler submitHandler, + Consumer> frameConsumer, + TuiLayout layout, + Supplier viewSupplier, + Supplier slashPickerSupplier, + ResumeSessionController resumeController + ) { + return testLoop( + submitHandler, + frameConsumer, + layout, + viewSupplier, + slashPickerSupplier, + resumeController, + null + ); + } + + private static TuiInputLoop testLoop( + TuiSubmitHandler submitHandler, + Consumer> frameConsumer, + TuiLayout layout, + Supplier viewSupplier, + Supplier slashPickerSupplier, + ResumeSessionController resumeController, + Consumer resumeStateConsumer + ) { + return testLoop( + submitHandler, + frameConsumer, + layout, + viewSupplier, + slashPickerSupplier, + resumeController, + resumeStateConsumer, + null + ); + } + + private static TuiInputLoop testLoop( + TuiSubmitHandler submitHandler, + Consumer> frameConsumer, + TuiLayout layout, + Supplier viewSupplier, + Supplier slashPickerSupplier, + ResumeSessionController resumeController, + Consumer resumeStateConsumer, + Supplier skillIndexSupplier + ) { + TestRenderRequest renderRequest = new TestRenderRequest(frameConsumer, layout); + TuiInputLoop loop = new TuiInputLoop( + submitHandler, + renderRequest, + layout, + viewSupplier, + slashPickerSupplier, + resumeController, + resumeStateConsumer, + skillIndexSupplier + ); + renderRequest.bind(loop); + return loop; + } + + private static final class TestRenderRequest implements Runnable { + private final Consumer> frameConsumer; + private final TuiLayout layout; + private final TuiRenderer renderer = new TuiRenderer(); + private final TuiTranscriptPartitioner partitioner = new TuiTranscriptPartitioner(); + private TuiInputLoop loop; + + private TestRenderRequest(Consumer> frameConsumer, TuiLayout layout) { + this.frameConsumer = frameConsumer; + this.layout = layout; + } + + private void bind(TuiInputLoop loop) { + this.loop = loop; + } + + @Override + public void run() { + TuiViewModel view = loop.viewForRender(); + TuiTranscriptPartition partition = partitioner.partition(view.blocks()); + List lines = new ArrayList<>(); + renderer.renderCommittedBlocks(partition.history(), layout.width()).stream() + .map(TerminalLine::text) + .forEach(lines::add); + lines.addAll(renderer.renderSurface( + view, + partition.live(), + layout, + loop.draft(), + loop.cursor(), + loop.overlayLines(), + loop.toolOutputExpanded() + ).lines()); + frameConsumer.accept(List.copyOf(lines)); + } + } + @Test void enterSubmitsDraftAndRerendersClearedInput() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(20, 4) ); @@ -64,12 +193,10 @@ void enterSubmitsDraftAndRerendersClearedInput() { @Test void compactRuntimeLineBlocksTypingAndSubmit() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, ignored -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(20, 4), () -> new TuiViewModel( List.of(), @@ -91,12 +218,10 @@ void compactRuntimeLineBlocksTypingAndSubmit() { @Test void escapeInterruptsCompactRuntimeLine() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, ignored -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(20, 4), () -> new TuiViewModel( List.of(), @@ -118,11 +243,9 @@ void escapeInterruptsCompactRuntimeLine() { void rendersCursorAtCurrentEditorPosition() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(30, 4) ); @@ -134,7 +257,7 @@ void rendersCursorAtCurrentEditorPosition() { } @Test - void pageKeysScrollTranscriptWithoutChangingDraftOrCursor() { + void pageKeysDoNotChangeInputOrRenderedProjection() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List> frames = new ArrayList<>(); List blocks = java.util.stream.IntStream.rangeClosed(1, 10) @@ -153,35 +276,31 @@ void pageKeysScrollTranscriptWithoutChangingDraftOrCursor() { Optional.empty(), Optional.empty() ); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(List.copyOf(lines)), - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(40, 6), () -> view ); loop.acceptText("draft"); int cursor = loop.cursor(); + List beforePageKey = frames.getLast(); loop.acceptKey(TerminalKey.PAGE_UP); assertEquals("draft", loop.draft()); assertEquals(cursor, loop.cursor()); - assertTrue(frames.getLast().contains("line8")); - assertTrue(frames.getLast().contains("line9")); - assertFalse(frames.getLast().contains("line10")); + assertEquals(beforePageKey, frames.getLast()); loop.acceptKey(TerminalKey.PAGE_DOWN); assertEquals("draft", loop.draft()); assertEquals(cursor, loop.cursor()); - assertTrue(frames.getLast().contains("line9")); - assertTrue(frames.getLast().contains("line10")); + assertEquals(beforePageKey, frames.getLast()); } @Test - void mouseWheelScrollsTranscriptWhileArrowUpStillBrowsesInputHistory() { + void mouseWheelDoesNotChangeInputWhileArrowUpStillBrowsesInputHistory() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List> frames = new ArrayList<>(); List blocks = java.util.stream.IntStream.rangeClosed(1, 30) @@ -200,11 +319,9 @@ void mouseWheelScrollsTranscriptWhileArrowUpStillBrowsesInputHistory() { Optional.empty(), Optional.empty() ); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(List.copyOf(lines)), - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(40, 6), () -> view ); @@ -212,33 +329,27 @@ void mouseWheelScrollsTranscriptWhileArrowUpStillBrowsesInputHistory() { loop.acceptKey(TerminalKey.ENTER); loop.acceptText("draft"); int cursor = loop.cursor(); + List beforeWheel = frames.getLast(); loop.acceptKey(TerminalKey.MOUSE_WHEEL_UP); assertEquals("draft", loop.draft()); assertEquals(cursor, loop.cursor()); - assertTrue(frames.getLast().contains("history-28")); - assertTrue(frames.getLast().contains("history-29")); - assertFalse(frames.getLast().contains("history-30")); + assertEquals(beforeWheel, frames.getLast()); loop.acceptKey(TerminalKey.CTRL_U); loop.acceptKey(TerminalKey.UP); assertEquals("previous prompt", loop.draft()); - assertTrue(frames.getLast().contains("history-28")); - assertTrue(frames.getLast().contains("history-29")); - assertFalse(frames.getLast().contains("history-30")); } @Test void backspaceDeletesPreviousCharacterAndRerendersInput() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(30, 4) ); @@ -255,11 +366,9 @@ void backspaceDeletesPreviousCharacterAndRerendersInput() { void pasteWithNewlineKeepsDraftRendersRowsAndSubmitsOriginalText() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(20, 6) ); @@ -277,8 +386,8 @@ void pasteWithNewlineKeepsDraftRendersRowsAndSubmitsOriginalText() { @Test void modifiedEnterInsertsNewlineInsteadOfSubmitting() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop(submit, ignored -> { - }, new TuiRenderer(), new TuiScreen(2), new TuiLayout(20, 4)); + TuiInputLoop loop = testLoop(submit, ignored -> { + }, new TuiLayout(20, 4)); loop.acceptText("hello"); loop.acceptKey(TerminalKey.MODIFIED_ENTER); @@ -291,8 +400,8 @@ void modifiedEnterInsertsNewlineInsteadOfSubmitting() { @Test void ctrlCClearsDraftBeforeInterruptingActiveTool() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop(submit, ignored -> { - }, new TuiRenderer(), new TuiScreen(2), new TuiLayout(20, 4)); + TuiInputLoop loop = testLoop(submit, ignored -> { + }, new TuiLayout(20, 4)); loop.acceptText("draft"); loop.acceptKey(TerminalKey.CTRL_C); @@ -306,8 +415,8 @@ void ctrlCClearsDraftBeforeInterruptingActiveTool() { @Test void escapeInterruptsActiveToolWithoutRequiringInputFocus() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop(submit, ignored -> { - }, new TuiRenderer(), new TuiScreen(2), new TuiLayout(20, 4)); + TuiInputLoop loop = testLoop(submit, ignored -> { + }, new TuiLayout(20, 4)); loop.setToolRunning(true); loop.acceptKey(TerminalKey.ESC); @@ -319,12 +428,10 @@ void escapeInterruptsActiveToolWithoutRequiringInputFocus() { @Test void escapeInterruptsActiveToolBeforeClosingSlashOverlay() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, ignored -> { }, - new TuiRenderer(), - new TuiScreen(3), new TuiLayout(40, 5), null, () -> new SlashCommandPicker(List.of("/model")) @@ -342,8 +449,8 @@ void escapeInterruptsActiveToolBeforeClosingSlashOverlay() { @Test void ctrlCRequestsExitWhenInputIsEmptyAndNoToolIsRunning() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop(submit, ignored -> { - }, new TuiRenderer(), new TuiScreen(2), new TuiLayout(20, 4)); + TuiInputLoop loop = testLoop(submit, ignored -> { + }, new TuiLayout(20, 4)); loop.acceptKey(TerminalKey.CTRL_C); @@ -373,11 +480,9 @@ void ctrlODoesNotExpandHistoricalToolOrChangeDraft() { Optional.empty(), Optional.empty() ); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(30), new TuiLayout(80, 30), () -> view ); @@ -429,11 +534,9 @@ void toolOutputToggleAppliesOnlyToRunningToolBlock() { Optional.empty(), Optional.empty() ); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(40), new TuiLayout(80, 40), () -> view ); @@ -472,11 +575,9 @@ void expandToolsActionLeavesHistoricalToolCollapsed() { Optional.empty(), Optional.empty() ); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(30), new TuiLayout(80, 30), () -> view ); @@ -490,12 +591,10 @@ void expandToolsActionLeavesHistoricalToolCollapsed() { @Test void enterSubmitsPermissionDefaultOptionWhenPromptIsOpen() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, ignored -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(40, 4), () -> permissionView("allow_once", "cancel") ); @@ -510,11 +609,9 @@ void enterSubmitsPermissionDefaultOptionWhenPromptIsOpen() { void upAndDownSelectPermissionOptionAndEnterSubmitsSelectedOption() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(4), new TuiLayout(40, 6), () -> permissionViewWithOptions("allow_once", "escape_cancel") ); @@ -533,11 +630,9 @@ void upAndDownSelectPermissionOptionAndEnterSubmitsSelectedOption() { void permissionPromptTakesPriorityOverSlashOverlayNavigation() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(5), new TuiLayout(40, 9), () -> permissionViewWithOptions("allow_once", "escape_cancel"), () -> new SlashCommandPicker(List.of("/model", "/plan")) @@ -557,12 +652,10 @@ void permissionPromptTakesPriorityOverSlashOverlayNavigation() { @Test void escapeAndCtrlCInterruptPermissionPromptInsteadOfSubmittingPermissionOption() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, ignored -> { }, - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(40, 4), () -> permissionViewWithOptions("allow_once", "escape_cancel") ); @@ -579,8 +672,8 @@ void escapeAndCtrlCInterruptPermissionPromptInsteadOfSubmittingPermissionOption( @Test void upAndDownNavigateSubmittedHistory() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop(submit, ignored -> { - }, new TuiRenderer(), new TuiScreen(2), new TuiLayout(20, 4)); + TuiInputLoop loop = testLoop(submit, ignored -> { + }, new TuiLayout(20, 4)); loop.acceptText("first"); loop.acceptKey(TerminalKey.ENTER); @@ -600,8 +693,8 @@ void upAndDownNavigateSubmittedHistory() { @Test void upDoesNotReplaceNonEmptyDraftWithHistoryUntilHistoryNavigationStartsFromEmptyInput() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop(submit, ignored -> { - }, new TuiRenderer(), new TuiScreen(2), new TuiLayout(20, 4)); + TuiInputLoop loop = testLoop(submit, ignored -> { + }, new TuiLayout(20, 4)); loop.acceptText("first"); loop.acceptKey(TerminalKey.ENTER); @@ -621,11 +714,9 @@ void upDoesNotReplaceNonEmptyDraftWithHistoryUntilHistoryNavigationStartsFromEmp void upAndDownMoveCursorInsideMultilineDraftBeforeHistoryNavigation() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(20, 6) ); @@ -650,11 +741,9 @@ void upAndDownMoveCursorInsideMultilineDraftBeforeHistoryNavigation() { void upAndDownMoveCursorAcrossSoftWrappedInputRowsBeforeHistoryNavigation() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(2), new TuiLayout(8, 6) ); @@ -678,11 +767,9 @@ void upAndDownMoveCursorAcrossSoftWrappedInputRowsBeforeHistoryNavigation() { void slashOverlayShowsCandidatesAndAcceptsSelection() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(6), new TuiLayout(40, 9), null, () -> SlashCommandPicker.withTemplates(List.of("review")) @@ -705,12 +792,10 @@ void slashOverlayShowsCandidatesAndAcceptsSelection() { @Test void slashOverlayUsesArrowKeysAndEscWithoutHistoryNavigation() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, ignored -> { }, - new TuiRenderer(), - new TuiScreen(3), new TuiLayout(40, 5), null, () -> new SlashCommandPicker(List.of("/model", "/plan", "/compact")) @@ -735,12 +820,10 @@ void slashOverlayUsesArrowKeysAndEscWithoutHistoryNavigation() { @Test void removedModeCommandIsNotAcceptedAsModelPrefix() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, ignored -> { }, - new TuiRenderer(), - new TuiScreen(3), new TuiLayout(40, 5), null, () -> new SlashCommandPicker(List.of("/model", "/plan", "/compact")) @@ -757,11 +840,9 @@ void removedModeCommandIsNotAcceptedAsModelPrefix() { void skillOverlayShowsCandidatesAcceptsSelectionAndSubmitsBinding() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(6), new TuiLayout(60, 9), null, () -> new SlashCommandPicker(List.of()), @@ -787,11 +868,9 @@ void skillOverlayShowsCandidatesAcceptsSelectionAndSubmitsBinding() { void skillOverlayOpensWhenOnlyDollarIsTyped() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(6), new TuiLayout(60, 9), null, () -> new SlashCommandPicker(List.of()), @@ -808,12 +887,10 @@ void skillOverlayOpensWhenOnlyDollarIsTyped() { @Test void escapeClosesSkillOverlayAndSuppressesCurrentTokenBinding() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, ignored -> { }, - new TuiRenderer(), - new TuiScreen(4), new TuiLayout(60, 7), null, () -> new SlashCommandPicker(List.of()), @@ -834,11 +911,9 @@ void escapeClosesSkillOverlayAndSuppressesCurrentTokenBinding() { void slashOverlayScrollsSelectedCandidateIntoVisibleWindow() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(6), new TuiLayout(40, 9), null, () -> new SlashCommandPicker(List.of( @@ -867,12 +942,10 @@ void slashOverlayScrollsSelectedCandidateIntoVisibleWindow() { @Test void unknownSlashWithNoOverlayCandidatesSubmitsAsNormalInput() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, ignored -> { }, - new TuiRenderer(), - new TuiScreen(3), new TuiLayout(40, 5), null, () -> new SlashCommandPicker(List.of("/model")) @@ -949,11 +1022,9 @@ public SessionRuntimeState resume(String sessionId, String leafId) { } }; TuiRenderState renderState = new TuiRenderState(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(8), new TuiLayout(80, 10), renderState::view, () -> new SlashCommandPicker(List.of("/resume")), @@ -1001,12 +1072,10 @@ void resumeSelectingUserEntrySwitchesToParentLeafAndRestoresDraftText() { Instant.EPOCH ) ); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, ignored -> { }, - new TuiRenderer(), - new TuiScreen(8), new TuiLayout(80, 10), null, () -> new SlashCommandPicker(List.of("/resume")), @@ -1093,11 +1162,9 @@ public SessionRuntimeState resumeWithBranchSummary(String sessionId, String targ ); } }; - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(8), new TuiLayout(80, 10), null, () -> new SlashCommandPicker(List.of("/resume")), @@ -1179,12 +1246,10 @@ public SessionRuntimeState resumeWithBranchSummary(String sessionId, String targ return runtimeState(sessionId, "summary_leaf"); } }; - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, ignored -> { }, - new TuiRenderer(), - new TuiScreen(8), new TuiLayout(80, 10), null, () -> new SlashCommandPicker(List.of("/resume")), @@ -1207,11 +1272,9 @@ public SessionRuntimeState resumeWithBranchSummary(String sessionId, String targ void resumeControllerAddsResumeToSlashPickerCandidates() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); - TuiInputLoop loop = new TuiInputLoop( + TuiInputLoop loop = testLoop( submit, lines -> frames.add(String.join("\n", lines)), - new TuiRenderer(), - new TuiScreen(5), new TuiLayout(80, 8), null, () -> new SlashCommandPicker(List.of("/review")), @@ -1227,8 +1290,8 @@ void resumeControllerAddsResumeToSlashPickerCandidates() { @Test void editingKeysMoveCursorDeleteLineUndoAndYank() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop(submit, ignored -> { - }, new TuiRenderer(), new TuiScreen(2), new TuiLayout(30, 4)); + TuiInputLoop loop = testLoop(submit, ignored -> { + }, new TuiLayout(30, 4)); loop.acceptText("alpha beta gamma"); loop.acceptKey(TerminalKey.LEFT); @@ -1247,8 +1310,8 @@ void editingKeysMoveCursorDeleteLineUndoAndYank() { @Test void altYRotatesKillRingAfterYank() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); - TuiInputLoop loop = new TuiInputLoop(submit, ignored -> { - }, new TuiRenderer(), new TuiScreen(2), new TuiLayout(30, 4)); + TuiInputLoop loop = testLoop(submit, ignored -> { + }, new TuiLayout(30, 4)); loop.acceptText("alpha beta gamma"); loop.acceptKey(TerminalKey.ALT_BACKSPACE); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java index a694c2cb..a1e7b016 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java @@ -19,26 +19,31 @@ void allocatesOnlyTheBoundedSurfaceRowsThatAreNeeded() { } @Test - void allocatesAllRegionsWithinTerminalHeight() { + void allocatesAllSurfaceRegionsWithinReservedTerminalBudget() { for (int height : new int[] {2, 3, 6, 24}) { + for (int desiredLiveHeight : new int[] {0, 3, 100}) { for (int desiredInputHeight : new int[] {1, 4, 100}) { for (int desiredOverlayHeight : new int[] {0, 3, 100}) { TuiLayout layout = new TuiLayout(80, height); - var regions = layout.allocate(desiredInputHeight, desiredOverlayHeight, true); + var regions = layout.allocateSurface( + desiredLiveHeight, + desiredInputHeight, + desiredOverlayHeight + ); String scenario = "height=" + height + + ", live=" + desiredLiveHeight + ", input=" + desiredInputHeight + ", overlay=" + desiredOverlayHeight; - assertTrue(regions.totalHeight() <= height, scenario); - assertEquals(1, regions.statusHeight(), scenario); + assertTrue(regions.totalHeight() <= height - 1, scenario); + assertEquals(height > 2 ? 1 : 0, regions.statusHeight(), scenario); assertTrue(regions.inputHeight() >= 1, scenario); - if (height >= 3) { - assertTrue(regions.transcriptHeight() >= 1, scenario); - } + assertTrue(regions.transcriptHeight() <= desiredLiveHeight, scenario); } } } + } } @Test diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java index 33b61caa..f95c588a 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java @@ -135,65 +135,6 @@ void mutableSurfaceKeepsOverlaysDiffAndSingleStatusLineVisible() { assertTrue(surface.lines().getLast().contains("ses_1")); } - @Test - void rendersFixedHeightHistoryAndLiveRegions() { - TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(6); - TuiViewModel view = new TuiViewModel( - List.of( - new TuiMessageBlock("history", "m1", "assistant", "old answer", false), - new TuiMessageBlock("live", "m2", "assistant", "streaming answer", true) - ), - new StatusBarState("ses_1", "gpt-5.4", "running", "default"), - List.of(), - Optional.empty(), - Optional.empty() - ); - - TuiRenderFrame frame = renderer.renderFrame(view, screen, new TuiLayout(40, 10), "draft", 5); - - assertEquals(10, frame.lines().size()); - int historyIndex = frame.lines().indexOf("old answer"); - int separatorIndex = indexOfContentSeparator(frame.lines()); - int liveIndex = frame.lines().indexOf("streaming answer"); - int inputIndex = indexOfLineContaining(frame.lines(), "> draft"); - assertTrue(historyIndex >= 0); - assertTrue(historyIndex < separatorIndex); - assertTrue(separatorIndex < liveIndex); - assertTrue(liveIndex < inputIndex); - assertEquals(1, frame.lines().stream().filter(this::isContentSeparator).count()); - } - - @Test - void retainsOnlyLatestFiveHundredRenderedHistoryLines() { - TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(16); - List history = java.util.stream.IntStream.rangeClosed(1, 510) - .mapToObj(index -> (TuiBlock) new TuiMessageBlock( - "history-" + index, - "message-" + index, - "assistant", - "history-" + index, - false - )) - .toList(); - TuiViewModel view = new TuiViewModel( - history, - new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), - List.of(), - Optional.empty(), - Optional.empty() - ); - - renderer.renderFrame(view, screen, new TuiLayout(40, 20), "", 0); - screen.scrollUp(1_000); - List lines = renderer.renderFrame(view, screen, new TuiLayout(40, 20), "", 0).lines(); - - assertFalse(lines.stream().anyMatch(line -> line.matches("history-(?:[1-9]|10)"))); - assertTrue(lines.contains("history-11")); - assertEquals(500, screen.retainedLineCount()); - } - @Test void toolExpansionChangesOnlyLiveRegion() { TuiRenderer renderer = new TuiRenderer(); @@ -228,26 +169,22 @@ void toolExpansionChangesOnlyLiveRegion() { Optional.empty() ); - List collapsed = renderer.renderFrame( - view, new TuiScreen(10), new TuiLayout(60, 14), "", -1, List.of(), false + TuiTranscriptPartition partition = new TuiTranscriptPartitioner().partition(view.blocks()); + List history = renderer.renderCommittedBlocks(partition.history(), 60); + List collapsed = renderer.renderSurface( + view, partition.live(), new TuiLayout(60, 14), "", -1, List.of(), false ).lines(); - List expanded = renderer.renderFrame( - view, new TuiScreen(10), new TuiLayout(60, 14), "", -1, List.of(), true + List expanded = renderer.renderSurface( + view, partition.live(), new TuiLayout(60, 14), "", -1, List.of(), true ).lines(); - int collapsedSeparator = indexOfContentSeparator(collapsed); - int expandedSeparator = indexOfContentSeparator(expanded); - assertEquals(collapsed.subList(0, collapsedSeparator), expanded.subList(0, expandedSeparator)); - assertFalse( - collapsed.subList(collapsedSeparator + 1, indexOfInputBorder(collapsed)) - .equals(expanded.subList(expandedSeparator + 1, indexOfInputBorder(expanded))) - ); + assertTrue(history.stream().anyMatch(line -> line.text().contains("history detail"))); + assertFalse(collapsed.equals(expanded)); } @Test void rendersLinearTranscriptStatusAndInput() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(2); TuiViewModel view = new TuiViewModel( List.of(new TuiMessageBlock("b1", "m1", "assistant", "hello world", false)), new StatusBarState("ses_1", "gpt-5.4", "execute", "tool:running"), @@ -256,7 +193,7 @@ void rendersLinearTranscriptStatusAndInput() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(12, 5), "draft"); + List lines = render(renderer, view, new TuiLayout(12, 5), "draft"); assertEquals(5, lines.size()); assertEquals("hello world", lines.get(0)); @@ -269,7 +206,6 @@ void rendersLinearTranscriptStatusAndInput() { @Test void statusBarPreservesToolOnNarrowWidth() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState("session-long", "very-long-model", "execute", "tool:running"), @@ -278,7 +214,7 @@ void statusBarPreservesToolOnNarrowWidth() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(10, 3), ""); + List lines = render(renderer, view, new TuiLayout(10, 3), ""); assertTrue(lines.getLast().contains("tool")); } @@ -286,7 +222,6 @@ void statusBarPreservesToolOnNarrowWidth() { @Test void statusBarWithApprovalProjectionTruncatesOnNarrowWidth() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState( @@ -306,39 +241,15 @@ void statusBarWithApprovalProjectionTruncatesOnNarrowWidth() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(20, 3), ""); + List lines = render(renderer, view, new TuiLayout(20, 3), ""); assertTrue(AnsiWidth.displayWidth(lines.getLast()) <= 20); assertFalse(lines.getLast().contains("\n")); } - @Test - void statusBarKeepsUnreadLineCountWhenOrdinaryFieldsAreTruncated() { - TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); - screen.setTranscript(List.of("old", "current")); - screen.scrollUp(1); - TuiViewModel view = new TuiViewModel( - List.of( - new TuiMessageBlock("b1", "m1", "assistant", "old", false), - new TuiMessageBlock("b2", "m2", "assistant", "current", false) - ), - new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), - List.of(), - Optional.empty(), - Optional.empty() - ); - - List lines = renderer.render(view, screen, new TuiLayout(12, 3), ""); - - assertTrue(lines.getLast().endsWith("↑ 1 lines")); - assertTrue(AnsiWidth.displayWidth(lines.getLast()) <= 12); - } - @Test void statusBarDoesNotRenderInternalRuntimeFields() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState( @@ -356,7 +267,7 @@ void statusBarDoesNotRenderInternalRuntimeFields() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(120, 3), ""); + List lines = render(renderer, view, new TuiLayout(120, 3), ""); assertEquals("ses_1 gpt-5.4 EXECUTE DEFAULT_EXECUTE", lines.getLast()); assertFalse(lines.getLast().contains("cwd:")); @@ -368,7 +279,6 @@ void statusBarDoesNotRenderInternalRuntimeFields() { @Test void messageBlocksUseMarkdownRenderer() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(2); TuiViewModel view = new TuiViewModel( List.of(new TuiMessageBlock("b1", "m1", "assistant", "## Done ##\n- [x] task", false)), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -377,7 +287,7 @@ void messageBlocksUseMarkdownRenderer() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(20, 6), ""); + List lines = render(renderer, view, new TuiLayout(20, 6), ""); assertEquals("Done", lines.get(0)); assertEquals("[x] task", lines.get(1)); @@ -386,7 +296,6 @@ void messageBlocksUseMarkdownRenderer() { @Test void rendersUserAndThinkingBlocksWithRoleStyles() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(3); TuiViewModel view = new TuiViewModel( List.of( new TuiMessageBlock("u1", "m1", "user", "请修复 TUI", false), @@ -399,7 +308,7 @@ void rendersUserAndThinkingBlocksWithRoleStyles() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(40, 7), ""); + List lines = render(renderer, view, new TuiLayout(40, 7), ""); assertEquals("\033[38;5;81muser: 请修复 TUI\033[0m", lines.get(0)); assertEquals("\033[38;5;244mthinking: 分析路径\033[0m", lines.get(1)); @@ -409,7 +318,6 @@ void rendersUserAndThinkingBlocksWithRoleStyles() { @Test void rendersMultilineThinkingWithoutEmbeddedNewlines() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(2); TuiViewModel view = new TuiViewModel( List.of(new TuiThinkingBlock("t1", "m1", "第一行\n第二行", false, false)), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -418,7 +326,7 @@ void rendersMultilineThinkingWithoutEmbeddedNewlines() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(40, 6), ""); + List lines = render(renderer, view, new TuiLayout(40, 6), ""); assertEquals("\033[38;5;244mthinking: 第一行\033[0m", lines.get(0)); assertEquals("\033[38;5;244m 第二行\033[0m", lines.get(1)); @@ -427,7 +335,6 @@ void rendersMultilineThinkingWithoutEmbeddedNewlines() { @Test void compressesThinkingDisplayAndShowsHiddenLineCount() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(4); TuiViewModel view = new TuiViewModel( List.of(new TuiThinkingBlock("t1", "m1", "第一行\n第二行\n第三行\n第四行\n第五行", false, false)), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -436,7 +343,7 @@ void compressesThinkingDisplayAndShowsHiddenLineCount() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(40, 8), ""); + List lines = render(renderer, view, new TuiLayout(40, 8), ""); assertEquals("\033[38;5;244mthinking: 第一行\033[0m", lines.get(0)); assertEquals("\033[38;5;244m 第二行\033[0m", lines.get(1)); @@ -449,7 +356,6 @@ void compressesThinkingDisplayAndShowsHiddenLineCount() { @Test void inputLineMarksHardwareCursorAtEditorCursor() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -458,7 +364,7 @@ void inputLineMarksHardwareCursorAtEditorCursor() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(30, 3), "alpha beta", 6); + List lines = render(renderer, view, new TuiLayout(30, 4), "alpha beta", 6); assertInputBorder(lines.get(0), 30); assertInputContent(lines.get(lines.size() - 2), "> alpha |CURSOR|" + INPUT_CURSOR + "beta"); @@ -467,7 +373,6 @@ void inputLineMarksHardwareCursorAtEditorCursor() { @Test void emptyTranscriptRendersInputBlockAndStatusOnly() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -476,20 +381,18 @@ void emptyTranscriptRendersInputBlockAndStatusOnly() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(20, 6), "", 0); + List lines = render(renderer, view, new TuiLayout(20, 6), "", 0); - assertEquals(6, lines.size()); - assertEquals(List.of("", ""), lines.subList(0, 2)); + assertEquals(4, lines.size()); + assertInputBorder(lines.get(0), 20); + assertInputContent(lines.get(1), "> |CURSOR|" + INPUT_CURSOR); assertInputBorder(lines.get(2), 20); - assertInputContent(lines.get(3), "> |CURSOR|" + INPUT_CURSOR); - assertInputBorder(lines.get(4), 20); - assertTrue(lines.get(5).contains("ses_1")); + assertTrue(lines.get(3).contains("ses_1")); } @Test void compactRuntimeUsesSingleReadonlyInputLineWithoutCursorBackgroundBlock() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState("ses_1", "gpt-5.4-mini", "running", "DEFAULT_EXECUTE"), @@ -499,7 +402,7 @@ void compactRuntimeUsesSingleReadonlyInputLineWithoutCursorBackgroundBlock() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(40, 8), "draft", 5); + List lines = render(renderer, view, new TuiLayout(40, 8), "draft", 5); String frame = String.join("\n", lines); assertTrue(frame.contains("compact")); @@ -510,7 +413,6 @@ void compactRuntimeUsesSingleReadonlyInputLineWithoutCursorBackgroundBlock() { @Test void inputLineKeepsCursorMarkerAfterNarrowWrapping() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -519,17 +421,16 @@ void inputLineKeepsCursorMarkerAfterNarrowWrapping() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(8, 3), "abcdefgh", 8); + List lines = render(renderer, view, new TuiLayout(8, 3), "abcdefgh", 8); - assertEquals(3, lines.size()); - assertInputBorder(lines.get(0), 8); - assertInputContent(lines.get(lines.size() - 2), "fgh|CURSOR|" + INPUT_CURSOR); + assertEquals(2, lines.size()); + assertInputContent(lines.getFirst(), "fgh|CURSOR|" + INPUT_CURSOR); + assertTrue(lines.getLast().contains("ses_1")); } @Test void visibleCursorDoesNotPushInputContentPastLayoutWidth() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -538,7 +439,7 @@ void visibleCursorDoesNotPushInputContentPastLayoutWidth() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(8, 4), "abcdef", 6); + List lines = render(renderer, view, new TuiLayout(8, 4), "abcdef", 6); for (String line : lines) { assertTrue(AnsiWidth.displayWidth(line.replace(TuiRenderFrame.CURSOR_MARKER, "")) <= 8); @@ -546,9 +447,8 @@ void visibleCursorDoesNotPushInputContentPastLayoutWidth() { } @Test - void longInputIsWindowedWithoutRemovingLatestTranscriptLine() { + void longInputIsWindowedWithoutChangingCommittedHistory() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of( new TuiMessageBlock("b1", "m1", "assistant", "line1", false), @@ -562,20 +462,22 @@ void longInputIsWindowedWithoutRemovingLatestTranscriptLine() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(8, 6), "abcdefghij", 10); + TuiTranscriptPartition partition = new TuiTranscriptPartitioner().partition(view.blocks()); + List history = renderer.renderCommittedBlocks(partition.history(), 8).stream() + .map(TerminalLine::text) + .toList(); + TuiRenderFrame surface = renderer.renderSurface( + view, partition.live(), new TuiLayout(8, 6), "abcdefghij", 10, List.of(), false + ); - assertTrue(lines.size() <= 6); - assertTrue(lines.contains("line4")); - assertFalse(lines.contains("line1")); - assertFalse(lines.contains("line2")); - assertFalse(lines.contains("line3")); - assertTrue(lines.stream().anyMatch(line -> line.contains(TuiRenderFrame.CURSOR_MARKER))); + assertEquals(List.of("line1", "line2", "line3", "line4"), history); + assertTrue(surface.lines().size() <= 5); + assertTrue(surface.lines().stream().anyMatch(line -> line.contains(TuiRenderFrame.CURSOR_MARKER))); } @Test void explicitNewlineStartsNewInputRowWithoutSplittingDraftSemantics() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -584,7 +486,7 @@ void explicitNewlineStartsNewInputRowWithoutSplittingDraftSemantics() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(12, 5), "hello\nworld", 11); + List lines = render(renderer, view, new TuiLayout(12, 6), "hello\nworld", 11); assertEquals(5, lines.size()); assertInputBorder(lines.get(0), 12); @@ -597,7 +499,6 @@ void explicitNewlineStartsNewInputRowWithoutSplittingDraftSemantics() { @Test void inputViewportShowsCursorRowsWhileKeepingOneTranscriptLine() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(new TuiMessageBlock("b1", "m1", "assistant", "history", false)), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -606,7 +507,7 @@ void inputViewportShowsCursorRowsWhileKeepingOneTranscriptLine() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(10, 6), "one\ntwo\nthree\nfour", 18); + List lines = render(renderer, view, new TuiLayout(10, 6), "one\ntwo\nthree\nfour", 18); assertTrue(lines.size() <= 6); assertTrue(lines.contains("history")); @@ -617,9 +518,8 @@ void inputViewportShowsCursorRowsWhileKeepingOneTranscriptLine() { } @Test - void minimumInputAndTranscriptFitInThreeLineTerminal() { + void minimumInputSurfaceFitsBesideCommittedHistoryInThreeLineTerminal() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(new TuiMessageBlock("b1", "m1", "assistant", "history", false)), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -628,19 +528,22 @@ void minimumInputAndTranscriptFitInThreeLineTerminal() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(10, 3), "one\ntwo\nthree\nfour", 18); + TuiTranscriptPartition partition = new TuiTranscriptPartitioner().partition(view.blocks()); + List history = renderer.renderCommittedBlocks(partition.history(), 10); + TuiRenderFrame surface = renderer.renderSurface( + view, partition.live(), new TuiLayout(10, 3), "one\ntwo\nthree\nfour", 18, List.of(), false + ); - assertTrue(lines.size() <= 3); - assertTrue(lines.contains("history")); - assertTrue(lines.stream().anyMatch(line -> line.contains("four") + assertEquals(List.of(new TerminalLine("history")), history); + assertTrue(surface.lines().size() <= 2); + assertTrue(surface.lines().stream().anyMatch(line -> line.contains("four") && line.contains(TuiRenderFrame.CURSOR_MARKER))); - assertTrue(lines.getLast().contains("ses_1")); + assertTrue(surface.lines().getLast().contains("ses_1")); } @Test void permissionPromptRendersAsBottomOverlay() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(5); PermissionUpdate rememberUpdate = new PermissionUpdate( PermissionRuleSource.SESSION, new PermissionRule( @@ -678,22 +581,20 @@ void permissionPromptRendersAsBottomOverlay() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(40, 9), ""); + List lines = render(renderer, view, new TuiLayout(40, 9), ""); assertInputBorder(lines.get(0), 40); assertInputContent(lines.get(1), "> "); - assertInputBorder(lines.get(2), 40); - assertEquals("permission toolu_1: Need approval", lines.get(3)); - assertEquals("rule: bash:npm test", lines.get(4)); - assertEquals("> 允许一次", lines.get(5)); - assertEquals(" 允许并记住", lines.get(6)); + assertEquals("permission toolu_1: Need approval", lines.get(2)); + assertEquals("rule: bash:npm test", lines.get(3)); + assertEquals("> 允许一次", lines.get(4)); + assertEquals(" 允许并记住", lines.get(5)); assertTrue(lines.getLast().contains("ses_1")); } @Test - void permissionPromptStaysVisibleWhenWorkingLineIsActiveInShortViewport() { + void permissionPromptTakesPriorityOverWorkingLineInShortViewport() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(2); PermissionPromptView prompt = new PermissionPromptView( "perm_toolu_1", "toolu_1", @@ -716,17 +617,16 @@ void permissionPromptStaysVisibleWhenWorkingLineIsActiveInShortViewport() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(40, 6), ""); + List lines = render(renderer, view, new TuiLayout(40, 6), ""); assertTrue(lines.stream().anyMatch(line -> line.contains("permission toolu_1"))); assertTrue(lines.stream().anyMatch(line -> line.contains("> 允许一次"))); - assertTrue(lines.stream().anyMatch(line -> line.contains("working (12s)"))); + assertFalse(lines.stream().anyMatch(line -> line.contains("working (12s)"))); } @Test void tallPermissionPromptKeepsSelectedOptionInputCursorAndStatusVisible() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(2); List options = java.util.stream.IntStream.rangeClosed(1, 8) .mapToObj(index -> new PermissionOption( "option_" + index, @@ -755,7 +655,7 @@ void tallPermissionPromptKeepsSelectedOptionInputCursorAndStatusVisible() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(40, 6), "draft", 5); + List lines = render(renderer, view, new TuiLayout(40, 6), "draft", 5); assertTrue(lines.size() <= 6); assertTrue(lines.stream().anyMatch(line -> line.contains("> Option 6"))); @@ -767,7 +667,6 @@ void tallPermissionPromptKeepsSelectedOptionInputCursorAndStatusVisible() { @Test void multilinePermissionPromptIsSplitIntoFrameLines() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(8); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -788,7 +687,7 @@ void multilinePermissionPromptIsSplitIntoFrameLines() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(80, 12), ""); + List lines = render(renderer, view, new TuiLayout(80, 12), ""); assertTrue(lines.stream().noneMatch(line -> line.contains("\n"))); int promptStart = lines.indexOf("permission toolu_1: REQUEST_PERMISSIONS"); @@ -803,7 +702,6 @@ void multilinePermissionPromptIsSplitIntoFrameLines() { @Test void diffViewIsRenderedInTranscriptArea() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(4); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -818,7 +716,7 @@ void diffViewIsRenderedInTranscriptArea() { )) ); - List lines = renderer.render(view, screen, new TuiLayout(40, 8), ""); + List lines = render(renderer, view, new TuiLayout(40, 9), ""); assertEquals("diff: 1 file changed", lines.get(0)); assertEquals("M src/App.java", lines.get(1)); @@ -827,9 +725,8 @@ void diffViewIsRenderedInTranscriptArea() { } @Test - void runtimeLineUsesTranscriptSpaceOnlyWhenActive() { + void runtimeLineAppearsOnlyInMutableSurface() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(3); TuiViewModel view = new TuiViewModel( List.of( new TuiMessageBlock("b1", "m1", "assistant", "line1", false), @@ -843,18 +740,14 @@ void runtimeLineUsesTranscriptSpaceOnlyWhenActive() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(40, 8), ""); + List lines = render(renderer, view, new TuiLayout(40, 8), ""); - assertEquals("line2", lines.get(0)); - assertEquals("line3", lines.get(1)); - assertTrue(isContentSeparator(lines.get(2))); + assertEquals(List.of("line1", "line2", "line3"), lines.subList(0, 3)); assertEquals("· retrying attempt 2 rate limit", lines.get(3)); assertInputBorder(lines.get(4), 40); assertInputContent(lines.get(5), "> "); assertInputBorder(lines.get(6), 40); assertTrue(lines.getLast().contains("ses_1")); - - TuiScreen screenWithoutRuntime = new TuiScreen(3); TuiViewModel withoutRuntime = new TuiViewModel( view.blocks(), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -862,16 +755,14 @@ void runtimeLineUsesTranscriptSpaceOnlyWhenActive() { Optional.empty(), Optional.empty() ); - List withoutRuntimeLines = renderer.render(withoutRuntime, screenWithoutRuntime, new TuiLayout(40, 8), ""); + List withoutRuntimeLines = render(renderer, withoutRuntime, new TuiLayout(40, 8), ""); - assertEquals("", withoutRuntimeLines.get(0)); - assertEquals(List.of("line1", "line2", "line3"), withoutRuntimeLines.subList(1, 4)); + assertEquals(List.of("line1", "line2", "line3"), withoutRuntimeLines.subList(0, 3)); } @Test void toolDetailsRenderBelowToolHeader() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(3); TuiViewModel view = new TuiViewModel( List.of(new TuiToolBlock("tool:1", "msg_1", "toolu_1", "bash", TuiToolState.DONE, "Bash", "stdout: ok\nexit 0", false)), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -880,7 +771,7 @@ void toolDetailsRenderBelowToolHeader() { Optional.empty() ); - List lines = renderer.render(view, screen, new TuiLayout(40, 7), ""); + List lines = render(renderer, view, new TuiLayout(40, 7), ""); assertEquals("done $ Bash", lines.get(0)); assertEquals(" stdout: ok", lines.get(1)); @@ -890,7 +781,6 @@ void toolDetailsRenderBelowToolHeader() { @Test void splitsToolRuntimeAndOverlayLogicalLinesIntoPhysicalFrameLines() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(20); TuiViewModel view = new TuiViewModel( List.of(new TuiToolBlock( "tool:1", @@ -909,9 +799,9 @@ void splitsToolRuntimeAndOverlayLogicalLinesIntoPhysicalFrameLines() { Optional.empty() ); - TuiRenderFrame frame = renderer.renderFrame( + TuiRenderFrame frame = renderFrame( + renderer, view, - screen, new TuiLayout(40, 20), "", 0, @@ -930,7 +820,6 @@ void splitsToolRuntimeAndOverlayLogicalLinesIntoPhysicalFrameLines() { @Test void statusBarNormalizesLogicalLineBreaksWithoutGrowingChrome() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState("ses_1", "gpt-5.4\r\nmini", "execute", "default"), @@ -939,7 +828,7 @@ void statusBarNormalizesLogicalLineBreaksWithoutGrowingChrome() { Optional.empty() ); - TuiRenderFrame frame = renderer.renderFrame(view, screen, new TuiLayout(40, 8), "", 0); + TuiRenderFrame frame = renderFrame(renderer, view, new TuiLayout(40, 8), "", 0); assertFalse(frame.lines().getLast().contains("\r")); assertFalse(frame.lines().getLast().contains("\n")); @@ -949,7 +838,6 @@ void statusBarNormalizesLogicalLineBreaksWithoutGrowingChrome() { @Test void narrowToolStatusFallbackAlsoNormalizesLogicalLineBreaks() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState("session-long", "model-long", "execute", "tool:\nrunning"), @@ -958,7 +846,7 @@ void narrowToolStatusFallbackAlsoNormalizesLogicalLineBreaks() { Optional.empty() ); - TuiRenderFrame frame = renderer.renderFrame(view, screen, new TuiLayout(20, 8), "", 0); + TuiRenderFrame frame = renderFrame(renderer, view, new TuiLayout(20, 8), "", 0); assertEquals("tool tool: running", frame.lines().getLast()); } @@ -966,7 +854,6 @@ void narrowToolStatusFallbackAlsoNormalizesLogicalLineBreaks() { @Test void bashToolCollapsedShowsCommandStatusSummaryAndTailPreview() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(20); TuiViewModel view = new TuiViewModel( List.of(new TuiToolBlock( "tool:1", @@ -984,7 +871,7 @@ void bashToolCollapsedShowsCommandStatusSummaryAndTailPreview() { Optional.empty() ); - List lines = renderer.renderFrame(view, screen, new TuiLayout(80, 30), "", -1, List.of(), false).lines(); + List lines = renderFrame(renderer, view, new TuiLayout(80, 30), "", -1, List.of(), false).lines(); assertTrue(lines.contains("failed $ mvn test")); assertTrue(lines.contains(" exit 1")); @@ -995,7 +882,6 @@ void bashToolCollapsedShowsCommandStatusSummaryAndTailPreview() { @Test void readEditAndUnknownToolsUseStructuredTitles() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(20); TuiViewModel view = new TuiViewModel( List.of( new TuiToolBlock("tool:read", "msg_1", "toolu_read", "read", TuiToolState.DONE, "src/App.java:1-80", "1 | class App {}\n2 |", false), @@ -1008,7 +894,7 @@ void readEditAndUnknownToolsUseStructuredTitles() { Optional.empty() ); - List lines = renderer.renderFrame(view, screen, new TuiLayout(80, 30), "", -1, List.of(), false).lines(); + List lines = renderFrame(renderer, view, new TuiLayout(80, 30), "", -1, List.of(), false).lines(); assertTrue(lines.contains("tools: read x1 (Ctrl+O details)")); assertTrue(lines.contains("done edit src/App.java +1 -1")); @@ -1018,7 +904,6 @@ void readEditAndUnknownToolsUseStructuredTitles() { @Test void readToolOutputNeverShowsFileContentAndExpandsToInvocationOnly() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(30); TuiViewModel view = new TuiViewModel( List.of(new TuiToolBlock( "tool:1", @@ -1038,8 +923,8 @@ void readToolOutputNeverShowsFileContentAndExpandsToInvocationOnly() { Optional.empty() ); - List collapsed = renderer.renderFrame(view, screen, new TuiLayout(80, 30), "", -1, List.of(), false).lines(); - List expanded = renderer.renderFrame(view, screen, new TuiLayout(80, 30), "", -1, List.of(), true).lines(); + List collapsed = renderFrame(renderer, view, new TuiLayout(80, 30), "", -1, List.of(), false).lines(); + List expanded = renderFrame(renderer, view, new TuiLayout(80, 30), "", -1, List.of(), true).lines(); assertFalse(collapsed.contains(" 1 | line 1")); assertFalse(collapsed.contains(" 11 | line 11")); @@ -1052,7 +937,6 @@ void readToolOutputNeverShowsFileContentAndExpandsToInvocationOnly() { @Test void writeToolStillShowsContentPreview() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(20); TuiViewModel view = new TuiViewModel( List.of(new TuiToolBlock( "tool:write", @@ -1070,7 +954,7 @@ void writeToolStillShowsContentPreview() { Optional.empty() ); - List lines = renderer.renderFrame(view, screen, new TuiLayout(80, 20), "", -1, List.of(), false).lines(); + List lines = renderFrame(renderer, view, new TuiLayout(80, 20), "", -1, List.of(), false).lines(); assertTrue(lines.contains("done write src/App.java")); assertTrue(lines.contains(" class App {}")); @@ -1079,7 +963,6 @@ void writeToolStillShowsContentPreview() { @Test void searchToolsCollapseToCountsAndExpandToInvocationOnly() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(20); TuiViewModel view = new TuiViewModel( List.of( new TuiToolBlock( @@ -1119,8 +1002,8 @@ void searchToolsCollapseToCountsAndExpandToInvocationOnly() { Optional.empty() ); - List collapsed = renderer.renderFrame(view, screen, new TuiLayout(80, 20), "", -1, List.of(), false).lines(); - List expanded = renderer.renderFrame(view, screen, new TuiLayout(80, 20), "", -1, List.of(), true).lines(); + List collapsed = renderFrame(renderer, view, new TuiLayout(80, 20), "", -1, List.of(), false).lines(); + List expanded = renderFrame(renderer, view, new TuiLayout(80, 20), "", -1, List.of(), true).lines(); assertTrue(collapsed.contains("tools: glob x1, read x1 (Ctrl+O details)")); assertTrue(collapsed.contains("tools: grep x1 (Ctrl+O details)")); @@ -1137,19 +1020,18 @@ void searchToolsCollapseToCountsAndExpandToInvocationOnly() { @Test void expandedToolOutputIsBoundedByAvailableTerminalRows() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(12); TuiViewModel view = new TuiViewModel( List.of(new TuiToolBlock( "tool:bash", "msg_1", "toolu_bash", "bash", - TuiToolState.DONE, + TuiToolState.RUNNING, "mvn test", String.join("\n", java.util.stream.IntStream.rangeClosed(1, 60) .mapToObj(index -> "stdout: line " + index) .toList()), - false + true )), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), List.of(), @@ -1157,9 +1039,9 @@ void expandedToolOutputIsBoundedByAvailableTerminalRows() { Optional.empty() ); - List lines = renderer.renderFrame( + List lines = renderFrame( + renderer, view, - screen, new TuiLayout(80, 12), "draft", 5, @@ -1167,7 +1049,7 @@ void expandedToolOutputIsBoundedByAvailableTerminalRows() { true ).lines(); - assertTrue(lines.size() <= 12, "expanded tool output should not exceed terminal height"); + assertTrue(lines.size() <= 11, "expanded tool output should not exceed surface height"); assertTrue(lines.stream().anyMatch(line -> line.contains("more lines") || line.contains("earlier lines"))); assertTrue(lines.stream().anyMatch(line -> line.contains("> draft"))); assertTrue(lines.stream().anyMatch(line -> line.contains("> /model"))); @@ -1175,7 +1057,7 @@ void expandedToolOutputIsBoundedByAvailableTerminalRows() { } @Test - void collapsedAndExpandedToolFramesShareGlobalTranscriptBudget() { + void collapsedAndExpandedActiveToolFramesShareSurfaceBudget() { String details = String.join("\n", java.util.stream.IntStream.rangeClosed(1, 100) .mapToObj(index -> "detail line " + index) .toList()); @@ -1185,10 +1067,10 @@ void collapsedAndExpandedToolFramesShareGlobalTranscriptBudget() { "msg_1", "toolu_" + index, "custom_tool", - TuiToolState.DONE, + TuiToolState.RUNNING, "call " + index, details, - false + true )) .toList(); TuiViewModel view = new TuiViewModel( @@ -1200,18 +1082,18 @@ void collapsedAndExpandedToolFramesShareGlobalTranscriptBudget() { ); TuiRenderer renderer = new TuiRenderer(); - TuiRenderFrame collapsed = renderer.renderFrame( + TuiRenderFrame collapsed = renderFrame( + renderer, view, - new TuiScreen(12), new TuiLayout(80, 12), "", -1, List.of(), false ); - TuiRenderFrame expanded = renderer.renderFrame( + TuiRenderFrame expanded = renderFrame( + renderer, view, - new TuiScreen(12), new TuiLayout(80, 12), "", -1, @@ -1244,11 +1126,9 @@ void narrowWrappedToolTitleStillLeavesAnOmissionMarkerWithinFiveRows() { Optional.empty(), Optional.empty() ); - - TuiScreen screen = new TuiScreen(20); - TuiRenderFrame frame = new TuiRenderer().renderFrame( + TuiRenderFrame frame = renderFrame( + new TuiRenderer(), view, - screen, new TuiLayout(20, 20), "", -1, @@ -1256,13 +1136,12 @@ void narrowWrappedToolTitleStillLeavesAnOmissionMarkerWithinFiveRows() { false ); - assertEquals(20, frame.lines().size()); - assertTrue(screen.retainedLineCount() <= 5); + assertTrue(frame.lines().size() <= 19); assertTrue(frame.lines().stream().anyMatch(line -> line.contains("more lines"))); } private void assertFrameFitsHeightWithOmissionMarker(TuiRenderFrame frame, int height) { - assertEquals(height, frame.lines().size()); + assertTrue(frame.lines().size() <= height - 1); assertTrue(frame.lines().stream() .anyMatch(line -> line.contains("more lines") || line.contains("earlier lines"))); } @@ -1270,7 +1149,6 @@ private void assertFrameFitsHeightWithOmissionMarker(TuiRenderFrame frame, int h @Test void slashOverlayRendersBelowInputBlock() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(2); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState("ses_1", "gpt-5.4", "execute", "default"), @@ -1279,29 +1157,27 @@ void slashOverlayRendersBelowInputBlock() { Optional.empty() ); - List lines = renderer.render( + List lines = render( + renderer, view, - screen, new TuiLayout(40, 7), "/", 1, List.of("> /model", " /thinking", " /compact") ); - assertEquals(7, lines.size()); + assertEquals(6, lines.size()); assertInputBorder(lines.get(0), 40); assertInputContent(lines.get(1), "> /|CURSOR|" + INPUT_CURSOR); - assertInputBorder(lines.get(2), 40); - assertTrue(lines.get(3).startsWith("> /model")); - assertTrue(lines.get(4).contains(" /thinking")); - assertTrue(lines.get(5).contains(" /compact")); - assertTrue(lines.get(6).contains("ses_1")); + assertTrue(lines.get(2).startsWith("> /model")); + assertTrue(lines.get(3).contains(" /thinking")); + assertTrue(lines.get(4).contains(" /compact")); + assertTrue(lines.get(5).contains("ses_1")); } @Test void overlayRendersBelowInputWithTranscriptContent() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(10); TuiViewModel view = new TuiViewModel( List.of( new TuiMessageBlock("b1", "m1", "assistant", "hello", false), @@ -1313,17 +1189,20 @@ void overlayRendersBelowInputWithTranscriptContent() { Optional.empty() ); - List withOverlay = renderer.render( - view, screen, new TuiLayout(40, 10), "/", 1, + List withOverlay = render( + renderer, view, new TuiLayout(40, 10), "/", 1, List.of("> /model", " /thinking", " /compact") ); - List withoutOverlay = renderer.render( - view, screen, new TuiLayout(40, 10), "/", 1 + List withoutOverlay = render( + renderer, view, new TuiLayout(40, 10), "/", 1 ); - assertEquals(10, withOverlay.size()); - assertEquals(10, withoutOverlay.size()); + assertTrue(withOverlay.size() <= 9); + assertTrue(withoutOverlay.size() <= 9); + assertTrue(withOverlay.size() > withoutOverlay.size()); + assertEquals(List.of("hello", "world"), withOverlay.subList(0, 2)); + assertEquals(List.of("hello", "world"), withoutOverlay.subList(0, 2)); int overlayIndex = -1; int inputBorderIndex = -1; for (int i = 0; i < withOverlay.size(); i++) { @@ -1342,8 +1221,6 @@ view, screen, new TuiLayout(40, 10), "/", 1 @Test void emptyOverlayProducesSameOutputAsNoOverlay() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen1 = new TuiScreen(5); - TuiScreen screen2 = new TuiScreen(5); TuiViewModel view = new TuiViewModel( List.of(new TuiMessageBlock("b1", "m1", "assistant", "test", false)), new StatusBarState("ses_1", "gpt-5.4", "ready", "default"), @@ -1352,16 +1229,93 @@ void emptyOverlayProducesSameOutputAsNoOverlay() { Optional.empty() ); - List withEmptyOverlay = renderer.render( - view, screen1, new TuiLayout(40, 8), "hello", 5, List.of() + List withEmptyOverlay = render( + renderer, view, new TuiLayout(40, 8), "hello", 5, List.of() ); - List withoutOverlay = renderer.render( - view, screen2, new TuiLayout(40, 8), "hello", 5 + List withoutOverlay = render( + renderer, view, new TuiLayout(40, 8), "hello", 5 ); assertEquals(withoutOverlay, withEmptyOverlay); } + private List render( + TuiRenderer renderer, + TuiViewModel view, + TuiLayout layout, + String input + ) { + return renderFrame(renderer, view, layout, input, -1).lines(); + } + + private List render( + TuiRenderer renderer, + TuiViewModel view, + TuiLayout layout, + String input, + int cursor + ) { + return renderFrame(renderer, view, layout, input, cursor).lines(); + } + + private List render( + TuiRenderer renderer, + TuiViewModel view, + TuiLayout layout, + String input, + int cursor, + List overlayLines + ) { + return renderFrame(renderer, view, layout, input, cursor, overlayLines).lines(); + } + + private TuiRenderFrame renderFrame( + TuiRenderer renderer, + TuiViewModel view, + TuiLayout layout, + String input, + int cursor + ) { + return renderFrame(renderer, view, layout, input, cursor, List.of()); + } + + private TuiRenderFrame renderFrame( + TuiRenderer renderer, + TuiViewModel view, + TuiLayout layout, + String input, + int cursor, + List overlayLines + ) { + return renderFrame(renderer, view, layout, input, cursor, overlayLines, false); + } + + private TuiRenderFrame renderFrame( + TuiRenderer renderer, + TuiViewModel view, + TuiLayout layout, + String input, + int cursor, + List overlayLines, + boolean toolOutputExpanded + ) { + TuiTranscriptPartition partition = new TuiTranscriptPartitioner().partition(view.blocks()); + List lines = new java.util.ArrayList<>(); + renderer.renderCommittedBlocks(partition.history(), layout.width()).stream() + .map(TerminalLine::text) + .forEach(lines::add); + lines.addAll(renderer.renderSurface( + view, + partition.live(), + layout, + input, + cursor, + overlayLines, + toolOutputExpanded + ).lines()); + return TuiRenderFrame.fromTextLines(lines); + } + private void assertInputBorder(String line, int width) { assertEquals(width, AnsiWidth.displayWidth(line)); assertTrue(line.contains("─")); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java deleted file mode 100644 index d3d58fec..00000000 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package cn.lypi.transport.tui; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.List; -import java.util.stream.IntStream; -import org.junit.jupiter.api.Test; - -class TuiScreenTest { - @Test - void retainsOnlyLatestFiveHundredPhysicalHistoryLines() { - TuiScreen screen = new TuiScreen(10); - screen.setTranscript(IntStream.rangeClosed(1, 501) - .mapToObj(index -> "line-" + index) - .toList()); - - assertEquals(500, screen.retainedLineCount()); - screen.scrollUp(1_000); - assertEquals("line-2", screen.visibleTranscript().getFirst()); - } - - @Test - void preservesScrolledContentWhenBoundedWindowReceivesAnotherLine() { - TuiScreen screen = new TuiScreen(10); - screen.setTranscript(historyLines(1, 500)); - screen.scrollUp(5); - List visibleBeforeAppend = screen.visibleTranscript(); - - screen.setTranscript(historyLines(1, 501)); - - assertEquals(6, screen.linesBelow()); - assertEquals(visibleBeforeAppend, screen.visibleTranscript()); - } - - @Test - void resetClearsRetainedHistoryAndScrollOffset() { - TuiScreen screen = new TuiScreen(10); - screen.setTranscript(historyLines(1, 20)); - screen.scrollUp(5); - - screen.reset(); - - assertEquals(0, screen.retainedLineCount()); - assertEquals(0, screen.linesBelow()); - assertEquals(List.of(), screen.visibleTranscript()); - } - - @Test - void visibleTranscriptReturnsOnlyViewportTailByDefault() { - TuiScreen screen = new TuiScreen(2); - - screen.setTranscript(List.of("a", "b")); - screen.setTranscript(List.of("a", "b", "c")); - - assertEquals(List.of("b", "c"), screen.visibleTranscript()); - assertEquals(0, screen.linesBelow()); - } - - @Test - void scrollUpShowsOlderTranscriptAndNewTranscriptPreservesOffset() { - TuiScreen screen = new TuiScreen(2); - screen.setTranscript(List.of("a", "b", "c", "d")); - screen.scrollUp(1); - - assertEquals(List.of("b", "c"), screen.visibleTranscript()); - assertEquals(1, screen.linesBelow()); - - screen.setTranscript(List.of("a", "b", "c", "d", "e")); - - assertEquals(List.of("b", "c"), screen.visibleTranscript()); - assertEquals(2, screen.linesBelow()); - - screen.scrollDown(2); - - assertEquals(List.of("d", "e"), screen.visibleTranscript()); - assertEquals(0, screen.linesBelow()); - } - - @Test - void pageScrollUsesViewportHeightMinusOne() { - TuiScreen screen = new TuiScreen(4); - screen.setTranscript(List.of("a", "b", "c", "d", "e", "f", "g", "h")); - - screen.scrollPageUp(); - - assertEquals(List.of("b", "c", "d", "e"), screen.visibleTranscript()); - assertEquals(3, screen.linesBelow()); - - screen.scrollPageDown(); - - assertEquals(List.of("e", "f", "g", "h"), screen.visibleTranscript()); - assertEquals(0, screen.linesBelow()); - } - - private List historyLines(int first, int last) { - return IntStream.rangeClosed(first, last) - .mapToObj(index -> "line-" + index) - .toList(); - } -} From 5466efe5a3767b1c9bffe99580a34a4c1f30ffdb Mon Sep 17 00:00:00 2001 From: lyfmt Date: Thu, 16 Jul 2026 02:01:33 +0800 Subject: [PATCH 50/58] refactor(tui): return scrollback controls to terminal --- .../transport/tui/KeyBindingRegistry.java | 4 -- .../java/cn/lypi/transport/tui/KeyMapper.java | 29 +------- .../transport/tui/TerminalInputAction.java | 2 - .../cn/lypi/transport/tui/TerminalKey.java | 4 -- .../cn/lypi/transport/tui/TuiInputLoop.java | 2 - .../transport/tui/KeyBindingRegistryTest.java | 5 +- .../cn/lypi/transport/tui/KeyMapperTest.java | 10 +-- .../transport/tui/TerminalInputPumpTest.java | 26 +++++++ .../lypi/transport/tui/TuiInputLoopTest.java | 67 ++----------------- 9 files changed, 37 insertions(+), 112 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyBindingRegistry.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyBindingRegistry.java index a10e594b..5874b79a 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyBindingRegistry.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyBindingRegistry.java @@ -26,10 +26,6 @@ static KeyBindingRegistry defaults() { bindings.put(TerminalKey.WORD_RIGHT, TerminalInputAction.MOVE_WORD_RIGHT); bindings.put(TerminalKey.UP, TerminalInputAction.PREVIOUS_HISTORY); bindings.put(TerminalKey.DOWN, TerminalInputAction.NEXT_HISTORY); - bindings.put(TerminalKey.MOUSE_WHEEL_UP, TerminalInputAction.SCROLL_TRANSCRIPT_UP); - bindings.put(TerminalKey.MOUSE_WHEEL_DOWN, TerminalInputAction.SCROLL_TRANSCRIPT_DOWN); - bindings.put(TerminalKey.PAGE_UP, TerminalInputAction.SCROLL_TRANSCRIPT_UP); - bindings.put(TerminalKey.PAGE_DOWN, TerminalInputAction.SCROLL_TRANSCRIPT_DOWN); bindings.put(TerminalKey.CTRL_O, TerminalInputAction.TOGGLE_TOOL_OUTPUT_EXPANDED); bindings.put(TerminalKey.EXPAND_TOOLS, TerminalInputAction.EXPAND_TOOLS); return new KeyBindingRegistry(bindings); diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyMapper.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyMapper.java index 9270a60f..d2fc830b 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyMapper.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/KeyMapper.java @@ -1,12 +1,8 @@ package cn.lypi.transport.tui; import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; final class KeyMapper { - private static final Pattern SGR_MOUSE = Pattern.compile("\\033\\[<(\\d+);\\d+;\\d+([Mm])"); - Optional map(String sequence) { return switch (sequence) { case "\u0003" -> Optional.of(TerminalKey.CTRL_C); @@ -28,34 +24,11 @@ Optional map(String sequence) { case "\033[1;5C" -> Optional.of(TerminalKey.WORD_RIGHT); case "\033[A", "\033OA" -> Optional.of(TerminalKey.UP); case "\033[B", "\033OB" -> Optional.of(TerminalKey.DOWN); - case "\033[5~" -> Optional.of(TerminalKey.PAGE_UP); - case "\033[6~" -> Optional.of(TerminalKey.PAGE_DOWN); case "\033[?u", "\033[65;129u", "\033[27;7;65u" -> Optional.empty(); - default -> mapSgrMouse(sequence).or(() -> filterKittyReleaseOrRepeat(sequence)); + default -> filterKittyReleaseOrRepeat(sequence); }; } - private Optional mapSgrMouse(String sequence) { - Matcher matcher = SGR_MOUSE.matcher(sequence); - if (!matcher.matches()) { - return Optional.empty(); - } - if (!"M".equals(matcher.group(2))) { - return Optional.of(TerminalKey.OTHER); - } - try { - int button = Integer.parseInt(matcher.group(1)); - if ((button & 64) == 0) { - return Optional.of(TerminalKey.OTHER); - } - return Optional.of((button & 1) == 0 - ? TerminalKey.MOUSE_WHEEL_UP - : TerminalKey.MOUSE_WHEEL_DOWN); - } catch (NumberFormatException ignored) { - return Optional.of(TerminalKey.OTHER); - } - } - private Optional filterKittyReleaseOrRepeat(String sequence) { if (sequence != null && sequence.matches("\\033\\[[0-9]+;(129|130)u")) { return Optional.empty(); diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalInputAction.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalInputAction.java index 316f7423..db6c8a08 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalInputAction.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalInputAction.java @@ -19,8 +19,6 @@ public enum TerminalInputAction { MOVE_WORD_RIGHT, PREVIOUS_HISTORY, NEXT_HISTORY, - SCROLL_TRANSCRIPT_UP, - SCROLL_TRANSCRIPT_DOWN, TOGGLE_THINKING, TOGGLE_TOOL_OUTPUT_EXPANDED, EXPAND_TOOLS, diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalKey.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalKey.java index 9c7dff92..05a986d6 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalKey.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalKey.java @@ -21,10 +21,6 @@ public enum TerminalKey { WORD_RIGHT, UP, DOWN, - MOUSE_WHEEL_UP, - MOUSE_WHEEL_DOWN, - PAGE_UP, - PAGE_DOWN, EXPAND_TOOLS, OTHER } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java index 23804b40..2efe87a8 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java @@ -276,8 +276,6 @@ void acceptKey(TerminalKey key) { case MOVE_WORD_RIGHT -> editor.moveWordRight(); case PREVIOUS_HISTORY -> editor.previousHistory(); case NEXT_HISTORY -> editor.nextHistory(); - case SCROLL_TRANSCRIPT_UP, SCROLL_TRANSCRIPT_DOWN -> { - } case TOGGLE_TOOL_OUTPUT_EXPANDED, EXPAND_TOOLS -> toolOutputExpanded = !toolOutputExpanded; default -> { } diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java index 289d0f1e..07950681 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java @@ -14,10 +14,7 @@ void defaultBindingsCoverPiStyleEditingAndThinkingShortcuts() { assertEquals(TerminalInputAction.DELETE_NEXT_WORD, registry.actionFor(TerminalKey.ALT_DELETE)); assertEquals(TerminalInputAction.TOGGLE_TOOL_OUTPUT_EXPANDED, registry.actionFor(TerminalKey.CTRL_O)); assertEquals(TerminalInputAction.INSERT_NEWLINE, registry.actionFor(TerminalKey.MODIFIED_ENTER)); - assertEquals(TerminalInputAction.SCROLL_TRANSCRIPT_UP, registry.actionFor(TerminalKey.MOUSE_WHEEL_UP)); - assertEquals(TerminalInputAction.SCROLL_TRANSCRIPT_DOWN, registry.actionFor(TerminalKey.MOUSE_WHEEL_DOWN)); - assertEquals(TerminalInputAction.SCROLL_TRANSCRIPT_UP, registry.actionFor(TerminalKey.PAGE_UP)); - assertEquals(TerminalInputAction.SCROLL_TRANSCRIPT_DOWN, registry.actionFor(TerminalKey.PAGE_DOWN)); + assertEquals(TerminalInputAction.NOOP, registry.actionFor(TerminalKey.OTHER)); } @Test diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyMapperTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyMapperTest.java index 091ae287..47a6a996 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyMapperTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyMapperTest.java @@ -29,8 +29,6 @@ void mapsModifiedEnterAndWordMovementSequences() { assertEquals(TerminalKey.RIGHT, mapper.map("\033[C").orElseThrow()); assertEquals(TerminalKey.UP, mapper.map("\033[A").orElseThrow()); assertEquals(TerminalKey.DOWN, mapper.map("\033OB").orElseThrow()); - assertEquals(TerminalKey.PAGE_UP, mapper.map("\033[5~").orElseThrow()); - assertEquals(TerminalKey.PAGE_DOWN, mapper.map("\033[6~").orElseThrow()); } @Test @@ -44,11 +42,13 @@ void mapsLineEditingAndYankKeys() { } @Test - void mapsSgrWheelPressesAndIgnoresOtherMouseEvents() { + void leavesNativeScrollbackAndMouseSequencesUnclaimed() { KeyMapper mapper = new KeyMapper(); - assertEquals(TerminalKey.MOUSE_WHEEL_UP, mapper.map("\033[<64;40;12M").orElseThrow()); - assertEquals(TerminalKey.MOUSE_WHEEL_DOWN, mapper.map("\033[<65;40;12M").orElseThrow()); + assertEquals(TerminalKey.OTHER, mapper.map("\033[5~").orElseThrow()); + assertEquals(TerminalKey.OTHER, mapper.map("\033[6~").orElseThrow()); + assertEquals(TerminalKey.OTHER, mapper.map("\033[<64;40;12M").orElseThrow()); + assertEquals(TerminalKey.OTHER, mapper.map("\033[<65;40;12M").orElseThrow()); assertEquals(TerminalKey.OTHER, mapper.map("\033[<0;40;12M").orElseThrow()); assertEquals(TerminalKey.OTHER, mapper.map("\033[<0;40;12m").orElseThrow()); assertEquals(TerminalKey.OTHER, mapper.map("\033[<32;40;12M").orElseThrow()); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalInputPumpTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalInputPumpTest.java index 96b17733..dba34526 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalInputPumpTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalInputPumpTest.java @@ -177,6 +177,32 @@ void flushesIncompleteBufferedSequenceWhenInputIsDrained() throws IOException { assertEquals(List.of("tail"), submit.submitted); } + @Test + void nativeScrollbackSequencesAreIgnoredWithoutChangingDraft() throws IOException { + TuiInputLoop loop = new TuiInputLoop( + new RecordingSubmitHandler(), + () -> { + }, + new TuiLayout(40, 4) + ); + loop.acceptText("draft"); + TerminalInputPump pump = new TerminalInputPump( + new QueueInputSource( + "\033[5~", + "\033[6~", + "\033[<64;40;12M", + "\033[<65;40;12M" + ), + new KeyMapper(), + loop + ); + + pump.drainAvailable(); + + assertEquals("draft", loop.draft()); + assertEquals(5, loop.cursor()); + } + @Test void keepsPendingPasteWhenInputIsTemporarilyDrained() throws IOException { RecordingSubmitHandler submit = new RecordingSubmitHandler(); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java index 5542bf8d..ada245d2 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInputLoopTest.java @@ -257,63 +257,11 @@ void rendersCursorAtCurrentEditorPosition() { } @Test - void pageKeysDoNotChangeInputOrRenderedProjection() { + void otherKeyDoesNotChangeInputOrRenderedProjection() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List> frames = new ArrayList<>(); - List blocks = java.util.stream.IntStream.rangeClosed(1, 10) - .mapToObj(index -> (TuiBlock) new TuiMessageBlock( - "b" + index, - "m" + index, - "assistant", - "line" + index, - false - )) - .toList(); TuiViewModel view = new TuiViewModel( - blocks, - new StatusBarState("ses_1", "gpt-5.4", "ready", "default"), List.of(), - Optional.empty(), - Optional.empty() - ); - TuiInputLoop loop = testLoop( - submit, - lines -> frames.add(List.copyOf(lines)), - new TuiLayout(40, 6), - () -> view - ); - loop.acceptText("draft"); - int cursor = loop.cursor(); - List beforePageKey = frames.getLast(); - - loop.acceptKey(TerminalKey.PAGE_UP); - - assertEquals("draft", loop.draft()); - assertEquals(cursor, loop.cursor()); - assertEquals(beforePageKey, frames.getLast()); - - loop.acceptKey(TerminalKey.PAGE_DOWN); - - assertEquals("draft", loop.draft()); - assertEquals(cursor, loop.cursor()); - assertEquals(beforePageKey, frames.getLast()); - } - - @Test - void mouseWheelDoesNotChangeInputWhileArrowUpStillBrowsesInputHistory() { - RecordingSubmitHandler submit = new RecordingSubmitHandler(); - List> frames = new ArrayList<>(); - List blocks = java.util.stream.IntStream.rangeClosed(1, 30) - .mapToObj(index -> (TuiBlock) new TuiMessageBlock( - "b" + index, - "m" + index, - "assistant", - "history-" + index, - false - )) - .toList(); - TuiViewModel view = new TuiViewModel( - blocks, new StatusBarState("ses_1", "gpt-5.4", "ready", "default"), List.of(), Optional.empty(), @@ -325,22 +273,15 @@ void mouseWheelDoesNotChangeInputWhileArrowUpStillBrowsesInputHistory() { new TuiLayout(40, 6), () -> view ); - loop.acceptText("previous prompt"); - loop.acceptKey(TerminalKey.ENTER); loop.acceptText("draft"); int cursor = loop.cursor(); - List beforeWheel = frames.getLast(); + List beforeOtherKey = frames.getLast(); - loop.acceptKey(TerminalKey.MOUSE_WHEEL_UP); + loop.acceptKey(TerminalKey.OTHER); assertEquals("draft", loop.draft()); assertEquals(cursor, loop.cursor()); - assertEquals(beforeWheel, frames.getLast()); - - loop.acceptKey(TerminalKey.CTRL_U); - loop.acceptKey(TerminalKey.UP); - - assertEquals("previous prompt", loop.draft()); + assertEquals(beforeOtherKey, frames.getLast()); } @Test From c76f643152c8ccebc4bc9b80d8663b299dd6e21c Mon Sep 17 00:00:00 2001 From: lyfmt Date: Thu, 16 Jul 2026 02:06:03 +0800 Subject: [PATCH 51/58] test(tui): lock inline scrollback commit protocol --- .../JLineTuiTransportRenderPipelineTest.java | 149 ++++++++++++++++++ .../tui/TuiContractEndToEndTest.java | 41 +++++ 2 files changed, 190 insertions(+) diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java index babdd287..6f57ce8c 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportRenderPipelineTest.java @@ -18,7 +18,10 @@ import cn.lypi.contracts.event.EventEnvelope; import cn.lypi.contracts.event.EventFilter; import cn.lypi.contracts.event.EventSubscription; +import cn.lypi.contracts.event.MessageBlockSnapshot; import cn.lypi.contracts.event.MessageDeltaEvent; +import cn.lypi.contracts.event.MessageEndEvent; +import cn.lypi.contracts.event.MessageStartEvent; import cn.lypi.contracts.event.PermissionRequestEvent; import cn.lypi.contracts.event.ProviderFallbackEndEvent; import cn.lypi.contracts.event.ProviderFallbackStartEvent; @@ -203,6 +206,114 @@ void restoredHistoryCommitsAllPhysicalLinesWithoutApplicationTruncation() { assertFalse(surfaceText(first).contains("history-line-")); } + @Test + void streamingFinalizationCommitsFinalTextOnceAndInputEditsDoNotReplayIt() throws Exception { + RecordingEventBus events = new RecordingEventBus(); + RecordingFrameSink sink = new RecordingFrameSink(); + QueueInputSource input = new QueueInputSource(); + JLineTuiTransport transport = JLineTuiTransport.withBatchInput( + sink, + 80, + 10, + input, + new RecordingSubmitHandler() + ); + transport.attach(events, TestRuntimeStates.basic("ses_1")); + + events.emit(new MessageStartEvent( + "ses_1", + "msg_stream", + MessageRole.ASSISTANT, + MessageKind.TEXT, + Map.of(), + Instant.parse("2026-06-09T00:00:00Z") + )); + events.emit(textDelta("stream-first", false)); + transport.renderCurrentFrameUnderUiLock(); + + TuiRenderBatch intermediate = sink.batches.getLast(); + assertTrue(intermediate.historyLines().isEmpty()); + assertTrue(surfaceText(intermediate).contains("stream-first")); + + int finalPhaseStart = sink.batches.size(); + events.emit(textDelta("-final", true)); + events.emit(new MessageEndEvent( + "ses_1", + "msg_stream", + MessageRole.ASSISTANT, + MessageKind.TEXT, + List.of(new MessageBlockSnapshot( + "block_stream", + ContentBlockKind.TEXT, + "stream-first-final", + Map.of() + )), + Optional.empty(), + Optional.of("stop"), + Map.of(), + Instant.parse("2026-06-09T00:00:01Z") + )); + transport.renderCurrentFrameUnderUiLock(); + + long finalCommits = sink.batches.subList(finalPhaseStart, sink.batches.size()).stream() + .flatMap(batch -> batch.historyLines().stream()) + .map(TerminalLine::text) + .filter("stream-first-final"::equals) + .count(); + assertEquals(1, finalCommits); + assertFalse(surfaceText(sink.batches.getLast()).contains("stream-first")); + + input.add("draft"); + transport.drainInputForTest(); + + TuiRenderBatch inputBatch = sink.batches.getLast(); + assertTrue(inputBatch.historyLines().isEmpty()); + assertTrue(surfaceText(inputBatch).contains("> draft")); + } + + @Test + void transientRuntimeToolAndPermissionUpdatesNeverCommitHistory() { + RecordingEventBus events = new RecordingEventBus(); + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchRenderer(sink, 80, 12); + transport.attach(events, TestRuntimeStates.basic("ses_1")); + + events.emit(new TurnStartEvent( + "ses_1", + "turn_1", + Instant.parse("2026-06-09T00:00:00Z") + )); + transport.renderCurrentFrameUnderUiLock(); + assertTrue(sink.batches.getLast().historyLines().isEmpty()); + assertTrue(surfaceText(sink.batches.getLast()).contains("working (")); + + events.emit(new ToolStartEvent( + "ses_1", + "toolu_1", + "bash", + Instant.parse("2026-06-09T00:00:01Z") + )); + events.emit(new ToolProgressEvent( + "ses_1", + "toolu_1", + ToolProgress.output("stdout", "progress-only\n"), + Instant.parse("2026-06-09T00:00:02Z") + )); + transport.renderCurrentFrameUnderUiLock(); + assertTrue(sink.batches.getLast().historyLines().isEmpty()); + assertTrue(surfaceText(sink.batches.getLast()).contains("progress-only")); + + events.emit(new PermissionRequestEvent( + "ses_1", + "toolu_1", + "Need approval", + Instant.parse("2026-06-09T00:00:03Z") + )); + transport.renderCurrentFrameUnderUiLock(); + assertTrue(sink.batches.getLast().historyLines().isEmpty()); + assertTrue(surfaceText(sink.batches.getLast()).contains("permission toolu_1: Need approval")); + } + @Test void toolProgressBurstReducesImmediatelyAndCoalescesTerminalFrames() { RecordingEventBus events = new RecordingEventBus(); @@ -512,6 +623,44 @@ void resizeAndPageKeysDoNotReplayCommittedTranscript() throws Exception { assertTrue(sink.batches.getLast().historyLines().isEmpty()); } + @Test + void resizeRoundTripPreservesDraftCursorAndDoesNotReplayHistory() throws Exception { + RecordingEventBus events = new RecordingEventBus(); + RecordingFrameSink sink = new RecordingFrameSink(); + QueueInputSource input = new QueueInputSource("draft", "\033[D", "\033[D"); + JLineTuiTransport transport = JLineTuiTransport.withBatchInput( + sink, + 80, + 12, + input, + new RecordingSubmitHandler() + ); + transport.attach(events, historyRuntimeState("ses_1", 3)); + transport.renderCurrentFrameUnderUiLock(); + assertEquals(3, sink.batches.getLast().historyLines().size()); + transport.drainInputForTest(); + + transport.resizeForTest(60, 8); + TuiRenderBatch narrow = sink.batches.getLast(); + assertTrue(narrow.historyLines().isEmpty()); + assertEquals( + inputContent("> dra|CURSOR|" + INPUT_CURSOR + "ft"), + inputLine(narrow.surface().lines()) + ); + narrow.surface().lines().forEach(line -> assertEquals( + line.replace(TuiRenderFrame.CURSOR_MARKER, ""), + AnsiWidth.truncate(line.replace(TuiRenderFrame.CURSOR_MARKER, ""), 60) + )); + + transport.resizeForTest(80, 12); + TuiRenderBatch restored = sink.batches.getLast(); + assertTrue(restored.historyLines().isEmpty()); + assertEquals( + inputContent("> dra|CURSOR|" + INPUT_CURSOR + "ft"), + inputLine(restored.surface().lines()) + ); + } + @Test void inputRerenderPreservesCommittedTranscriptWithoutReplayingIt() throws Exception { RecordingEventBus events = new RecordingEventBus(); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java index 32e17e4e..797e8145 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java @@ -66,6 +66,47 @@ class TuiContractEndToEndTest { private static final Instant NOW = Instant.parse("2026-06-07T10:00:00Z"); + @Test + void messageLifecycleBecomesCommitEligibleOnlyAfterMessageEnd() { + TuiEventReducer reducer = new TuiEventReducer(); + TuiTranscriptPartitioner partitioner = new TuiTranscriptPartitioner(); + TuiTranscriptCommitLedger ledger = new TuiTranscriptCommitLedger(); + TuiProjectionKey key = new TuiProjectionKey("ses_1", "leaf_1"); + + reducer.reduce(new MessageStartEvent( + "ses_1", + "msg_stream", + MessageRole.ASSISTANT, + MessageKind.TEXT, + Map.of(), + NOW + )); + reducer.reduce(delta("msg_stream", "block_stream", ContentBlockKind.TEXT, "stream-final", false)); + + TuiTranscriptPartition streaming = partitioner.partition(reducer.view().blocks()); + assertTrue(streaming.history().isEmpty()); + assertTrue(ledger.advance(key, streaming.history()).isEmpty()); + + reducer.reduce(new MessageEndEvent( + "ses_1", + "msg_stream", + MessageRole.ASSISTANT, + MessageKind.TEXT, + List.of(), + Optional.empty(), + Optional.of("stop"), + Map.of(), + NOW.plusMillis(1) + )); + + TuiTranscriptPartition completed = partitioner.partition(reducer.view().blocks()); + List committed = ledger.advance(key, completed.history()); + assertEquals(1, committed.size()); + TuiMessageBlock message = assertInstanceOf(TuiMessageBlock.class, committed.getFirst()); + assertEquals("stream-final", message.content()); + assertTrue(ledger.advance(key, completed.history()).isEmpty()); + } + @Test void assistantThinkingToolLifecycleAndWindowedOutputRenderFromSemanticEvents() { TuiEventReducer reducer = new TuiEventReducer(); From 47efc9a1b764d701197a47ece8ad2f18c253e63d Mon Sep 17 00:00:00 2001 From: lyfmt Date: Thu, 16 Jul 2026 13:10:50 +0800 Subject: [PATCH 52/58] fix(tui): preserve scrollback across resize and close Probe the post-resize cursor to account for terminal line reflow, and preserve the shell cursor while resetting scroll regions on close. --- .../transport/tui/InlineTerminalRenderer.java | 54 ++++++++++++++++++- .../tui/JLineTerminalInputSource.java | 30 ++++++----- .../lypi/transport/tui/JLineTerminalIo.java | 6 +++ .../lypi/transport/tui/JLineTuiTransport.java | 27 ++++++++-- .../transport/tui/TerminalCursorProbe.java | 47 ++++++++-------- .../cn/lypi/transport/tui/TerminalIo.java | 9 ++++ .../lypi/transport/tui/TerminalSession.java | 6 +++ .../tui/InlineTerminalRendererTest.java | 37 ++++++++++++- .../transport/tui/JLineTuiTransportTest.java | 45 +++++++++++++++- .../transport/tui/TerminalSessionTest.java | 4 +- 10 files changed, 217 insertions(+), 48 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java index d2f447e9..b30e6d4c 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java @@ -18,6 +18,7 @@ final class InlineTerminalRenderer { private InlineViewport renderedViewport; private List previousSurface = List.of(); private Optional previousCursor = Optional.empty(); + private Optional resizeCursorPosition = Optional.empty(); private boolean geometryDirty; private boolean finished; @@ -62,11 +63,17 @@ && sameGeometry(renderedViewport, nextViewport) renderedViewport = finalViewport; previousSurface = surface.lines(); previousCursor = surface.cursor(); + resizeCursorPosition = Optional.empty(); geometryDirty = false; } void resize(int width, int height) { + resize(width, height, Optional.empty()); + } + + void resize(int width, int height, Optional cursorPosition) { viewport = viewport.resize(width, height); + resizeCursorPosition = cursorPosition == null ? Optional.empty() : cursorPosition; geometryDirty = true; } @@ -97,6 +104,7 @@ void finish() throws IOException { } previousSurface = List.of(); previousCursor = Optional.empty(); + resizeCursorPosition = Optional.empty(); if (failure != null) { throw failure; } @@ -115,8 +123,9 @@ private InlineViewport writeRenderTransaction( || geometryDirty || !sameGeometry(renderedViewport, nextViewport); if (geometryChanged && renderedViewport != null) { - scrollCommittedRowsForUpwardViewport(renderedViewport, nextViewport); - clearSurfaceUnion(renderedViewport, nextViewport); + InlineViewport physicalPrevious = physicalViewportAfterResize(renderedViewport, nextViewport); + scrollCommittedRowsForUpwardViewport(physicalPrevious, nextViewport); + clearSurfaceUnion(physicalPrevious, nextViewport); } boolean linearHistoryInsertion = requiresLinearHistoryInsertion(history, nextViewport); finalViewport = insertHistory(history, nextViewport); @@ -148,6 +157,47 @@ private InlineViewport writeRenderTransaction( return finalViewport; } + private InlineViewport physicalViewportAfterResize( + InlineViewport previous, + InlineViewport next + ) { + int nextHeight = next.terminalHeight(); + int cursorRow = reflowedCursorRow(next.width()); + int physicalTop = resizeCursorPosition + .map(position -> Math.max(0, position.row() - cursorRow)) + .orElseGet(() -> { + int previousCursorRow = previous.top() + cursorRow; + int terminalScroll = Math.max(0, previousCursorRow - (nextHeight - 1)); + return Math.max(0, previous.top() - terminalScroll); + }); + physicalTop = Math.min(physicalTop, nextHeight - 1); + int physicalHeight = Math.min(reflowedSurfaceHeight(next.width()), nextHeight - physicalTop); + return new InlineViewport(physicalTop, physicalHeight, next.width(), nextHeight); + } + + private int reflowedCursorRow(int width) { + SurfaceCursor cursor = previousCursor.orElseGet(() -> new SurfaceCursor( + previousSurface.size() - 1, + AnsiWidth.displayWidth(previousSurface.getLast().text()) + )); + int row = 0; + for (int index = 0; index < cursor.row(); index++) { + row += reflowedLineHeight(previousSurface.get(index), width); + } + return row + cursor.column() / width; + } + + private int reflowedSurfaceHeight(int width) { + return previousSurface.stream() + .mapToInt(line -> reflowedLineHeight(line, width)) + .sum(); + } + + private int reflowedLineHeight(TerminalLine line, int width) { + int displayWidth = AnsiWidth.displayWidth(line.text()); + return Math.max(1, (displayWidth + width - 1) / width); + } + private void scrollCommittedRowsForUpwardViewport( InlineViewport previous, InlineViewport next diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTerminalInputSource.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTerminalInputSource.java index 9e1491a7..32f408e3 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTerminalInputSource.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTerminalInputSource.java @@ -31,21 +31,23 @@ private JLineTerminalInputSource(NonBlockingReader reader, String replayInput) { @Override public Optional read() throws IOException { - if (!replayInput.isEmpty()) { - String replay = replayInput; - replayInput = ""; - return Optional.of(replay); + synchronized (reader) { + if (!replayInput.isEmpty()) { + String replay = replayInput; + replayInput = ""; + return Optional.of(replay); + } + int first = reader.read(FIRST_CHARACTER_TIMEOUT_MILLIS); + if (first == NonBlockingReader.READ_EXPIRED || first == NonBlockingReader.EOF) { + return Optional.empty(); + } + StringBuilder chunk = new StringBuilder(); + chunk.append((char) first); + if (first == '\033') { + readEscapeContinuation(chunk); + } + return Optional.of(chunk.toString()); } - int first = reader.read(FIRST_CHARACTER_TIMEOUT_MILLIS); - if (first == NonBlockingReader.READ_EXPIRED || first == NonBlockingReader.EOF) { - return Optional.empty(); - } - StringBuilder chunk = new StringBuilder(); - chunk.append((char) first); - if (first == '\033') { - readEscapeContinuation(chunk); - } - return Optional.of(chunk.toString()); } private void readEscapeContinuation(StringBuilder chunk) throws IOException { diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTerminalIo.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTerminalIo.java index 7ccfbd4a..59344e91 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTerminalIo.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTerminalIo.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.io.IOError; +import java.time.Duration; import org.jline.terminal.Attributes; import org.jline.terminal.Terminal; @@ -49,6 +50,11 @@ public int height() { } } + @Override + public CursorProbeResult queryCursor(Duration timeout) throws IOException { + return TerminalCursorProbe.query(terminal, timeout); + } + @Override public AutoCloseable onResize(Runnable callback) throws IOException { Terminal.SignalHandler previous = terminal.handle(Terminal.Signal.WINCH, signal -> callback.run()); diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java index c5a40be8..7be970cb 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java @@ -1214,17 +1214,36 @@ private void resize(int width, int height) { if (reducer == null) { return; } - updateViewport(width, height); + updateViewportFromTerminal(width, height); redrawScheduler.renderNow(this::renderCurrentFrame); } } - private void updateViewport(int width, int height) { + private void updateViewportFromTerminal(int width, int height) { + CursorProbeResult probe = queryCursorAfterResize(); + updateViewport(width, height, probe.position()); + if (inputPump != null && !probe.replayInput().isEmpty()) { + inputPump.dispatchChunk(probe.replayInput()); + } + } + + private CursorProbeResult queryCursorAfterResize() { + if (terminalIo == null) { + return new CursorProbeResult(Optional.empty(), ""); + } + try { + return terminalIo.queryCursor(CURSOR_PROBE_TIMEOUT); + } catch (IOException | RuntimeException ignored) { + return new CursorProbeResult(Optional.empty(), ""); + } + } + + private void updateViewport(int width, int height, Optional cursorPosition) { int safeWidth = safeWidth(width); int safeHeight = safeHeight(height); layout = new TuiLayout(safeWidth, safeHeight); if (inlineTerminalRenderer != null) { - inlineTerminalRenderer.resize(safeWidth, safeHeight); + inlineTerminalRenderer.resize(safeWidth, safeHeight, cursorPosition); } if (inputLoop != null) { inputLoop.updateLayout(layout); @@ -1240,7 +1259,7 @@ private void reconcileTerminalSize() { int resolvedWidth = currentWidth > 0 ? currentWidth : layout.width(); int resolvedHeight = currentHeight > 1 ? currentHeight : layout.height(); if (layout.width() != resolvedWidth || layout.height() != resolvedHeight) { - updateViewport(resolvedWidth, resolvedHeight); + updateViewportFromTerminal(resolvedWidth, resolvedHeight); } } diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java index f4f40637..b98cb30e 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java @@ -23,31 +23,34 @@ static CursorProbeResult query(Terminal terminal, Duration timeout) throws IOExc throw new IllegalArgumentException("cursor probe timeout must be non-negative"); } - terminal.writer().write("\033[6n"); - terminal.flush(); + NonBlockingReader reader = terminal.reader(); + synchronized (reader) { + terminal.writer().write("\033[6n"); + terminal.flush(); - long deadline = System.nanoTime() + timeout.toNanos(); - StringBuilder response = new StringBuilder(); - while (true) { - long remainingNanos = deadline - System.nanoTime(); - if (remainingNanos <= 0) { - break; - } - long remainingMillis = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNanos)); - int next = terminal.reader().read(remainingMillis); - if (next == NonBlockingReader.EOF) { - break; - } - if (next == NonBlockingReader.READ_EXPIRED) { - continue; - } - response.append((char) next); - CursorProbeResult parsed = parse(response.toString()); - if (parsed.position().isPresent()) { - return parsed; + long deadline = System.nanoTime() + timeout.toNanos(); + StringBuilder response = new StringBuilder(); + while (true) { + long remainingNanos = deadline - System.nanoTime(); + if (remainingNanos <= 0) { + break; + } + long remainingMillis = Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNanos)); + int next = reader.read(remainingMillis); + if (next == NonBlockingReader.EOF) { + break; + } + if (next == NonBlockingReader.READ_EXPIRED) { + continue; + } + response.append((char) next); + CursorProbeResult parsed = parse(response.toString()); + if (parsed.position().isPresent()) { + return parsed; + } } + return parse(response.toString()); } - return parse(response.toString()); } static CursorProbeResult parse(String input) { diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalIo.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalIo.java index ae682865..f57fd3c5 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalIo.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalIo.java @@ -1,6 +1,8 @@ package cn.lypi.transport.tui; import java.io.IOException; +import java.time.Duration; +import java.util.Optional; interface TerminalIo { /** @@ -28,6 +30,13 @@ interface TerminalIo { */ int height(); + /** + * 有界查询 resize 后的硬件 cursor,并返回查询期间读到的普通输入。 + */ + default CursorProbeResult queryCursor(Duration timeout) throws IOException { + return new CursorProbeResult(Optional.empty(), ""); + } + /** * 注册 resize 回调,并返回用于恢复原信号处理器的句柄。 */ diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java index 98ac3318..3d2c4ce9 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java @@ -5,6 +5,8 @@ import org.jline.terminal.TerminalBuilder; public final class TerminalSession implements AutoCloseable { + static final String SAVE_CURSOR = "\0337"; + static final String RESTORE_CURSOR = "\0338"; static final String RESET_SCROLL_REGION = "\033[r"; static final String ENABLE_BRACKETED_PASTE = "\033[?2004h"; static final String DISABLE_BRACKETED_PASTE = "\033[?2004l"; @@ -80,9 +82,11 @@ public void close() throws Exception { } closed = true; try { + io.write(SAVE_CURSOR); io.write(RESET_SCROLL_REGION); io.write(DISABLE_MODIFY_OTHER_KEYS); io.write(DISABLE_BRACKETED_PASTE); + io.write(RESTORE_CURSOR); io.write(SHOW_CURSOR); io.flush(); } finally { @@ -106,9 +110,11 @@ private static void restoreAfterOpenFailure( AutoCloseable resizeHandler, AutoCloseable rawMode ) { + writeStaticQuietly(io, SAVE_CURSOR); writeStaticQuietly(io, RESET_SCROLL_REGION); writeStaticQuietly(io, DISABLE_MODIFY_OTHER_KEYS); writeStaticQuietly(io, DISABLE_BRACKETED_PASTE); + writeStaticQuietly(io, RESTORE_CURSOR); writeStaticQuietly(io, SHOW_CURSOR); flushStaticQuietly(io); closeStaticQuietly(interruptHandler); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java index 200043d3..2cd8c73b 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.Test; class InlineTerminalRendererTest { @@ -219,7 +220,7 @@ void finishClearsSurfaceResetsRegionAndIsIdempotent() throws Exception { } @Test - void resizeRoundTripMovesViewportWithoutRewritingHistory() throws Exception { + void resizeRoundTripAccountsForTerminalCursorShiftWithoutRewritingHistory() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(80, 8); InlineTerminalRenderer renderer = new InlineTerminalRenderer( io, @@ -236,10 +237,13 @@ void resizeRoundTripMovesViewportWithoutRewritingHistory() throws Exception { renderer.render(new TuiRenderBatch(List.of(), surface)); String shrinkOutput = io.output.toString(); - assertTrue(shrinkOutput.contains("\033[1;5r\033[5;1H\r\n\r\n\033[r")); + assertTrue(shrinkOutput.contains("\033[1;4r\033[4;1H\r\n\033[r")); + assertFalse(shrinkOutput.contains("\033[1;5r")); assertFalse(shrinkOutput.contains("committed")); assertFalse(shrinkOutput.contains("\033[2J")); assertFalse(shrinkOutput.contains("\033[3J")); + assertTrue(shrinkOutput.contains("\033[4;1H\033[2Klive")); + assertTrue(shrinkOutput.contains("\033[6;1H\033[2Kstatus")); io.resetOutput(); io.setDimensions(80, 8); @@ -249,10 +253,39 @@ void resizeRoundTripMovesViewportWithoutRewritingHistory() throws Exception { String growOutput = io.output.toString(); assertFalse(growOutput.contains("committed")); assertFalse(growOutput.contains("\r\n")); + assertTrue(growOutput.contains("\033[4;1H\033[2K")); assertTrue(growOutput.contains("\033[6;1H\033[2Klive")); assertTrue(growOutput.contains("\033[8;1H\033[2Kstatus")); } + @Test + void resizeUsesReportedCursorAfterTerminalReflowsSurfaceLines() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(80, 12); + InlineTerminalRenderer renderer = new InlineTerminalRenderer( + io, + new InlineViewport(7, 5, 80, 12) + ); + String border = "─".repeat(80); + TuiRenderFrame surface = TuiRenderFrame.fromTextLines(List.of( + "stream-intermediate", + border, + "> draft|CURSOR|", + border, + "status" + )); + renderer.render(new TuiRenderBatch(List.of(), surface)); + io.resetOutput(); + + renderer.resize(60, 9, Optional.of(new TerminalPosition(2, 8))); + renderer.render(new TuiRenderBatch(List.of(), surface)); + + String output = io.output.toString(); + assertTrue(output.contains("\033[1;5r\033[5;1H\r\n\033[r")); + assertFalse(output.contains("\033[1;6r")); + assertFalse(output.contains("\033[2J")); + assertFalse(output.contains("\033[3J")); + } + @Test void rejectsCursorMarkerInHistoryBeforeWritingTerminalOutput() { RecordingTerminalIo io = new RecordingTerminalIo(80, 8); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java index df143174..8ca54199 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java @@ -54,6 +54,7 @@ import java.lang.reflect.Proxy; import java.math.BigDecimal; import java.nio.file.Path; +import java.time.Duration; import java.time.Instant; import java.util.ArrayDeque; import java.util.ArrayList; @@ -127,9 +128,11 @@ void openAssemblesTerminalSessionRendererInputAndEventSubscription() throws Exce assertTrue(io.rawModeRestored); assertFalse(io.output.toString().contains("\033[?1049")); assertTrue(io.output.toString().endsWith( - TerminalSession.RESET_SCROLL_REGION + TerminalSession.SAVE_CURSOR + + TerminalSession.RESET_SCROLL_REGION + TerminalSession.DISABLE_MODIFY_OTHER_KEYS + TerminalSession.DISABLE_BRACKETED_PASTE + + TerminalSession.RESTORE_CURSOR + TerminalSession.SHOW_CURSOR )); } @@ -345,9 +348,11 @@ void closeAfterHistoryCommitClearsSurfaceAndRestoresModesWithoutPromptNewline() assertTrue(io.output.toString().contains("\033[?2026h")); assertTrue(io.output.toString().contains("\033[2K")); assertTrue(io.output.toString().endsWith( - TerminalSession.RESET_SCROLL_REGION + TerminalSession.SAVE_CURSOR + + TerminalSession.RESET_SCROLL_REGION + TerminalSession.DISABLE_MODIFY_OTHER_KEYS + TerminalSession.DISABLE_BRACKETED_PASTE + + TerminalSession.RESTORE_CURSOR + TerminalSession.SHOW_CURSOR )); assertFalse(io.output.toString().contains("\033[?1049")); @@ -450,6 +455,34 @@ void openResizeCallbackReadsCurrentTerminalDimensions() throws Exception { transport.close(); } + @Test + void resizeCursorProbeReplaysInputReadBeforePositionReport() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(); + io.cursorProbeResult = new CursorProbeResult( + Optional.of(new TerminalPosition(2, 4)), + "x" + ); + RecordingEventBus events = new RecordingEventBus(); + JLineTuiTransport transport = JLineTuiTransport.open( + runtimeState(), + events, + io, + new QueueInputSource(), + new RecordingSubmitHandler(), + 40, + 4 + ); + + io.width = 20; + io.height = 6; + io.resizeCallback.run(); + + assertEquals(1, io.cursorProbeQueries); + assertEquals(1, transport.currentDraftLengthForTest()); + assertTrue(io.output.toString().contains("> x")); + transport.close(); + } + @Test void renderReconcilesTerminalSizeWhenResizeSignalIsDelayed() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); @@ -1045,6 +1078,8 @@ private static final class RecordingTerminalIo implements TerminalIo { private boolean rawModeRestored; private int width = 40; private int height = 4; + private int cursorProbeQueries; + private CursorProbeResult cursorProbeResult = new CursorProbeResult(Optional.empty(), ""); private Runnable resizeCallback = () -> { }; @@ -1085,6 +1120,12 @@ public int width() { public int height() { return height; } + + @Override + public CursorProbeResult queryCursor(Duration timeout) { + cursorProbeQueries++; + return cursorProbeResult; + } } private static final class FailingInitialFrameTerminalIo implements TerminalIo { diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java index 71c28721..95ece565 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java @@ -30,7 +30,7 @@ void openEntersInteractiveTerminalModesAndCloseRestoresThem() throws Exception { assertTrue(io.rawModeRestored); assertEquals( "\033[?2004h\033[?25l\033[>4;2m" - + "\033[r\033[>4m\033[?2004l\033[?25h", + + "\0337\033[r\033[>4m\033[?2004l\0338\033[?25h", io.output.toString() ); } @@ -79,7 +79,7 @@ void openFailureRestoresTerminalResourcesWithoutAlternateScreenModes() { assertThrows(IOException.class, () -> TerminalSession.open(io)); assertEquals( - "\033[?2004h\033[r\033[>4m\033[?2004l\033[?25h", + "\033[?2004h\0337\033[r\033[>4m\033[?2004l\0338\033[?25h", io.output.toString() ); assertFalse(io.output.toString().contains("\033[?1049")); From 3541623a18adb377f11cd5e422283a9aa14b37ed Mon Sep 17 00:00:00 2001 From: lyfmt Date: Thu, 16 Jul 2026 13:12:58 +0800 Subject: [PATCH 53/58] test(tui): verify native scrollback in tmux pty --- .../lypi/transport/tui/TuiFramePtyProbe.java | 19 +- .../transport/tui/TuiInteractionPtyProbe.java | 64 ++++- .../src/test/resources/run-tui-frame-pty.sh | 102 +++----- .../test/resources/run-tui-interaction-pty.sh | 220 +++++++++++++++--- .../src/test/resources/run-tui-smoke.sh | 16 +- 5 files changed, 285 insertions(+), 136 deletions(-) diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java index f248406b..245dccd0 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java @@ -2,8 +2,6 @@ import cn.lypi.contracts.tui.StatusBarState; import cn.lypi.contracts.tui.TuiMessageBlock; -import cn.lypi.contracts.tui.TuiToolBlock; -import cn.lypi.contracts.tui.TuiToolState; import cn.lypi.contracts.tui.TuiViewModel; import java.nio.file.Files; import java.nio.file.Path; @@ -22,6 +20,8 @@ public static void main(String[] args) throws Exception { } Path readyFile = Path.of(args[0]); Path exitFile = Path.of(args[1]); + System.out.print("SHELL_SENTINEL\n"); + System.out.flush(); Terminal terminal = TerminalBuilder.builder().system(true).build(); TerminalIo io = new JLineTerminalIo(terminal); try (TerminalSession session = TerminalSession.open(io)) { @@ -82,19 +82,10 @@ private static TuiViewModel view(String runtimeLine) { return new TuiViewModel( List.of( new TuiMessageBlock("history:pty", "message:history", "assistant", "history stable", false), - new TuiToolBlock( - "tool:pty", - "message:pty", - "tool-use:pty", - "bash", - TuiToolState.RUNNING, - "tool one\ntool two", - "", - true - ) + new TuiMessageBlock("live:pty", "message:live", "assistant", "stream/live row", true) ), - new StatusBarState("status", "", "", ""), - runtimeLine, + new StatusBarState(runtimeLine, "", "", ""), + "", List.of(), Optional.empty(), Optional.empty() diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInteractionPtyProbe.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInteractionPtyProbe.java index 2da9b32d..bdf664c1 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInteractionPtyProbe.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInteractionPtyProbe.java @@ -14,11 +14,15 @@ import java.nio.file.Path; import java.time.Instant; import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicLong; import org.jline.terminal.Terminal; import org.jline.terminal.TerminalBuilder; public final class TuiInteractionPtyProbe { + private static final int INITIAL_HISTORY_BLOCKS = 42; + private TuiInteractionPtyProbe() { } @@ -39,24 +43,49 @@ public static void main(String[] args) throws Exception { terminal.getWidth(), terminal.getHeight() )) { - for (int index = 1; index <= 30; index++) { - events.emit(message("msg_" + index, "block_" + index, "history-" + index, true)); + for (int index = 1; index <= INITIAL_HISTORY_BLOCKS; index++) { + String suffix = "%03d".formatted(index); + events.emit(message( + "msg_" + suffix, + "block_" + suffix, + "history-sentinel-" + suffix, + true + )); } transport.flushPendingFrameForTest(); Files.writeString(controlDirectory.resolve("ready"), "ready"); - Thread emitter = Thread.ofVirtual().start(() -> { + AtomicReference controllerFailure = new AtomicReference<>(); + Thread controller = Thread.ofVirtual().start(() -> { try { - await(controlDirectory.resolve("emit-first")); - events.emit(message("msg_stream", "block_stream", "stream-first", false)); + await(controlDirectory.resolve("emit-intermediate")); + events.emit(message("msg_stream", "block_stream", "stream-intermediate", false)); + transport.flushPendingFrameForTest(); + signal(controlDirectory.resolve("intermediate-emitted")); + + await(controlDirectory.resolve("resize-small")); + awaitSize(terminal, 60, 9); + transport.flushPendingFrameForTest(); + signal(controlDirectory.resolve("resize-small-processed")); + + await(controlDirectory.resolve("resize-large")); + awaitSize(terminal, 80, 12); + transport.flushPendingFrameForTest(); + signal(controlDirectory.resolve("resize-large-processed")); + await(controlDirectory.resolve("emit-final")); events.emit(message("msg_stream", "block_stream", "-final", true)); - } catch (Exception exception) { - throw new IllegalStateException(exception); + transport.flushPendingFrameForTest(); + signal(controlDirectory.resolve("final-emitted")); + } catch (Throwable failure) { + controllerFailure.set(failure); } }); transport.runUntilExit(); - emitter.join(); + controller.join(); + if (controllerFailure.get() != null) { + throw new IllegalStateException("PTY controller failed", controllerFailure.get()); + } } } @@ -76,7 +105,7 @@ private static MessageDeltaEvent message(String messageId, String blockId, Strin } private static void await(Path signal) throws Exception { - long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15); while (!Files.exists(signal)) { if (System.nanoTime() >= deadline) { throw new IllegalStateException("timed out waiting for " + signal.getFileName()); @@ -85,6 +114,23 @@ private static void await(Path signal) throws Exception { } } + private static void awaitSize(Terminal terminal, int width, int height) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15); + while (terminal.getWidth() != width || terminal.getHeight() != height) { + if (System.nanoTime() >= deadline) { + throw new IllegalStateException( + "timed out waiting for terminal size " + width + "x" + height + + ", current=" + terminal.getWidth() + "x" + terminal.getHeight() + ); + } + Thread.sleep(10L); + } + } + + private static void signal(Path path) throws Exception { + Files.writeString(path, "done"); + } + private static final class ProbeEventBus implements EventBus { private final AtomicLong sequence = new AtomicLong(); private volatile EventConsumer consumer; diff --git a/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh b/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh index 788787ca..c224a3b7 100755 --- a/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh +++ b/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh @@ -27,11 +27,11 @@ READY_FILE="$TMP_DIR/ready" EXIT_FILE="$TMP_DIR/exit" printf -v PTY_COMMAND \ - 'printf "SHELL_SENTINEL\n"; TERM=xterm-256color java -cp %q cn.lypi.transport.tui.TuiFramePtyProbe %q %q; status=$?; printf "\nLYPI_TUI_FRAME_EXIT=%%s\n" "$status"; sleep 30' \ + 'TERM=xterm-256color java -cp %q cn.lypi.transport.tui.TuiFramePtyProbe %q %q; status=$?; printf "\nLYPI_TUI_FRAME_EXIT=%%s\n" "$status"; sleep 30' \ "$PROBE_CLASSPATH" "$READY_FILE" "$EXIT_FILE" tmux -L "$TMUX_SOCKET" new-session -d -x 60 -y 12 "$PTY_COMMAND" -ALTERNATE_CAPTURE="$TMP_DIR/alternate-pane.txt" +FULL_CAPTURE="$TMP_DIR/full-pane.txt" probe_ready=false for _ in $(seq 1 100); do if [[ -f "$READY_FILE" ]]; then @@ -41,106 +41,62 @@ for _ in $(seq 1 100); do sleep 0.05 done -tmux -L "$TMUX_SOCKET" capture-pane -p >"$ALTERNATE_CAPTURE" +tmux -L "$TMUX_SOCKET" capture-pane -p -S - >"$FULL_CAPTURE" if [[ "$probe_ready" != true ]]; then echo "tui frame PTY probe did not become ready" >&2 - cat "$ALTERNATE_CAPTURE" >&2 - exit 1 -fi -if grep -Fq "SHELL_SENTINEL" "$ALTERNATE_CAPTURE"; then - echo "primary screen leaked into alternate-screen capture" >&2 - cat "$ALTERNATE_CAPTURE" >&2 - exit 1 -fi -if grep -Fq "status-old" "$ALTERNATE_CAPTURE"; then - echo "stale status-old remained in final pane" >&2 - cat "$ALTERNATE_CAPTURE" >&2 - exit 1 -fi -if [[ "$(wc -l <"$ALTERNATE_CAPTURE")" -ne 12 ]]; then - echo "alternate-screen frame height does not match tmux pane" >&2 - cat "$ALTERNATE_CAPTURE" >&2 + cat "$FULL_CAPTURE" >&2 exit 1 fi expected_lines=( + 'SHELL_SENTINEL' 'history stable' - 'running $ tool one' - 'tool two' - '· status-updated' + 'stream/live row' '> input|' - 'status' + 'status-updated' ) -previous_line=0 for expected in "${expected_lines[@]}"; do - mapfile -t matches < <(grep -nxF "$expected" "$ALTERNATE_CAPTURE" || true) + mapfile -t matches < <(grep -nxF "$expected" "$FULL_CAPTURE" || true) if [[ "${#matches[@]}" -ne 1 ]]; then - echo "expected exactly one final pane line: $expected" >&2 - cat "$ALTERNATE_CAPTURE" >&2 - exit 1 - fi - current_line="${matches[0]%%:*}" - if (( current_line <= previous_line )); then - echo "final pane line order is incorrect at: $expected" >&2 - cat "$ALTERNATE_CAPTURE" >&2 + echo "expected exactly one full-capture line: $expected" >&2 + cat "$FULL_CAPTURE" >&2 exit 1 fi - previous_line="$current_line" done - -mapfile -t separators < <(grep -nE '^┄+$' "$ALTERNATE_CAPTURE" || true) -if [[ "${#separators[@]}" -ne 1 ]]; then - echo "expected exactly one history/live separator" >&2 - cat "$ALTERNATE_CAPTURE" >&2 - exit 1 -fi -separator_line="${separators[0]%%:*}" -history_line="$(grep -nxF 'history stable' "$ALTERNATE_CAPTURE")" -history_line="${history_line%%:*}" -running_line="$(grep -nxF 'running $ tool one' "$ALTERNATE_CAPTURE")" -running_line="${running_line%%:*}" -if (( history_line >= separator_line || running_line <= separator_line )); then - echo "history/live regions are ordered incorrectly" >&2 - cat "$ALTERNATE_CAPTURE" >&2 - exit 1 -fi -if [[ "$(grep -nxF '> input|' "$ALTERNATE_CAPTURE")" != 10:* ]]; then - echo "input is not on the fixed bottom content row" >&2 - cat "$ALTERNATE_CAPTURE" >&2 - exit 1 -fi -if [[ "$(grep -nxF 'status' "$ALTERNATE_CAPTURE")" != 12:* ]]; then - echo "status is not on the bottom pane row" >&2 - cat "$ALTERNATE_CAPTURE" >&2 +if grep -Fq "status-old" "$FULL_CAPTURE"; then + echo "stale status entered terminal scrollback" >&2 + cat "$FULL_CAPTURE" >&2 exit 1 fi : >"$EXIT_FILE" -PRIMARY_CAPTURE="$TMP_DIR/primary-pane.txt" +POST_CLOSE_CAPTURE="$TMP_DIR/post-close-pane.txt" probe_finished=false for _ in $(seq 1 100); do - tmux -L "$TMUX_SOCKET" capture-pane -p >"$PRIMARY_CAPTURE" - if grep -Fq "LYPI_TUI_FRAME_EXIT=" "$PRIMARY_CAPTURE"; then + tmux -L "$TMUX_SOCKET" capture-pane -p -S - >"$POST_CLOSE_CAPTURE" + if grep -Fq "LYPI_TUI_FRAME_EXIT=" "$POST_CLOSE_CAPTURE"; then probe_finished=true break fi sleep 0.05 done -if [[ "$probe_finished" != true ]] || ! grep -Fxq "LYPI_TUI_FRAME_EXIT=0" "$PRIMARY_CAPTURE"; then +if [[ "$probe_finished" != true ]] || ! grep -Fxq "LYPI_TUI_FRAME_EXIT=0" "$POST_CLOSE_CAPTURE"; then echo "tui frame PTY probe did not close cleanly" >&2 - cat "$PRIMARY_CAPTURE" >&2 + cat "$POST_CLOSE_CAPTURE" >&2 exit 1 fi -if ! grep -Fxq "SHELL_SENTINEL" "$PRIMARY_CAPTURE"; then - echo "primary screen was not restored" >&2 - cat "$PRIMARY_CAPTURE" >&2 - exit 1 -fi -for tui_line in 'history stable' 'running $ tool one' '· status-updated' '> input|'; do - if grep -Fq "$tui_line" "$PRIMARY_CAPTURE"; then - echo "TUI content remained after leaving alternate screen: $tui_line" >&2 - cat "$PRIMARY_CAPTURE" >&2 +for retained in 'SHELL_SENTINEL' 'history stable'; do + if [[ "$(grep -Fxc "$retained" "$POST_CLOSE_CAPTURE" || true)" -ne 1 ]]; then + echo "expected retained scrollback line exactly once after close: $retained" >&2 + cat "$POST_CLOSE_CAPTURE" >&2 + exit 1 + fi +done +for mutable in 'stream/live row' '> input|' 'status-old' 'status-updated'; do + if grep -Fxq "$mutable" "$POST_CLOSE_CAPTURE"; then + echo "mutable surface remained after close: $mutable" >&2 + cat "$POST_CLOSE_CAPTURE" >&2 exit 1 fi done diff --git a/lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh b/lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh index c3975429..7bc8b7a3 100644 --- a/lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh +++ b/lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh @@ -8,6 +8,10 @@ if ! command -v tmux >/dev/null 2>&1; then echo "tmux is required for TUI interaction PTY" >&2 exit 1 fi +if ! command -v script >/dev/null 2>&1; then + echo "script is required for attached tmux client input" >&2 + exit 1 +fi mvn -q -pl lypi-transport-tui -am test-compile @@ -27,14 +31,27 @@ CONTROL_DIR="$TMP_DIR/control" mkdir -p "$CONTROL_DIR" printf -v PTY_COMMAND \ - 'TERM=xterm-256color java -cp %q cn.lypi.transport.tui.TuiInteractionPtyProbe %q; printf "\nLYPI_TUI_INTERACTION_EXIT=%%s\n" "$?"; sleep 20' \ + 'printf "SHELL_INTERACTION_SENTINEL\n"; TERM=xterm-256color java -cp %q cn.lypi.transport.tui.TuiInteractionPtyProbe %q; status=$?; printf "LYPI_TUI_INTERACTION_EXIT=%%s\n" "$status"; IFS= read -r shell_input; printf "SHELL_INPUT=%%s\n" "$shell_input"; sleep 20' \ "$PROBE_CLASSPATH" "$CONTROL_DIR" tmux -L "$TMUX_SOCKET" new-session -d -x 80 -y 12 "$PTY_COMMAND" +tmux -L "$TMUX_SOCKET" set-option -g mouse on PANE_CAPTURE="$TMP_DIR/pane.txt" +FULL_CAPTURE="$TMP_DIR/full-pane.txt" +CLIENT_LOG="$TMP_DIR/client.log" +PANE_TARGET="0:0.0" + +capture_visible() { + tmux -L "$TMUX_SOCKET" capture-pane -p -t "$PANE_TARGET" >"$PANE_CAPTURE" +} + +capture_full() { + tmux -L "$TMUX_SOCKET" capture-pane -p -S - -t "$PANE_TARGET" >"$FULL_CAPTURE" +} + wait_for_file() { local path="$1" - for _ in $(seq 1 200); do + for _ in $(seq 1 300); do [[ -f "$path" ]] && return 0 sleep 0.05 done @@ -43,81 +60,216 @@ wait_for_file() { wait_for_pane_text() { local expected="$1" - for _ in $(seq 1 200); do - tmux -L "$TMUX_SOCKET" capture-pane -p >"$PANE_CAPTURE" + for _ in $(seq 1 300); do + capture_visible grep -Fq -- "$expected" "$PANE_CAPTURE" && return 0 sleep 0.05 done return 1 } -if ! wait_for_file "$CONTROL_DIR/ready" || ! wait_for_pane_text "history-30"; then +wait_for_full_text() { + local expected="$1" + for _ in $(seq 1 300); do + capture_full + grep -Fq -- "$expected" "$FULL_CAPTURE" && return 0 + sleep 0.05 + done + return 1 +} + +assert_exact_count() { + local expected="$1" + local wanted="$2" + local actual + actual="$(grep -Fxc -- "$expected" "$FULL_CAPTURE" || true)" + if [[ "$actual" -ne "$wanted" ]]; then + echo "expected $wanted exact full-capture line(s), found $actual: $expected" >&2 + cat "$FULL_CAPTURE" >&2 + exit 1 + fi +} + +assert_substring_count() { + local expected="$1" + local wanted="$2" + local actual + actual="$(grep -Fc -- "$expected" "$FULL_CAPTURE" || true)" + if [[ "$actual" -ne "$wanted" ]]; then + echo "expected $wanted full-capture match(es), found $actual: $expected" >&2 + cat "$FULL_CAPTURE" >&2 + exit 1 + fi +} + +assert_committed_history_once() { + local index suffix sentinel + for index in $(seq 1 42); do + printf -v suffix '%03d' "$index" + sentinel="history-sentinel-$suffix" + assert_exact_count "$sentinel" 1 + done +} + +if ! wait_for_file "$CONTROL_DIR/ready" || ! wait_for_full_text "history-sentinel-042"; then echo "TUI interaction probe did not render initial history" >&2 - cat "$PANE_CAPTURE" >&2 || true + cat "$FULL_CAPTURE" >&2 || true exit 1 fi +assert_committed_history_once +assert_exact_count "SHELL_INTERACTION_SENTINEL" 1 +assert_substring_count "ses_1" 1 -tmux -L "$TMUX_SOCKET" send-keys -l "draft" -if ! wait_for_pane_text "> draft"; then +tmux -L "$TMUX_SOCKET" send-keys -t "$PANE_TARGET" -l "draft" +if ! wait_for_pane_text "> draft|"; then echo "TUI interaction probe did not render the input draft" >&2 cat "$PANE_CAPTURE" >&2 exit 1 fi -: >"$CONTROL_DIR/emit-first" -if ! wait_for_pane_text "stream-first"; then - echo "intermediate streaming frame was not visible before final delta" >&2 - cat "$PANE_CAPTURE" >&2 +: >"$CONTROL_DIR/emit-intermediate" +if ! wait_for_file "$CONTROL_DIR/intermediate-emitted" \ + || ! wait_for_pane_text "stream-intermediate"; then + echo "intermediate streaming frame was not visible" >&2 + cat "$PANE_CAPTURE" >&2 || true + exit 1 +fi +capture_full +assert_committed_history_once +assert_exact_count "stream-intermediate" 1 +assert_exact_count "> draft|" 1 +assert_substring_count "ses_1" 1 + +tmux -L "$TMUX_SOCKET" resize-window -t 0 -x 60 -y 9 +: >"$CONTROL_DIR/resize-small" +if ! wait_for_file "$CONTROL_DIR/resize-small-processed"; then + echo "TUI interaction probe did not process 60x9 resize" >&2 + capture_full + cat "$FULL_CAPTURE" >&2 + exit 1 +fi +if [[ "$(tmux -L "$TMUX_SOCKET" display-message -p -t "$PANE_TARGET" '#{pane_width}x#{pane_height}')" != "60x9" ]]; then + echo "tmux pane did not reach 60x9" >&2 + exit 1 +fi +capture_full +assert_committed_history_once +assert_exact_count "stream-intermediate" 1 +assert_exact_count "> draft|" 1 +assert_substring_count "ses_1" 1 + +tmux -L "$TMUX_SOCKET" resize-window -t 0 -x 80 -y 12 +: >"$CONTROL_DIR/resize-large" +if ! wait_for_file "$CONTROL_DIR/resize-large-processed"; then + echo "TUI interaction probe did not process 80x12 resize" >&2 + capture_full + cat "$FULL_CAPTURE" >&2 exit 1 fi -if grep -Fq -- "stream-first-final" "$PANE_CAPTURE"; then - echo "final delta appeared before its signal" >&2 +if [[ "$(tmux -L "$TMUX_SOCKET" display-message -p -t "$PANE_TARGET" '#{pane_width}x#{pane_height}')" != "80x12" ]]; then + echo "tmux pane did not return to 80x12" >&2 exit 1 fi +capture_full +assert_committed_history_once +assert_exact_count "stream-intermediate" 1 +assert_exact_count "> draft|" 1 +assert_substring_count "ses_1" 1 : >"$CONTROL_DIR/emit-final" -if ! wait_for_pane_text "stream-first-final"; then +if ! wait_for_file "$CONTROL_DIR/final-emitted" \ + || ! wait_for_pane_text "stream-intermediate-final"; then echo "final streaming frame was not visible" >&2 - cat "$PANE_CAPTURE" >&2 + cat "$PANE_CAPTURE" >&2 || true exit 1 fi +capture_full +assert_committed_history_once +assert_exact_count "stream-intermediate" 0 +assert_exact_count "stream-intermediate-final" 1 +assert_exact_count "> draft|" 1 +assert_substring_count "ses_1" 1 -if ! grep -Fq -- "history-30" "$PANE_CAPTURE"; then - echo "latest history was not visible before mouse wheel input" >&2 - cat "$PANE_CAPTURE" >&2 +printf -v ATTACH_COMMAND \ + 'stty rows 12 cols 80; exec tmux -L %q attach-session -t 0' \ + "$TMUX_SOCKET" +{ + sleep 0.5 + printf '\033[<64;40;6M' + sleep 0.15 + printf '\033[<64;40;6M' + sleep 0.15 + printf '\002d' +} | TERM=xterm-256color timeout 8 script -q -e -c "$ATTACH_COMMAND" "$CLIENT_LOG" >/dev/null + +pane_state="$(tmux -L "$TMUX_SOCKET" display-message -p -t "$PANE_TARGET" \ + '#{pane_in_mode} #{scroll_position} #{pane_mode}')" +read -r pane_in_mode scroll_position pane_mode <<<"$pane_state" +if [[ "$pane_in_mode" != "1" \ + || "$pane_mode" != "copy-mode" \ + || ! "$scroll_position" =~ ^[0-9]+$ \ + || "$scroll_position" -le 0 ]]; then + echo "real mouse wheel did not enter tmux copy-mode: $pane_state" >&2 + cat "$CLIENT_LOG" >&2 exit 1 fi -tmux -L "$TMUX_SOCKET" send-keys -l $'\033[<64;40;6M' -for _ in $(seq 1 200); do - tmux -L "$TMUX_SOCKET" capture-pane -p >"$PANE_CAPTURE" - if ! grep -Fq -- "history-30" "$PANE_CAPTURE"; then - break - fi +tmux -L "$TMUX_SOCKET" send-keys -t "$PANE_TARGET" -X cancel +for _ in $(seq 1 100); do + [[ "$(tmux -L "$TMUX_SOCKET" display-message -p -t "$PANE_TARGET" '#{pane_in_mode}')" == "0" ]] && break sleep 0.05 done -if grep -Fq -- "history-30" "$PANE_CAPTURE"; then - echo "mouse wheel did not move the visible history window" >&2 - cat "$PANE_CAPTURE" >&2 +if [[ "$(tmux -L "$TMUX_SOCKET" display-message -p -t "$PANE_TARGET" '#{pane_in_mode}')" != "0" ]]; then + echo "tmux pane did not leave copy-mode" >&2 exit 1 fi -if ! grep -Fq -- "> draft" "$PANE_CAPTURE"; then - echo "mouse wheel changed the input draft" >&2 +if ! wait_for_pane_text "> draft|"; then + echo "native scrollback interaction changed the TUI draft" >&2 cat "$PANE_CAPTURE" >&2 exit 1 fi +capture_full +assert_committed_history_once +assert_exact_count "stream-intermediate" 0 +assert_exact_count "stream-intermediate-final" 1 +assert_exact_count "> draft|" 1 +assert_substring_count "ses_1" 1 -tmux -L "$TMUX_SOCKET" send-keys C-u +tmux -L "$TMUX_SOCKET" send-keys -t "$PANE_TARGET" C-u if ! wait_for_pane_text "> |"; then echo "TUI interaction probe did not clear the draft before exit" >&2 cat "$PANE_CAPTURE" >&2 exit 1 fi -tmux -L "$TMUX_SOCKET" send-keys C-c -if ! wait_for_pane_text "LYPI_TUI_INTERACTION_EXIT=0"; then +capture_full +assert_committed_history_once +assert_exact_count "> draft|" 0 +assert_exact_count "> |" 1 +assert_substring_count "ses_1" 1 + +tmux -L "$TMUX_SOCKET" send-keys -t "$PANE_TARGET" C-c +if ! wait_for_full_text "LYPI_TUI_INTERACTION_EXIT=0"; then echo "TUI interaction probe did not exit cleanly" >&2 - cat "$PANE_CAPTURE" >&2 + cat "$FULL_CAPTURE" >&2 exit 1 fi +tmux -L "$TMUX_SOCKET" send-keys -t "$PANE_TARGET" -l "shell-after-exit" +tmux -L "$TMUX_SOCKET" send-keys -t "$PANE_TARGET" Enter +if ! wait_for_full_text "SHELL_INPUT=shell-after-exit"; then + echo "shell did not take over input after TUI close" >&2 + cat "$FULL_CAPTURE" >&2 + exit 1 +fi + +capture_full +assert_committed_history_once +assert_exact_count "SHELL_INTERACTION_SENTINEL" 1 +assert_exact_count "stream-intermediate" 0 +assert_exact_count "stream-intermediate-final" 1 +assert_exact_count "> draft|" 0 +assert_exact_count "> |" 0 +assert_substring_count "ses_1" 0 +assert_exact_count "LYPI_TUI_INTERACTION_EXIT=0" 1 +assert_exact_count "SHELL_INPUT=shell-after-exit" 1 echo "tui interaction PTY passed" diff --git a/lypi-transport-tui/src/test/resources/run-tui-smoke.sh b/lypi-transport-tui/src/test/resources/run-tui-smoke.sh index 4aaae994..a0f47281 100644 --- a/lypi-transport-tui/src/test/resources/run-tui-smoke.sh +++ b/lypi-transport-tui/src/test/resources/run-tui-smoke.sh @@ -43,16 +43,12 @@ printf -v PTY_COMMAND 'TERM=xterm-256color java -cp %q TuiPtyProbe' "$TMP_DIR:$P timeout 15 script -q -e -c "$PTY_COMMAND" "$PTY_OUTPUT" >/dev/null expected_sequences=( - $'\033[?1049h' - $'\033[?1000h' - $'\033[?1006h' $'\033[?2004h' $'\033[?25l' + $'\033[>4;2m' "LYPI_TUI_PTY_OPEN" + $'\033[>4m' $'\033[?2004l' - $'\033[?1006l' - $'\033[?1000l' - $'\033[?1049l' $'\033[?25h' "LYPI_TUI_PTY_CLOSED" ) @@ -71,6 +67,14 @@ for expected in "${expected_sequences[@]}"; do previous_offset="$offset" done +for forbidden in $'\033[?1049h' $'\033[?1049l' \ + $'\033[?1000h' $'\033[?1006h'; do + if LC_ALL=C grep -aFq "$forbidden" "$PTY_OUTPUT"; then + echo "forbidden PTY mode sequence found: $(printf '%q' "$forbidden")" >&2 + exit 1 + fi +done + bash "$ROOT/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh" bash "$ROOT/lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh" From 67112c7efe3dac4ff23e0c96e914bd5184b19a80 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Thu, 16 Jul 2026 13:19:17 +0800 Subject: [PATCH 54/58] docs(tui): record inline scrollback invariants --- .codex/skills/lypi-transport-tui/SKILL.md | 47 ++++++++++++++--------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/.codex/skills/lypi-transport-tui/SKILL.md b/.codex/skills/lypi-transport-tui/SKILL.md index 62b319cc..42cbcdc9 100644 --- a/.codex/skills/lypi-transport-tui/SKILL.md +++ b/.codex/skills/lypi-transport-tui/SKILL.md @@ -24,10 +24,13 @@ The TUI adapts input and renders state. Durable behavior belongs in session, age - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/RuntimeTuiSubmitHandler.java` - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java` - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptPartitioner.java` -- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiContentLayout.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptCommitLedger.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderBatch.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiLayout.java` - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java` -- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java` -- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineViewport.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java` - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java` - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java` - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java` @@ -39,25 +42,29 @@ The TUI adapts input and renders state. Durable behavior belongs in session, age ## Event Flow -1. `JLineTuiTransport.open()` enters the alternate screen and wires terminal IO, input, reducer and rendering. +1. `JLineTuiTransport.open()` enters normal-screen interactive modes, probes the cursor with a timeout and replays non-CPR input. 2. `RuntimeTuiSubmitHandler.submitUserInput()` routes slash commands and resolves Skill mentions. 3. Normal input becomes `TurnRequest` and executes asynchronously. 4. `TuiEventReducer.reduce()` projects message, tool, permission, retry, compact, interrupt and session events. 5. `TuiTranscriptPartitioner` splits the stable prefix from the live tail without storing durable state. -6. `TuiRenderer` renders bounded history, live content and bottom chrome into one terminal-height frame. -7. `TerminalFrameRenderer` diffs physical rows by absolute screen position; it never sends transcript content to terminal scrollback. +6. `TuiTranscriptCommitLedger` emits each stable block once per `(sessionId, leafId)` projection. +7. `TuiRenderer` renders new committed blocks separately from the bounded live/input/overlay/status surface. +8. `InlineTerminalRenderer` inserts committed lines above the viewport and diffs only the mutable surface in one synchronized terminal transaction. ## Invariants - Reducers should not invent durable transcript content. They project events into display state. -- The stable transcript prefix becomes history. The first streaming, active, pending or running block starts the live tail. -- `TuiScreen` retains at most the latest 500 rendered history lines; session transcript and JSONL remain untouched. -- PageUp and PageDown scroll only history. Live content and bottom chrome stay on fixed rows. +- The first streaming, active, pending or running block starts the live tail; only the stable prefix can be committed. +- A stable block ID is committed at most once per projection key, including after transient stable/live regressions. +- Committed transcript is terminal-native scrollback. There is no application-side 500-line history window or scroll offset. +- PageUp, PageDown and mouse wheel never enter an application history model or mutate the draft; scrollback remains terminal/tmux-owned. - Historical tools always use their completed collapsed rendering. Ctrl+O affects tools in the live region only. -- Frames produced by `TuiRenderer` match terminal height. `TerminalFrameRenderer` rejects positive-height overflow instead of cropping it. -- First render and resize clear the alternate screen; normal redraws patch changed physical rows without `CRLF` scrolling. -- `TerminalSession` must pair DECSET/DECRST 1049 and restore terminal modes on close or partial open failure. -- Session id changes reset the history projection; resize reprojects the same session at the new width. +- The mutable surface is bounded by `terminalHeight - 1` and contains only live content, input, overlays and status. +- `TerminalSession` must not enable 1049, 1000 or 1006; close and partial failure restore raw mode, cursor and interactive modes. +- Resize probes the post-reflow cursor, preserves concurrent input, never replays committed blocks and redraws only the mutable surface. +- Finalizing a streaming block commits its final text once in the same transaction that removes it from the surface. +- Close clears the mutable surface while preserving committed transcript and the shell cursor handoff position. +- A projection-key change opens a new commit epoch without clearing earlier terminal scrollback. - Slash commands that change durable state should route through session/runtime contracts, not UI-only variables. - Permission prompts display decision context and should clear on decision or interrupt. - Skill mentions are resolved from the current `SkillIndex`, then passed into `TurnRequest`. @@ -66,10 +73,12 @@ The TUI adapts input and renders state. Durable behavior belongs in session, age ## Tests To Check - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java` -- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalFrameRendererTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalCursorProbeTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineViewportTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java` - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java` -- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContentLayoutTest.java` -- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptCommitLedgerTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java` - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiRendererTest.java` - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java` - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportConcurrencyTest.java` @@ -82,6 +91,8 @@ The TUI adapts input and renders state. Durable behavior belongs in session, age - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/SlashCommandRouterTest.java` - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/SkillMentionParserTest.java` - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiContractEndToEndTest.java` +- `lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh` +- `lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh` - `lypi-transport-tui/src/test/resources/run-tui-smoke.sh` ## Before Editing @@ -89,8 +100,8 @@ The TUI adapts input and renders state. Durable behavior belongs in session, age - Decide whether the change is view projection, rendering, input handling or runtime behavior. - If it changes data shape, update `lypi-contracts` view model records first. - Prefer event-driven state updates over querying internals from the UI layer. -- For rendering changes, test narrow width, multiline input, streaming updates, history/live allocation and terminal-height bounds. -- For terminal lifecycle changes, test partial open failure and a real PTY before relying on control-sequence unit tests alone. +- For rendering changes, test narrow width, multiline input, streaming finalization, resize reflow and surface-height bounds. +- For terminal lifecycle changes, test partial open failure and real tmux server/client PTYs before relying on control-sequence unit tests alone. - For slash commands, test consumed, prompt-rewrite and state-change paths. After using this Skill, reverse-check that the change did not move business state into TUI classes. From 19bcdc7d3f55ec3f2c843fe4b187511aa924a398 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Fri, 17 Jul 2026 16:06:52 +0800 Subject: [PATCH 55/58] feat(tui): restore startup banner artwork (task 1/4) --- .../lypi/transport/tui/TuiStartupBanner.java | 55 +++++++++++++++++++ .../transport/tui/TuiStartupBannerTest.java | 49 +++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiStartupBanner.java create mode 100644 lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiStartupBannerTest.java diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiStartupBanner.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiStartupBanner.java new file mode 100644 index 00000000..b5a5eb1f --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiStartupBanner.java @@ -0,0 +1,55 @@ +package cn.lypi.transport.tui; + +import java.util.ArrayList; +import java.util.List; + +final class TuiStartupBanner { + private static final int FULL_BANNER_MIN_WIDTH = 46; + private static final String ANSI_RESET = "\033[0m"; + private static final String PRIMARY = "\033[38;5;81m"; + private static final String ACCENT = "\033[38;5;213m"; + private static final String DIM = "\033[38;5;244m"; + private static final String BOLD = "\033[1m"; + private static final List FULL_ARTWORK = List.of( + DIM + "╭────────────────────────────────────────────────────────╮" + ANSI_RESET, + PRIMARY + BOLD + "██╗ ██╗ ██╗ ██████╗ ██╗" + ANSI_RESET, + PRIMARY + BOLD + "██║ ╚██╗ ██╔╝ ██╔══██╗ ██║" + ANSI_RESET, + ACCENT + BOLD + "██║ ╚████╔╝ █████╗██████╔╝ ██║" + ANSI_RESET, + ACCENT + BOLD + "██║ ╚██╔╝ ╚════╝██╔═══╝ ██║" + ANSI_RESET, + PRIMARY + BOLD + "███████╗ ██║ ██║ ██║" + ANSI_RESET, + DIM + "╰──────────────────────── LY-PI ────────────────────────╯" + ANSI_RESET, + ACCENT + "coding agent cockpit" + ANSI_RESET + ); + private static final List COMPACT_ARTWORK = List.of( + PRIMARY + BOLD + "LY-PI" + ANSI_RESET, + ACCENT + "coding agent" + ANSI_RESET + ); + + private TuiStartupBanner() { + } + + static List render(int width, int availableRows) { + List artwork = width >= FULL_BANNER_MIN_WIDTH ? FULL_ARTWORK : COMPACT_ARTWORK; + int lineCount = Math.max(availableRows, artwork.size()); + List lines = new ArrayList<>(lineCount); + int topPadding = Math.max(0, (lineCount - artwork.size()) / 2); + for (int index = 0; index < topPadding; index++) { + lines.add(new TerminalLine("")); + } + artwork.stream() + .map(line -> center(line, width)) + .forEach(lines::add); + while (lines.size() < lineCount) { + lines.add(new TerminalLine("")); + } + return List.copyOf(lines); + } + + private static TerminalLine center(String line, int width) { + int lineWidth = AnsiWidth.displayWidth(line); + String centered = lineWidth >= width + ? line + : " ".repeat((width - lineWidth) / 2) + line; + return new TerminalLine(AnsiWidth.truncate(centered, width)); + } +} diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiStartupBannerTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiStartupBannerTest.java new file mode 100644 index 00000000..edb29147 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiStartupBannerTest.java @@ -0,0 +1,49 @@ +package cn.lypi.transport.tui; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +class TuiStartupBannerTest { + @Test + void wideBannerRestoresFinalWelcomeArtworkAndCentersWithinAvailableRows() { + List lines = TuiStartupBanner.render(80, 12); + + String plain = stripAnsi(lines); + assertEquals(12, lines.size()); + assertEquals("", lines.get(0).text()); + assertEquals("", lines.get(1).text()); + assertEquals("", lines.get(10).text()); + assertEquals("", lines.get(11).text()); + assertTrue(plain.contains("LY-PI")); + assertTrue(plain.contains("coding agent cockpit")); + assertFalse(plain.contains("local-first")); + assertTrue(plain.contains("██████╗ ")); + assertTrue(plain.contains("██╔══██╗")); + assertTrue(plain.contains("██████╔╝")); + assertTrue(lines.stream().allMatch(line -> AnsiWidth.displayWidth(line.text()) <= 80)); + } + + @Test + void narrowBannerUsesCompactArtworkWithoutOverflow() { + List lines = TuiStartupBanner.render(40, 0); + + String plain = stripAnsi(lines); + assertEquals(2, lines.size()); + assertTrue(plain.contains("LY-PI")); + assertTrue(plain.contains("coding agent")); + assertFalse(plain.contains("cockpit")); + assertTrue(lines.stream().allMatch(line -> AnsiWidth.displayWidth(line.text()) <= 40)); + } + + private String stripAnsi(List lines) { + return lines.stream() + .map(TerminalLine::text) + .collect(Collectors.joining("\n")) + .replaceAll("\\u001B\\[[0-9;?]*[A-Za-z]", ""); + } +} From 7df215cb727779bf718a5a132ace247b7e24433c Mon Sep 17 00:00:00 2001 From: lyfmt Date: Fri, 17 Jul 2026 16:11:04 +0800 Subject: [PATCH 56/58] feat(tui): commit startup banner to native scrollback (task 2/4) --- .../transport/tui/InlineTerminalRenderer.java | 37 +++++++++++++- .../tui/InlineTerminalRendererTest.java | 51 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java index b30e6d4c..c774525d 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java @@ -14,17 +14,28 @@ final class InlineTerminalRenderer { private static final TerminalLine EMPTY_LINE = new TerminalLine(""); private final TerminalIo io; + private final boolean startupBannerEnabled; private InlineViewport viewport; private InlineViewport renderedViewport; private List previousSurface = List.of(); private Optional previousCursor = Optional.empty(); private Optional resizeCursorPosition = Optional.empty(); private boolean geometryDirty; + private boolean startupBannerCommitted; private boolean finished; InlineTerminalRenderer(TerminalIo io, InlineViewport viewport) { + this(io, viewport, false); + } + + static InlineTerminalRenderer withStartupBanner(TerminalIo io, InlineViewport viewport) { + return new InlineTerminalRenderer(io, viewport, true); + } + + private InlineTerminalRenderer(TerminalIo io, InlineViewport viewport, boolean startupBannerEnabled) { this.io = java.util.Objects.requireNonNull(io, "io"); this.viewport = java.util.Objects.requireNonNull(viewport, "viewport"); + this.startupBannerEnabled = startupBannerEnabled; } void render(TuiRenderBatch batch) throws IOException { @@ -36,7 +47,11 @@ void render(TuiRenderBatch batch) throws IOException { throw new IllegalArgumentException("mutable surface must contain at least one line"); } InlineViewport nextViewport = viewport.withSurfaceHeight(surface.lines().size()); - List history = prepareHistory(batch.historyLines(), nextViewport.width()); + boolean commitStartupBanner = startupBannerEnabled && !startupBannerCommitted; + List history = prepareHistory( + pendingHistory(batch, nextViewport, commitStartupBanner), + nextViewport.width() + ); if (history.isEmpty() && renderedViewport != null @@ -65,6 +80,26 @@ && sameGeometry(renderedViewport, nextViewport) previousCursor = surface.cursor(); resizeCursorPosition = Optional.empty(); geometryDirty = false; + startupBannerCommitted = startupBannerCommitted || commitStartupBanner; + } + + private List pendingHistory( + TuiRenderBatch batch, + InlineViewport nextViewport, + boolean includeStartupBanner + ) { + if (!includeStartupBanner) { + return batch.historyLines(); + } + int availableRows = Math.max( + 0, + nextViewport.terminalHeight() - nextViewport.top() - nextViewport.surfaceHeight() + ); + List combined = new ArrayList<>( + TuiStartupBanner.render(nextViewport.width(), availableRows) + ); + combined.addAll(batch.historyLines()); + return List.copyOf(combined); } void resize(int width, int height) { diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java index 2cd8c73b..a67f2446 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java @@ -14,6 +14,53 @@ class InlineTerminalRendererTest { private static final String SYNC_START = "\033[?2026h"; private static final String SYNC_END = "\033[?2026l"; + @Test + void startupBannerCommitsBeforeInitialHistoryInOneSynchronizedFlush() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(80, 12); + InlineTerminalRenderer renderer = InlineTerminalRenderer.withStartupBanner( + io, + new InlineViewport(4, 2, 80, 12) + ); + + renderer.render(new TuiRenderBatch( + List.of(new TerminalLine("history stable")), + TuiRenderFrame.fromTextLines(List.of("> |CURSOR|", "status")) + )); + + String output = io.output.toString(); + String plain = stripAnsi(output); + assertEquals(1, occurrences(output, SYNC_START)); + assertEquals(1, occurrences(output, SYNC_END)); + assertEquals(1, occurrences(plain, "LY-PI")); + assertTrue(plain.indexOf("LY-PI") < plain.indexOf("history stable")); + assertEquals(1, io.flushCount); + } + + @Test + void startupBannerDoesNotReplayOnRedrawOrResize() throws Exception { + RecordingTerminalIo io = new RecordingTerminalIo(80, 12); + InlineTerminalRenderer renderer = InlineTerminalRenderer.withStartupBanner( + io, + new InlineViewport(7, 5, 80, 12) + ); + TuiRenderFrame surface = TuiRenderFrame.fromTextLines(List.of( + "live", + "─".repeat(80), + "> draft|CURSOR|", + "─".repeat(80), + "status" + )); + renderer.render(new TuiRenderBatch(List.of(), surface)); + io.resetOutput(); + + renderer.render(new TuiRenderBatch(List.of(), surface)); + io.setDimensions(60, 9); + renderer.resize(60, 9, Optional.of(new TerminalPosition(2, 8))); + renderer.render(new TuiRenderBatch(List.of(), surface)); + + assertFalse(stripAnsi(io.output.toString()).contains("LY-PI")); + } + @Test void firstBatchCommitsHistoryAndSurfaceInOneSynchronizedFlush() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(80, 12); @@ -335,6 +382,10 @@ private int occurrences(String value, String needle) { return count; } + private String stripAnsi(String value) { + return value.replaceAll("\\u001B\\[[0-9;?]*[A-Za-z]", ""); + } + private static final class RecordingTerminalIo implements TerminalIo { private final StringBuilder output = new StringBuilder(); private int width; From 20332dabe128dc8c606e1e9221b7c5dc9b8cffac Mon Sep 17 00:00:00 2001 From: lyfmt Date: Fri, 17 Jul 2026 16:19:52 +0800 Subject: [PATCH 57/58] fix(tui): show startup banner in inline terminal (task 3/4) --- .../lypi/transport/tui/JLineTuiTransport.java | 4 ++-- .../transport/tui/JLineTuiTransportTest.java | 24 ++++++++++++++++++- .../lypi/transport/tui/TuiFramePtyProbe.java | 2 +- .../src/test/resources/run-tui-frame-pty.sh | 10 ++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java index 7be970cb..62dc0397 100644 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/JLineTuiTransport.java @@ -764,7 +764,7 @@ private static JLineTuiTransport openTerminal( viewport = initialViewport(initialWidth, initialHeight); } TerminalInputSource inputSource = new JLineTerminalInputSource(terminal, probe.replayInput()); - InlineTerminalRenderer nextRenderer = new InlineTerminalRenderer(io, viewport); + InlineTerminalRenderer nextRenderer = InlineTerminalRenderer.withStartupBanner(io, viewport); terminalRenderer = nextRenderer; transport = new JLineTuiTransport( terminalFrameSink(nextRenderer), @@ -875,7 +875,7 @@ static JLineTuiTransport open( try { int initialWidth = safeWidth(width > 0 ? width : io.width()); int initialHeight = safeHeight(height > 1 ? height : io.height()); - InlineTerminalRenderer nextRenderer = new InlineTerminalRenderer( + InlineTerminalRenderer nextRenderer = InlineTerminalRenderer.withStartupBanner( io, initialViewport(initialWidth, initialHeight) ); diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java index 8ca54199..8397f16d 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/JLineTuiTransportTest.java @@ -115,10 +115,13 @@ void openAssemblesTerminalSessionRendererInputAndEventSubscription() throws Exce events.emit(new ErrorEvent("ses_1", "err_1", "boom", Instant.parse("2026-06-09T00:00:00Z"))); transport.flushPendingFrameForTest(); + String plainOutput = stripAnsi(io.output.toString()); assertTrue(io.rawModeEntered); assertFalse(io.output.toString().contains("\033[?1049")); assertFalse(io.output.toString().contains("\033[2J")); assertTrue(io.output.toString().contains("\033[?2026h")); + assertTrue(plainOutput.contains("LY-PI")); + assertEquals(1, occurrences(plainOutput, "LY-PI")); assertTrue(io.output.toString().contains("error: boom")); assertTrue(io.output.toString().contains("> ")); assertTrue(io.output.toString().contains("ses_1")); @@ -154,7 +157,9 @@ void publicOpenUsesCursorProbeAnchorAndReplaysConcurrentInput() throws Exception String written = output.toString(); assertTrue(written.indexOf("\033[6n") > written.indexOf(TerminalSession.ENABLE_MODIFY_OTHER_KEYS)); assertTrue(written.indexOf("\033[?2026h") > written.indexOf("\033[6n")); - assertTrue(written.contains("\033[3;1H")); + assertTrue(written.indexOf("LY-PI") > written.indexOf("\033[6n")); + assertEquals(1, occurrences(stripAnsi(written), "LY-PI")); + assertTrue(written.contains("\033[5;1H")); assertFalse(written.contains("\033[6n\r\n")); assertEquals(5, transport.currentDraftLengthForTest()); @@ -204,6 +209,7 @@ void publicOpenRestoresTerminalWhenCursorProbeReadFails() { @Test void openRendersInitialFrameFromRuntimeState() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); + io.width = 120; RecordingEventBus events = new RecordingEventBus(); JLineTuiTransport transport = JLineTuiTransport.open( @@ -218,6 +224,8 @@ void openRendersInitialFrameFromRuntimeState() throws Exception { String frame = io.output.toString(); assertFalse(frame.contains("\033[H\033[J")); + assertTrue(stripAnsi(frame).contains("coding agent cockpit")); + assertEquals(1, occurrences(stripAnsi(frame), "LY-PI")); assertTrue(frame.contains("ses_1 gpt-5.4 EXECUTE DEFAULT_EXECUTE")); assertTrue(frame.contains("> ")); @@ -702,6 +710,20 @@ private SessionRuntimeState runtimeState() { return runtimeState("ses_1", "leaf_1"); } + private int occurrences(String value, String needle) { + int count = 0; + int from = 0; + while ((from = value.indexOf(needle, from)) >= 0) { + count++; + from += needle.length(); + } + return count; + } + + private String stripAnsi(String value) { + return value.replaceAll("\\u001B\\[[0-9;?]*[A-Za-z]", ""); + } + private SessionRuntimeState runtimeState(String sessionId, String leafId) { return new SessionRuntimeState( sessionId, diff --git a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java index 245dccd0..1287821c 100644 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java @@ -25,7 +25,7 @@ public static void main(String[] args) throws Exception { Terminal terminal = TerminalBuilder.builder().system(true).build(); TerminalIo io = new JLineTerminalIo(terminal); try (TerminalSession session = TerminalSession.open(io)) { - InlineTerminalRenderer terminalRenderer = new InlineTerminalRenderer( + InlineTerminalRenderer terminalRenderer = InlineTerminalRenderer.withStartupBanner( io, new InlineViewport(Math.max(0, io.height() - 1), 1, io.width(), io.height()) ); diff --git a/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh b/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh index c224a3b7..e0bce94a 100755 --- a/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh +++ b/lypi-transport-tui/src/test/resources/run-tui-frame-pty.sh @@ -63,6 +63,11 @@ for expected in "${expected_lines[@]}"; do exit 1 fi done +if [[ "$(grep -Fc 'LY-PI' "$FULL_CAPTURE" || true)" -ne 1 ]]; then + echo "expected startup banner exactly once" >&2 + cat "$FULL_CAPTURE" >&2 + exit 1 +fi if grep -Fq "status-old" "$FULL_CAPTURE"; then echo "stale status entered terminal scrollback" >&2 cat "$FULL_CAPTURE" >&2 @@ -93,6 +98,11 @@ for retained in 'SHELL_SENTINEL' 'history stable'; do exit 1 fi done +if [[ "$(grep -Fc 'LY-PI' "$POST_CLOSE_CAPTURE" || true)" -ne 1 ]]; then + echo "expected startup banner retained exactly once after close" >&2 + cat "$POST_CLOSE_CAPTURE" >&2 + exit 1 +fi for mutable in 'stream/live row' '> input|' 'status-old' 'status-updated'; do if grep -Fxq "$mutable" "$POST_CLOSE_CAPTURE"; then echo "mutable surface remained after close: $mutable" >&2 From 5c66220a62680389dd0674d6e69db4d6f1e1b797 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Fri, 17 Jul 2026 16:22:58 +0800 Subject: [PATCH 58/58] docs(tui): record startup banner invariant (task 4/4) --- .codex/skills/lypi-transport-tui/SKILL.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.codex/skills/lypi-transport-tui/SKILL.md b/.codex/skills/lypi-transport-tui/SKILL.md index 42cbcdc9..ea2dbd44 100644 --- a/.codex/skills/lypi-transport-tui/SKILL.md +++ b/.codex/skills/lypi-transport-tui/SKILL.md @@ -30,6 +30,7 @@ The TUI adapts input and renders state. Durable behavior belongs in session, age - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java` - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineViewport.java` - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java` +- `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiStartupBanner.java` - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java` - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java` - `lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java` @@ -49,7 +50,8 @@ The TUI adapts input and renders state. Durable behavior belongs in session, age 5. `TuiTranscriptPartitioner` splits the stable prefix from the live tail without storing durable state. 6. `TuiTranscriptCommitLedger` emits each stable block once per `(sessionId, leafId)` projection. 7. `TuiRenderer` renders new committed blocks separately from the bounded live/input/overlay/status surface. -8. `InlineTerminalRenderer` inserts committed lines above the viewport and diffs only the mutable surface in one synchronized terminal transaction. +8. On the first real-terminal frame, `InlineTerminalRenderer` prepends `TuiStartupBanner` as a one-time native-scrollback prelude. +9. `InlineTerminalRenderer` inserts committed lines above the viewport and diffs only the mutable surface in one synchronized terminal transaction. ## Invariants @@ -57,6 +59,7 @@ The TUI adapts input and renders state. Durable behavior belongs in session, age - The first streaming, active, pending or running block starts the live tail; only the stable prefix can be committed. - A stable block ID is committed at most once per projection key, including after transient stable/live regressions. - Committed transcript is terminal-native scrollback. There is no application-side 500-line history window or scroll offset. +- The startup banner is terminal decoration, not transcript: render it once per transport before initial committed history, then preserve it across redraw, resize, projection changes and close. - PageUp, PageDown and mouse wheel never enter an application history model or mutate the draft; scrollback remains terminal/tmux-owned. - Historical tools always use their completed collapsed rendering. Ctrl+O affects tools in the live region only. - The mutable surface is bounded by `terminalHeight - 1` and contains only live content, input, overlays and status. @@ -76,6 +79,7 @@ The TUI adapts input and renders state. Durable behavior belongs in session, age - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalCursorProbeTest.java` - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineViewportTest.java` - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java` +- `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiStartupBannerTest.java` - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java` - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptCommitLedgerTest.java` - `lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.java`