Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Optional<TerminalKey> 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);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public enum TerminalKey {
WORD_RIGHT,
UP,
DOWN,
PAGE_UP,
PAGE_DOWN,
EXPAND_TOOLS,
OTHER
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
}
Expand Down Expand Up @@ -377,6 +387,10 @@ private TerminalInputContext inputContext(Optional<PermissionPromptView> prompt)
);
}

private boolean transcriptScrollEnabled(Optional<PermissionPromptView> prompt) {
return prompt.isEmpty() && !resumeOverlayOpen() && !slashOverlayOpen() && !skillOverlayOpen();
}

private void submitPermissionOption(PermissionPromptView prompt, String optionId) {
submitHandler.submitPermissionOption(prompt.requestId(), prompt.toolUseId(), optionId);
render();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
177 changes: 143 additions & 34 deletions lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java
Original file line number Diff line number Diff line change
Expand Up @@ -70,21 +70,37 @@ TuiRenderFrame renderFrame(
List<String> overlayLines,
boolean toolOutputExpanded
) {
InputBlock inputBlock = compactRunning(view)
? readonlyRuntimeInputBlock("compact 正在进行...", layout)
: layoutInput(input, cursor, layout);
List<String> permissionOverlay = permissionOverlayLines(view, layout.width());
List<String> externalOverlay = overlayLines == null ? List.of() : overlayLines.stream()
.flatMap(line -> wrap(line, layout.width()).stream())
.toList();
List<String> 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<String> transcript = transcriptLines(view, layout.width(), toolOutputExpanded, effectiveTranscriptBudget);
screen.setTranscript(transcript);
List<String> 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<String> overlay = windowOverlay(
fullOverlay.lines(),
regions.overlayHeight(),
fullOverlay.selectedRow()
);
screen.updateViewportHeight(regions.transcriptHeight());
screen.setTranscript(fullTranscript);
List<String> transcript = screen.visibleTranscript();
int chromeLineCount = inputBlock.lines().size() + overlay.size() + regions.statusHeight();

List<String> lines = new ArrayList<>();
lines.addAll(transcript);
Expand Down Expand Up @@ -170,21 +186,68 @@ private List<String> transcriptLines(TuiViewModel view, int width, boolean toolO
return lines;
}

private List<String> permissionOverlayLines(TuiViewModel view, int width) {
private OverlayBlock permissionOverlay(TuiViewModel view, int width) {
if (view.permissionPrompt().isEmpty()) {
return List.of();
return OverlayBlock.empty();
}
List<String> 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()) {
appendPrefixedMultiline(lines, "rule: ", prompt.rule(), width, Integer.MAX_VALUE);
}
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<String> overlayLines, int width) {
if (overlayLines == null || overlayLines.isEmpty()) {
return OverlayBlock.empty();
}
List<String> 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<String> 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<String> windowOverlay(List<String> 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(
Expand Down Expand Up @@ -303,6 +366,19 @@ private List<String> 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(
" ",
Expand Down Expand Up @@ -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<InputVisualLine> 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<InputVisualLine> 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;
Expand Down Expand Up @@ -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<InputVisualLine> visualInputLines(String value, int cursor, boolean showCursor, int width) {
List<InputVisualLine> lines = new ArrayList<>();
StringBuilder current = new StringBuilder();
Expand Down Expand Up @@ -442,12 +519,11 @@ private List<InputVisualLine> 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<InputVisualLine> lines) {
Expand Down Expand Up @@ -511,6 +587,39 @@ int height() {
}
}

private record OverlayBlock(List<String> 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<InputVisualLine> visualLines;
private final int width;
private final String readonlyLine;

private InputCandidate(List<InputVisualLine> 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) {
}
}
Loading
Loading