From 633f08e2a566bc4e7d969d2bf7a3edbce8cc166b Mon Sep 17 00:00:00 2001 From: lyfmt Date: Tue, 14 Jul 2026 20:26:33 +0800 Subject: [PATCH 1/6] =?UTF-8?q?chore:=20=E5=90=AF=E5=8A=A8=20TUI=20viewpor?= =?UTF-8?q?t=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 2/6] =?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 3/6] =?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 4/6] =?UTF-8?q?fix(tui):=20=E7=94=A8=E6=B6=88=E6=81=AF=20v?= =?UTF-8?q?iewport=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 5/6] =?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 6/6] =?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(); }