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()`. diff --git a/.codex/skills/lypi-transport-tui/SKILL.md b/.codex/skills/lypi-transport-tui/SKILL.md new file mode 100644 index 00000000..ea2dbd44 --- /dev/null +++ b/.codex/skills/lypi-transport-tui/SKILL.md @@ -0,0 +1,111 @@ +--- +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/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/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` +- `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 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. `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. 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 + +- Reducers should not invent durable transcript content. They project events into display state. +- 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. +- `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`. +- 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/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` +- `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-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 + +- 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 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. 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) + ); + } } 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..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 @@ -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,9 +14,13 @@ 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; +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; @@ -27,8 +33,10 @@ 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(); private final ProviderRetryCoordinator retryCoordinator; private final Deque pendingEvents = new ArrayDeque<>(); private final List emittedEvents = new ArrayList<>(); @@ -40,7 +48,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; @@ -51,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 @@ -181,10 +191,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 +248,7 @@ private void handleAttemptFailure(RuntimeException exception) { Optional retryNotice = retryCoordinator.planRetry( exception, signal, - outputStarted, + visibleOutputStarted, nextRetryIndex ); if (retryNotice.isPresent()) { @@ -249,18 +259,41 @@ private void handleAttemptFailure(RuntimeException exception) { return; } retryIndex = 0; + int failedAttemptIndex = attemptIndex; attemptIndex++; - if (fallbackDecider.shouldFallback(exception, outputStarted) && !outputStarted && attemptIndex < attempts.size()) { + if (fallbackDecider.shouldFallback(exception, visibleOutputStarted) + && !visibleOutputStarted + && attemptIndex < attempts.size()) { + ProviderErrorClassification classification = errorClassifier.classify(exception, visibleOutputStarted); + pendingEvents.add(new ProviderFallbackNotice( + provider, + failedAttemptIndex + 1, + attemptIndex + 1, + attempts.get(failedAttemptIndex).mode(), + attempts.get(attemptIndex).mode(), + classification.fallbackAllowed() ? classification.reason() : "fallback_candidate", + classification.fallbackAllowed() ? classification.errorId() : "provider.fallback_candidate", + exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage() + )); 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/main/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapter.java b/lypi-ai/src/main/java/cn/lypi/ai/provider/openai/OpenAiCompatibleProviderAdapter.java index 3ed1e95f..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) { @@ -182,11 +188,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 +213,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/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)) 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..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 @@ -25,10 +25,12 @@ 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; 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 +52,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 +365,102 @@ 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 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 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( @@ -389,16 +490,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 +514,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 +527,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( @@ -676,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(); @@ -697,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", 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( 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..4fdeb09d --- /dev/null +++ b/lypi-tool/src/main/java/cn/lypi/tool/ToolEventSummaryFormatter.java @@ -0,0 +1,163 @@ +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; + +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; + + 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"); + + 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() ? buildGenericInputSummary(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 buildGenericInputSummary(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; } 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/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/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/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/DefaultToolRuntimeTest.java b/lypi-tool/src/test/java/cn/lypi/tool/DefaultToolRuntimeTest.java index b9facd48..3e587968 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 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<>(); 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/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/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")); } 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", 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..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,19 +1,9 @@ package cn.lypi.transport.tui; -import java.util.List; - @FunctionalInterface interface FrameSink { /** - * 渲染一帧终端行。 - */ - void render(List lines); - - /** - * 渲染已经过物理行校验并携带底部 chrome 元数据的一帧。 - * 兼容 sink 可消费文本视图;真实终端 sink 应覆盖此方法并保留类型化帧。 + * 提交一次 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 new file mode 100644 index 00000000..c774525d --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/InlineTerminalRenderer.java @@ -0,0 +1,443 @@ +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 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 { + 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()); + boolean commitStartupBanner = startupBannerEnabled && !startupBannerCommitted; + List history = prepareHistory( + pendingHistory(batch, nextViewport, commitStartupBanner), + 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(); + 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) { + 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; + } + + 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(); + resizeCursorPosition = 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) { + InlineViewport physicalPrevious = physicalViewportAfterResize(renderedViewport, nextViewport); + scrollCommittedRowsForUpwardViewport(physicalPrevious, nextViewport); + clearSurfaceUnion(physicalPrevious, nextViewport); + } + boolean linearHistoryInsertion = requiresLinearHistoryInsertion(history, nextViewport); + finalViewport = insertHistory(history, nextViewport); + if (geometryChanged + || renderedViewport == null + || linearHistoryInsertion + || !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 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 + ) 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 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); + 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/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..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 @@ -10,27 +10,44 @@ 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 { - 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); + 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()); } - 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 ccf752d9..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 @@ -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; @@ -14,13 +17,17 @@ 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.Optional; +import java.util.function.Consumer; +import java.util.function.LongSupplier; import java.util.function.Supplier; import org.jline.terminal.Terminal; @@ -30,40 +37,50 @@ 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; + 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) { 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); + this.redrawScheduler = new TuiRedrawScheduler(); this.runtimeState = null; } @@ -72,20 +89,34 @@ 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(); + 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); + this.redrawScheduler = redrawScheduler; this.runtimeState = null; } @@ -97,6 +128,8 @@ private JLineTuiTransport( TerminalInputSource inputSource, TuiSubmitHandler submitHandler, TerminalSession terminalSession, + TerminalIo terminalIo, + InlineTerminalRenderer inlineTerminalRenderer, Supplier slashPickerSupplier, DiffViewProvider diffViewProvider, ResumeSessionController resumeController, @@ -110,6 +143,8 @@ private JLineTuiTransport( inputSource, submitHandler, terminalSession, + terminalIo, + inlineTerminalRenderer, slashPickerSupplier, diffViewProvider, resumeController, @@ -126,6 +161,8 @@ private JLineTuiTransport( TerminalInputSource inputSource, TuiSubmitHandler submitHandler, TerminalSession terminalSession, + TerminalIo terminalIo, + InlineTerminalRenderer inlineTerminalRenderer, Supplier slashPickerSupplier, DiffViewProvider diffViewProvider, ResumeSessionController resumeController, @@ -133,18 +170,18 @@ private JLineTuiTransport( Clock clock ) { this.renderer = null; + 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, @@ -154,6 +191,8 @@ private JLineTuiTransport( ); 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); @@ -211,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 ); } @@ -383,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( @@ -545,11 +604,51 @@ 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( + 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( + legacyFrameSink(frameConsumer), + width, + height, + null, + new TuiRedrawScheduler(nanoTime, frameIntervalNanos) + ); + } + 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, @@ -565,6 +664,8 @@ static JLineTuiTransport withInput( submitHandler, null, null, + null, + null, NOOP_DIFF_VIEW_PROVIDER, null, null @@ -572,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, @@ -588,6 +707,8 @@ static JLineTuiTransport withInput( submitHandler, null, null, + null, + null, NOOP_DIFF_VIEW_PROVIDER, null, null, @@ -595,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 = InlineTerminalRenderer.withStartupBanner(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, @@ -670,46 +870,55 @@ static JLineTuiTransport open( holder[0].handleInterruptSignal(); } }); - TerminalFrameRenderer frameRenderer = TerminalFrameRenderer.withStartupPadding(io, session::updateRenderedRows); - FrameSink frameSink = new FrameSink() { - @Override - public void render(List lines) { - render(TuiRenderFrame.transcriptOnly(lines)); - } - - @Override - public void render(TuiRenderFrame frame) { - try { - frameRenderer.render(frame); - } catch (IOException exception) { - throw new UncheckedIOException(exception); - } - } - }; - JLineTuiTransport transport = new JLineTuiTransport( - frameSink, - width, - height, - state, - inputSource, - submitHandler, - session, - 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 = InlineTerminalRenderer.withStartupBanner( + 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, @@ -742,12 +951,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 +980,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 +995,47 @@ 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(); + 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() { + 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 +1059,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 +1097,42 @@ 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 (inlineTerminalRenderer != null) { + try { + inlineTerminalRenderer.finish(); + } catch (Exception exception) { + if (failure == null) { + failure = exception; + } else { + failure.addSuppressed(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() { @@ -865,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) { @@ -873,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++; @@ -890,6 +1194,7 @@ private void drainInput() throws IOException { if (inputPump.hasBufferedIncompleteKeySequence()) { synchronized (uiMonitor) { uiLockEntries++; + flushPendingFrameNow(); inputPump.flushBufferedInput(); } } @@ -897,6 +1202,7 @@ private void drainInput() throws IOException { } synchronized (uiMonitor) { uiLockEntries++; + flushPendingFrameNow(); inputPump.dispatchChunk(chunk.orElseThrow()); } } @@ -908,14 +1214,52 @@ private void resize(int width, int height) { if (reducer == null) { return; } - int safeWidth = safeWidth(width); - int safeHeight = safeHeight(height); - screen = new TuiScreen(Math.max(1, safeHeight - 2)); - layout = new TuiLayout(safeWidth, safeHeight); - if (inputLoop != null) { - inputLoop.updateViewport(screen, layout); - } - renderCurrentFrame(); + updateViewportFromTerminal(width, height); + redrawScheduler.renderNow(this::renderCurrentFrame); + } + } + + 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, cursorPosition); + } + if (inputLoop != null) { + inputLoop.updateLayout(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) { + updateViewportFromTerminal(resolvedWidth, resolvedHeight); } } @@ -923,20 +1267,64 @@ 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 { + reconcileTerminalSize(); + if (renderer != null) { + renderer.run(); + return; + } + if (reducer == null) { + return; + } + reducer.observeRuntimeAt(clock.instant()); + TuiViewModel view = reducer.view(); + syncInputLoopToolState(view); + 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; + } + } + + 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 +1341,7 @@ private void renderRuntimeTickUnderUiLock() { return; } uiLockEntries++; - renderCurrentFrame(); + redrawScheduler.renderNow(this::renderCurrentFrame); } } @@ -965,6 +1353,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; @@ -995,4 +1390,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/TerminalCursorProbe.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java new file mode 100644 index 00000000..b98cb30e --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalCursorProbe.java @@ -0,0 +1,74 @@ +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"); + } + + 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 = 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/TerminalFrameRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java deleted file mode 100644 index 274b584a..00000000 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalFrameRenderer.java +++ /dev/null @@ -1,457 +0,0 @@ -package cn.lypi.transport.tui; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.function.IntConsumer; - -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 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; - - 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)); - } - - 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; - } - - 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; - } - moveCursor(cursor, viewportTop, height); - io.write(SYNC_END); - } - - private void writeShrinkPatch( - List lines, - java.util.Optional cursor, - int viewportTop, - int height - ) 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; - } - } - moveCursor(cursor, viewportTop, height); - io.write(SYNC_END); - } - - 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)) { - continue; - } - int physicalRow = physicalRow(i + 1, viewportTop, height); - io.write("\033[" + physicalRow + ";1H"); - io.write("\033[2K"); - writeLine(lines.get(i)); - hardwareCursorRow = physicalRow; - } - } - 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; - } - } - 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; - } - } - 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; - } - 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) - ); - } - - 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 center(String line, int width) { - int lineWidth = AnsiWidth.displayWidth(line); - if (lineWidth >= width) { - return new TerminalLine(line); - } - 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 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); - } - - private CursorFrame stripCursor(List lines) { - List stripped = new ArrayList<>(); - 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 { - stripped.add(line); - } - } - return new CursorFrame(stripped, java.util.Optional.ofNullable(cursor)); - } - - private record CursorFrame(List lines, java.util.Optional cursor) { - } - - private record CursorPosition(int row, int column) { - } -} 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/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/main/java/cn/lypi/transport/tui/TerminalSession.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TerminalSession.java index e82a1259..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,9 @@ 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"; static final String HIDE_CURSOR = "\033[?25l"; @@ -17,7 +20,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; @@ -68,15 +70,11 @@ static TerminalSession open(TerminalIo io, Runnable resizeCallback, Runnable int io.flush(); return new TerminalSession(io, rawMode, resizeHandler, interruptHandler); } catch (IOException | RuntimeException exception) { - restoreAfterOpenFailure(interruptHandler, resizeHandler, rawMode); + restoreAfterOpenFailure(io, interruptHandler, resizeHandler, rawMode); throw exception; } } - void updateRenderedRows(int renderedRows) { - this.renderedRows = Math.max(0, renderedRows); - } - @Override public void close() throws Exception { if (closed) { @@ -84,13 +82,12 @@ 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(SHOW_CURSOR); io.write(DISABLE_BRACKETED_PASTE); - if (renderedRows > 0) { - io.write("\033[" + renderedRows + ";1H"); - } - io.write("\n"); + io.write(RESTORE_CURSOR); + io.write(SHOW_CURSOR); io.flush(); } finally { closeQuietly(interruptHandler); @@ -108,15 +105,39 @@ private void closeQuietly(AutoCloseable closeable) { } private static void restoreAfterOpenFailure( + TerminalIo io, AutoCloseable interruptHandler, 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); closeStaticQuietly(resizeHandler); closeStaticQuietly(rawMode); } + private static void writeStaticQuietly(TerminalIo io, String value) { + try { + io.write(value); + } catch (IOException | RuntimeException ignored) { + // NOTE: 打开失败时每个恢复序列都独立尝试,避免一次写失败阻断终端恢复。 + } + } + + 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/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/TuiEventReducer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiEventReducer.java index 3a22f38d..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 @@ -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; @@ -14,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; @@ -28,9 +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.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; @@ -106,6 +104,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 -> { @@ -386,6 +390,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 +401,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 +417,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 +429,7 @@ private void reduceToolProgress(ToolProgressEvent event) { current.toolName(), TuiToolState.RUNNING, current.label(), - appendDetail(current.details(), progressDetail(event.progress())), + details, true )); }); @@ -425,7 +438,12 @@ 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(), + event + ); state.putBlock(index, new TuiToolBlock( current.blockId(), current.messageId(), @@ -433,66 +451,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; @@ -573,22 +538,13 @@ private void reducePermissionDecision(PermissionDecisionEvent event) { } private void reduceError(ErrorEvent event) { + state.providerErrorObserved(); state.addBlock(new TuiErrorBlock( event.errorId(), event.message() )); } - 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"; @@ -641,17 +597,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/TuiInputLoop.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiInputLoop.java index 7d94bdfa..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 @@ -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,6 +22,7 @@ final class TuiInputLoop { private final TerminalInputPolicy inputPolicy = new TerminalInputPolicy(); private final Supplier slashPickerSupplier; private final Supplier skillIndexSupplier; + private final Runnable renderRequest; private final ResumeSessionController resumeController; private final ResumeOverlayController resumeOverlayController; private SlashCommandPicker slashPicker; @@ -41,69 +39,57 @@ 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, @@ -112,15 +98,14 @@ final class TuiInputLoop { Supplier skillIndexSupplier ) { 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.renderRequest = renderRequest == null ? () -> { + } : renderRequest; this.resumeController = resumeController; this.resumeOverlayController = resumeController == null ? null : new ResumeOverlayController( resumeController, @@ -310,8 +295,7 @@ boolean exitRequested() { return exitRequested; } - void updateViewport(TuiScreen screen, TuiLayout layout) { - this.screen = screen; + void updateLayout(TuiLayout layout) { this.layout = layout; } @@ -382,20 +366,12 @@ private void submitPermissionOption(PermissionPromptView prompt, String optionId render(); } - void renderCurrentFrame() { - render(); + private void render() { + renderRequest.run(); } - private void render() { - frameSink.render(renderer.renderFrame( - currentView(), - screen, - layout, - editor.text(), - editor.cursor(), - overlayLines(), - toolOutputExpanded - )); + TuiViewModel viewForRender() { + return currentView(); } private TuiViewModel currentView() { @@ -535,7 +511,7 @@ private List slashOverlayLines() { return lines; } - private List overlayLines() { + List overlayLines() { if (resumeOverlayController != null) { List resumeLines = resumeOverlayController.overlayLines(layout.width()); if (!resumeLines.isEmpty()) { @@ -588,6 +564,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 ef528e86..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,24 +12,27 @@ record TuiLayout(int width, int height) { } } - int transcriptHeight() { - return transcriptHeight(MIN_INPUT_CONTENT_HEIGHT + INPUT_BORDER_HEIGHT); + int maxSurfaceHeight() { + return Math.max(1, height - 1); } - int transcriptHeight(int inputBlockHeight) { - int boundedInputBlockHeight = Math.min(maxInputBlockHeight(), Math.max(1, inputBlockHeight)); - return Math.max(0, height - STATUS_BAR_HEIGHT - boundedInputBlockHeight); - } + 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 maxInputBlockHeight() { - return Math.max(1, height - STATUS_BAR_HEIGHT); - } + int boundedOverlayHeight = Math.max(0, desiredOverlayHeight); + int overlayHeight = Math.min(boundedOverlayHeight, remainingHeight); + remainingHeight -= overlayHeight; - 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); + 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); } + } 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/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/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/main/java/cn/lypi/transport/tui/TuiRenderFrame.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderFrame.java index a04d66a0..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 @@ -2,28 +2,21 @@ import java.util.List; -record TuiRenderFrame(List terminalLines, int chromeLineCount) { +record TuiRenderFrame(List terminalLines) { + static final String CURSOR_MARKER = "|CURSOR|"; + 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/TuiRenderState.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderState.java index e04a27c6..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,19 +1,14 @@ package cn.lypi.transport.tui; +import cn.lypi.contracts.common.ToolProgress; import cn.lypi.contracts.event.SessionStateEvent; -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.ToolEndEvent; import cn.lypi.contracts.tui.DiffView; import cn.lypi.contracts.tui.PermissionPromptView; 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; @@ -33,6 +28,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", ""); @@ -44,6 +40,7 @@ final class TuiRenderState { private Instant lastTurnObservedAt; private String lastTurnDurationLine; private String retryLine; + private String fallbackLine; private String compactLine; private String interruptLine; @@ -123,19 +120,21 @@ 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), @@ -151,74 +150,45 @@ void configure(SessionRuntimeState runtimeState) { } private void replaceBlocks(List nextBlocks) { + toolProgressBuffers.clear(); blocks.clear(); blocks.addAll(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 -> { - } - } - } + void toolStarted(String toolUseId) { + if (toolUseId != null && !toolUseId.isBlank()) { + runningToolUseIds.add(toolUseId); } - 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, + 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, - toolName, - TuiToolState.PENDING, - label, - false + ignored -> new TuiToolProgressBuffer(initialDetail) ); + buffer.append(progress); + return buffer.render(); } - void toolStarted(String toolUseId) { - if (toolUseId != null && !toolUseId.isBlank()) { - runningToolUseIds.add(toolUseId); + String completeToolProgress(String toolUseId, String initialDetail, ToolEndEvent event) { + TuiToolProgressBuffer buffer = toolProgressBuffers.remove(toolUseId); + if (buffer == null) { + buffer = new TuiToolProgressBuffer(initialDetail); } - statusBar = withMode(currentMode()); + buffer.complete(event); + return buffer.render(); } void toolEnded(String toolUseId) { + toolProgressBuffers.remove(toolUseId); if (toolUseId != null && !toolUseId.isBlank()) { runningToolUseIds.remove(toolUseId); } @@ -282,6 +252,7 @@ void turnEnded(long durationMillis) { activeTurnStartedAt = null; lastTurnObservedAt = null; retryLine = ""; + fallbackLine = ""; compactLine = ""; interruptLine = ""; lastTurnDurationLine = "worked " + formatTurnDuration(durationMillis); @@ -299,6 +270,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 = ""; @@ -311,9 +299,11 @@ void compactEnded() { } void interrupted(String reason) { + toolProgressBuffers.clear(); runningToolUseIds.clear(); runtimeInterruptibleTool = false; retryLine = ""; + fallbackLine = ""; compactLine = ""; activeTurnId = ""; activeTurnStartedAt = null; @@ -360,6 +350,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; } @@ -382,6 +375,7 @@ private void clearRuntimeLines() { lastTurnObservedAt = null; lastTurnDurationLine = ""; retryLine = ""; + fallbackLine = ""; compactLine = ""; interruptLine = ""; } @@ -450,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/TuiRenderer.java b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiRenderer.java index 8aa81ded..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 @@ -27,78 +27,63 @@ final class TuiRenderer { private final MarkdownRenderer markdownRenderer = new MarkdownRenderer(); private final ToolDisplayRendererRegistry toolDisplayRenderers = ToolDisplayRendererRegistry.defaults(); - 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); + List renderCommittedBlocks(List blocks, int width) { + return renderTranscriptBlocks(blocks, width, false, Integer.MAX_VALUE).stream() + .map(TerminalLine::new) + .toList(); } - TuiRenderFrame renderFrame( + TuiRenderFrame renderSurface( TuiViewModel view, - TuiScreen screen, + List liveBlocks, TuiLayout layout, String input, int cursor, 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 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(transcript); - lines.addAll(inputBlock.lines()); - lines.addAll(overlay); - lines.add(statusLine(view.statusBar(), screen, layout.width())); - return TuiRenderFrame.fromTextLines(lines, chromeLineCount); - } - - private List transcriptLines(List blocks, int width, boolean toolOutputExpanded) { - return transcriptLines(blocks, width, toolOutputExpanded, Integer.MAX_VALUE); + lines.addAll(tailPreservingOmissionMarker(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); } - 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) { @@ -159,8 +144,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))); @@ -170,11 +161,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 +174,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( @@ -218,20 +256,55 @@ 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()); } + 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()); } @@ -302,7 +375,7 @@ private List wrapLogicalLine(String text, int width) { return lines; } - private String statusLine(StatusBarState status, TuiScreen screen, int width) { + private String ordinaryStatusLine(StatusBarState status, int width) { String permissionMode = singleLine(status.permissionMode()); String full = String.join( " ", @@ -331,16 +404,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 +453,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 +516,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) { @@ -468,14 +541,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); @@ -485,7 +558,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(); } @@ -494,11 +567,27 @@ private String inputBorder(int width) { return INPUT_BORDER + "─".repeat(width) + ANSI_RESET; } - private List blankLines(int count) { - if (count <= 0) { + private List tail(List lines, int height) { + if (height <= 0 || lines.isEmpty()) { return List.of(); } - return java.util.Collections.nCopies(count, ""); + int start = Math.max(0, lines.size() - height); + return List.copyOf(lines.subList(start, lines.size())); + } + + 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 List.copyOf(visible); } private String nullToEmpty(String value) { @@ -511,6 +600,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 deleted file mode 100644 index 98598a16..00000000 --- a/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiScreen.java +++ /dev/null @@ -1,57 +0,0 @@ -package cn.lypi.transport.tui; - -import java.util.List; - -final class TuiScreen { - 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) { - int previousMaxLinesBelow = maxLinesBelow(); - this.transcript = List.copyOf(transcript); - linesBelow = Math.min(linesBelow, maxLinesBelow()); - if (linesBelow == 0 && previousMaxLinesBelow == 0) { - linesBelow = 0; - } - } - - 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)); - } - - 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; - } - - private int maxLinesBelow() { - return Math.max(0, transcript.size() - viewportHeight); - } -} 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/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..eaa23a43 --- /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(TuiTranscriptProjector.resultSummary(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); + } +} 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/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/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..52a8d022 --- /dev/null +++ b/lypi-transport-tui/src/main/java/cn/lypi/transport/tui/TuiTranscriptProjector.java @@ -0,0 +1,338 @@ +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.tool.ToolExecutionStatus; +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 (!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 (status == ToolExecutionStatus.FAILED || status == ToolExecutionStatus.TIMED_OUT) { + return TuiToolState.FAILED; + } + return 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; + } +} 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..a67f2446 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/InlineTerminalRendererTest.java @@ -0,0 +1,457 @@ +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 java.util.Optional; +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 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); + 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 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); + 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 resizeRoundTripAccountsForTerminalCursorShiftWithoutRewritingHistory() 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;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); + 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[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); + 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 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; + 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; + } + } +} 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/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 3d331ce5..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 @@ -4,31 +4,49 @@ 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.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; 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; 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; 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.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 { @@ -37,7 +55,331 @@ class JLineTuiTransportRenderPipelineTest { private static final String ANSI_RESET = "\033[0m"; @Test - void eventCallbackReducesAndRendersViewModelUnderUiLock() { + void completedRuntimeTranscriptRendersFinalToolStatesOnFirstFrame() { + RecordingEventBus events = new RecordingEventBus(); + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchRenderer(sink, 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")); + + 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); + assertTrue(firstFrame.contains("resume complete"), firstFrame); + assertFalse(firstFrame.contains("pending read"), firstFrame); + assertFalse(firstFrame.contains("pending $"), firstFrame); + } + + @Test + void reattachToResumedStateClearsOldProgressAndDoesNotRecommitSameProjection() { + RecordingEventBus events = new RecordingEventBus(); + 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( + "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(); + TuiRenderBatch resumedBatch = sink.batches.getLast(); + + 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()); + 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", + "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(); + TuiRenderBatch sameProjectionBatch = sink.batches.getLast(); + assertTrue(sameProjectionBatch.historyLines().isEmpty()); + assertFalse(surfaceText(sameProjectionBatch).contains("old-progress")); + } + + @Test + void changingSessionStartsNewTranscriptCommitEpoch() { + RecordingEventBus events = new RecordingEventBus(); + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchRenderer(sink, 80, 8); + transport.attach(events, completedRuntimeState("ses_old", 40)); + transport.renderCurrentFrameUnderUiLock(); + assertTrue(historyText(sink.batches.getLast()).contains("new history 0")); + + transport.attach(events, completedRuntimeState("ses_new", 5)); + transport.renderCurrentFrameUnderUiLock(); + + 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 pageUpDoesNotRecommitHistoryOrMoveLiveSurface() 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, historyRuntimeState("ses_1", 30)); + events.emit(new ToolStartEvent( + "ses_1", + "live-tool", + "bash", + Instant.parse("2026-06-09T00:00:00Z") + )); + transport.flushPendingFrameForTest(); + TuiRenderBatch initialBatch = sink.batches.getLast(); + + input.add("\033[5~"); + transport.drainInputForTest(); + TuiRenderBatch pageUpBatch = sink.batches.getLast(); + + 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 restoredHistoryCommitsAllPhysicalLinesWithoutApplicationTruncation() { + RecordingEventBus events = new RecordingEventBus(); + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchRenderer(sink, 80, 8); + transport.attach(events, historyRuntimeState("ses_1", 510)); + transport.renderCurrentFrameUnderUiLock(); + + 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 + 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(); + 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 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); @@ -55,17 +397,16 @@ void eventCallbackReducesAndRendersViewModelUnderUiLock() { java.util.Map.of(), Instant.parse("2026-06-09T00:00:00Z") )); - assertEquals(1, frames.size()); assertEquals("Done", frames.getFirst().lines().findFirst().orElseThrow()); assertEquals(1, transport.uiLockEntryCountForTest()); } @Test - void rendererFrameKeepsInputBlockAfterFullTranscriptForTerminalScrollback() { + 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++) { @@ -82,14 +423,17 @@ void rendererFrameKeepsInputBlockAfterFullTranscriptForTerminalScrollback() { Instant.parse("2026-06-09T00:00:00Z") )); } - - List latest = frames.getLast(); - assertEquals(10, latest.size()); - assertTrue(latest.contains("line 0")); - assertTrue(latest.contains("line 2")); - assertTrue(latest.contains("line 5")); - assertEquals(inputContent("> "), inputLine(latest)); - assertTrue(latest.getLast().contains("ses_1")); + transport.flushPendingFrameForTest(); + + 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 @@ -111,6 +455,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", @@ -121,8 +466,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( @@ -161,11 +506,12 @@ 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")); - 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 @@ -221,6 +567,7 @@ void resizeRerendersCurrentViewWithUpdatedDimensionsUnderUiLock() { java.util.Map.of(), Instant.parse("2026-06-09T00:00:00Z") )); + transport.flushPendingFrameForTest(); transport.resizeForTest(8, 4); @@ -230,11 +577,96 @@ void resizeRerendersCurrentViewWithUpdatedDimensionsUnderUiLock() { } @Test - void inputRerenderPreservesCurrentTranscriptView() throws Exception { + void resizeAndPageKeysDoNotReplayCommittedTranscript() throws Exception { RecordingEventBus events = new RecordingEventBus(); - List> frames = new ArrayList<>(); - JLineTuiTransport transport = JLineTuiTransport.withInput( - frames::add, + RecordingFrameSink sink = new RecordingFrameSink(); + QueueInputSource input = new QueueInputSource(); + JLineTuiTransport transport = JLineTuiTransport.withBatchInput( + sink, + 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") + )); + } + transport.flushPendingFrameForTest(); + assertTrue(allHistoryText(sink).contains("line 1")); + assertTrue(allHistoryText(sink).contains("line 10")); + + input.add("\033[5~"); + transport.drainInputForTest(); + assertTrue(sink.batches.getLast().historyLines().isEmpty()); + + transport.resizeForTest(40, 8); + + TuiRenderBatch resized = sink.batches.getLast(); + assertTrue(resized.historyLines().isEmpty()); + assertTrue(resized.surface().lines().size() <= 7); + + input.add("\033[6~"); + transport.drainInputForTest(); + 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(); + RecordingFrameSink sink = new RecordingFrameSink(); + JLineTuiTransport transport = JLineTuiTransport.withBatchInput( + sink, 40, 5, new QueueInputSource("draft"), @@ -256,8 +688,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 @@ -286,6 +722,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())); @@ -317,6 +754,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())); } @@ -324,8 +762,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( @@ -341,12 +779,59 @@ 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(); + + 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")); + } - 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")); + @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 @@ -367,8 +852,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) )); } @@ -388,6 +873,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(); @@ -516,7 +1113,7 @@ void idleTickRefreshesWorkingTurnElapsedTime() throws Exception { JLineTuiTransport transport = JLineTuiTransport.withInput( frames::add, 80, - 5, + 8, new QueueInputSource(), new RecordingSubmitHandler(), clock @@ -524,6 +1121,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)")); @@ -553,6 +1151,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"))); @@ -673,6 +1272,141 @@ 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 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, + 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 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) + .map(TuiToolBlock.class::cast) + .filter(block -> toolUseId.equals(block.toolUseId())) + .findFirst() + .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; @@ -680,8 +1414,12 @@ private QueueInputSource(String... chunks) { this.chunks = new ArrayDeque<>(List.of(chunks)); } + private synchronized void add(String chunk) { + chunks.addLast(chunk); + } + @Override - public Optional read() { + public synchronized Optional read() { return Optional.ofNullable(chunks.pollFirst()); } } @@ -719,6 +1457,39 @@ 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); + while (System.nanoTime() < deadline) { + if (frames.stream().anyMatch(predicate)) { + return true; + } + Thread.sleep(5L); + } + return frames.stream().anyMatch(predicate); + } + 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..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 @@ -49,27 +49,35 @@ 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.Duration; import java.time.Instant; import java.util.ArrayDeque; import java.util.ArrayList; 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 { 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,10 +113,15 @@ 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[?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(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")); @@ -116,12 +129,87 @@ void openAssemblesTerminalSessionRendererInputAndEventSubscription() throws Exce transport.close(); assertTrue(io.rawModeRestored); - assertFalse(io.output.toString().contains("\033[?1049l")); + assertFalse(io.output.toString().contains("\033[?1049")); + assertTrue(io.output.toString().endsWith( + TerminalSession.SAVE_CURSOR + + TerminalSession.RESET_SCROLL_REGION + + TerminalSession.DISABLE_MODIFY_OTHER_KEYS + + TerminalSession.DISABLE_BRACKETED_PASTE + + TerminalSession.RESTORE_CURSOR + + 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.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()); + + 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 void openRendersInitialFrameFromRuntimeState() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); + io.width = 120; RecordingEventBus events = new RecordingEventBus(); JLineTuiTransport transport = JLineTuiTransport.open( @@ -136,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("> ")); @@ -143,7 +233,7 @@ void openRendersInitialFrameFromRuntimeState() throws Exception { } @Test - void openPipelineKeepsLongTranscriptInOutputStreamWithoutAlternateScreenOrRepeatedHomeClear() throws Exception { + void openPipelineCommitsLongTranscriptWithoutAlternateScreenOrFullClear() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); io.height = 5; RecordingEventBus events = new RecordingEventBus(); @@ -171,20 +261,23 @@ void openPipelineKeepsLongTranscriptInOutputStreamWithoutAlternateScreenOrRepeat Map.of(), Instant.parse("2026-06-09T00:00:00Z") )); + transport.flushPendingFrameForTest(); } String output = io.output.toString(); assertTrue(output.contains("line 0")); assertTrue(output.contains("line 7")); - assertFalse(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(); + assertFalse(io.output.toString().contains("\033[?1049")); } @Test - void openPipelineDoesNotAppendBottomChromeToScrollbackDuringOverflow() throws Exception { + void openPipelineKeepsBottomChromeInViewportWithoutScrollbackOverflow() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); io.height = 4; RecordingEventBus events = new RecordingEventBus(); @@ -214,18 +307,20 @@ void openPipelineDoesNotAppendBottomChromeToScrollbackDuringOverflow() throws Ex Instant.parse("2026-06-09T00:00:00Z") )); } + transport.flushPendingFrameForTest(); String output = io.output.toString(); assertTrue(output.contains("\r\n")); - assertFalse(output.matches("(?s).*\\033\\[1;\\d+r.*")); + 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> ")); - assertTrue(output.contains("\033[2Kses_1 gpt-5.4")); transport.close(); } @Test - void closeAfterOverflowMovesPromptBelowPhysicalViewport() throws Exception { + void closeAfterHistoryCommitClearsSurfaceAndRestoresModesWithoutPromptNewline() throws Exception { RecordingTerminalIo io = new RecordingTerminalIo(); io.height = 3; RecordingEventBus events = new RecordingEventBus(); @@ -258,7 +353,18 @@ void closeAfterOverflowMovesPromptBelowPhysicalViewport() throws Exception { transport.close(); - assertTrue(io.output.toString().endsWith("\033[3;1H\n")); + assertTrue(io.output.toString().contains("\033[?2026h")); + assertTrue(io.output.toString().contains("\033[2K")); + assertTrue(io.output.toString().endsWith( + 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")); + assertFalse(io.output.toString().endsWith("\n")); } @Test @@ -272,7 +378,7 @@ void toolEndRefreshesDiffViewFromProvider() throws Exception { false, Map.of("snapshotHash", "sha256:1") ))); - io.height = 8; + io.height = 12; JLineTuiTransport transport = JLineTuiTransport.open( runtimeState(), @@ -282,7 +388,7 @@ void toolEndRefreshesDiffViewFromProvider() throws Exception { new RecordingSubmitHandler(), diffProvider, 80, - 8 + 12 ); io.output.setLength(0); @@ -292,6 +398,7 @@ void toolEndRefreshesDiffViewFromProvider() throws Exception { false, Instant.parse("2026-06-09T00:00:00Z") )); + transport.flushPendingFrameForTest(); assertEquals(1, diffProvider.calls); assertEquals(Path.of("."), diffProvider.cwd); @@ -318,7 +425,11 @@ void openClosesTerminalSessionWhenInitialFrameRenderFails() { )); assertTrue(io.rawModeRestored); - assertFalse(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 @@ -342,10 +453,67 @@ 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")); - assertEquals(4, rendered.split("\n", -1).length); - 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(); + } + + @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(); + 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(); } @@ -542,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, @@ -815,12 +997,111 @@ 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; private boolean rawModeRestored; private int width = 40; private int height = 4; + private int cursorProbeQueries; + private CursorProbeResult cursorProbeResult = new CursorProbeResult(Optional.empty(), ""); private Runnable resizeCallback = () -> { }; @@ -861,12 +1142,18 @@ public int width() { public int height() { return height; } + + @Override + public CursorProbeResult queryCursor(Duration timeout) { + cursorProbeQueries++; + return cursorProbeResult; + } } 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/KeyBindingRegistryTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/KeyBindingRegistryTest.java index b80d2303..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,6 +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.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 ffd0f5e1..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 @@ -41,6 +41,19 @@ void mapsLineEditingAndYankKeys() { assertEquals(TerminalKey.ALT_Y, mapper.map("\033y").orElseThrow()); } + @Test + void leavesNativeScrollbackAndMouseSequencesUnclaimed() { + KeyMapper mapper = new KeyMapper(); + + 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()); + } + @Test void filtersTerminalProtocolResponsesAndReleaseRepeatEvents() { KeyMapper mapper = new KeyMapper(); 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; + } + } +} 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 dd063c40..00000000 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalFrameRendererTest.java +++ /dev/null @@ -1,626 +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.assertTrue; - -import java.io.IOException; -import java.util.List; -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")); - } - - @Test - void tailChangePatchesLastLineWithoutAppendingDuplicateTranscript() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - 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); - - 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")); - } - - @Test - void transcriptInsertBeforeTailRewritesLinearTail() 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(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")); - } - - @Test - void transcriptAppendWithChromeScrollsLinearTail() 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)); - 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(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")); - } - - @Test - void viewportScrollPatchDoesNotRewriteRowsThatScrolledOutOfView() 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" - )); - 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")); - } - - @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")); - } - - @Test - void contentShrinkPatchesVisibleRowsWithoutClearingTerminalScrollback() 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")); - - 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" - )); - - 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", - io.output.toString() - ); - assertFalse(io.output.toString().contains("\033[2J\033[H")); - } - - @Test - void shrinkAfterLargeExpandedFrameUsesCurrentTailViewport() 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" - )); - 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); - - renderer.render(List.of("one")); - io.output.setLength(0); - io.width = 100; - renderer.render(List.of("one")); - - assertEquals("\033[?2026h\033[2J\033[Hone\033[?2026l", io.output.toString()); - } - - @Test - void changeAbovePreviousViewportUsesFullRender() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 2; - 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[2J\033[Htwo\nthree\033[?2026l", io.output.toString()); - } - - @Test - void firstFrameOnlyWritesVisibleViewportRows() throws Exception { - RecordingTerminalIo io = new RecordingTerminalIo(); - io.height = 3; - TerminalFrameRenderer renderer = new TerminalFrameRenderer(io); - - renderer.render(List.of("one", "two", "three", "four|CURSOR|")); - - assertEquals("two\nthree\nfour\033[3;5H", 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) throws IOException { - return () -> { - }; - } - - @Override - public AutoCloseable onInterrupt(Runnable callback) { - return () -> { - }; - } - } - - 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/TerminalInputPumpTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalInputPumpTest.java index b10dc43e..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 @@ -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( @@ -193,15 +177,39 @@ 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(); 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/TerminalSessionTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TerminalSessionTest.java index e9f1fb32..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 @@ -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; @@ -20,13 +21,16 @@ void openEntersInteractiveTerminalModesAndCloseRestoresThem() throws Exception { "\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[?2004h\033[?25l\033[>4;2m" - + "\033[>4m\033[?25h\033[?2004l\n", + + "\0337\033[r\033[>4m\033[?2004l\0338\033[?25h", io.output.toString() ); } @@ -41,17 +45,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 +73,18 @@ void openRegistersInterruptHandlerAndCloseRestoresIt() throws Exception { } @Test - void openFailureRestoresRawModeAndResizeHandler() { - FailingTerminalIo io = new FailingTerminalIo(); + void openFailureRestoresTerminalResourcesWithoutAlternateScreenModes() { + FailingTerminalIo io = new FailingTerminalIo(2); assertThrows(IOException.class, () -> TerminalSession.open(io)); + assertEquals( + "\033[?2004h\0337\033[r\033[>4m\033[?2004l\0338\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); @@ -141,9 +141,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 +159,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 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..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 @@ -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,8 @@ 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; import cn.lypi.contracts.tui.TuiThinkingBlock; @@ -52,11 +57,56 @@ 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"); + @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(); @@ -157,6 +207,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(); @@ -284,6 +455,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.renderSurface( + view, + view.blocks(), + new TuiLayout(80, 120), + "", + -1, + List.of(), + false + ); + TuiRenderFrame expanded = renderer.renderSurface( + view, + view.blocks(), + new TuiLayout(80, 120), + "", + -1, + List.of(), + true + ); + + 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"))); + } + private static MessageDeltaEvent delta( String messageId, String blockId, @@ -305,6 +534,32 @@ private static MessageDeltaEvent delta( ); } + private static List renderedTranscript(TuiViewModel view) { + TuiRenderFrame frame = new TuiRenderer().renderSurface( + view, + view.blocks(), + new TuiLayout(80, 20), + "", + -1, + List.of(), + false + ); + 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/TuiEventReducerTest.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiEventReducerTest.java index 8aea0d55..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 @@ -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; @@ -281,6 +283,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(); @@ -401,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")); @@ -589,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(), @@ -629,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/TuiFramePtyProbe.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiFramePtyProbe.java index cc8a375b..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 @@ -1,9 +1,10 @@ package cn.lypi.transport.tui; import cn.lypi.contracts.tui.StatusBarState; -import cn.lypi.contracts.tui.TuiToolBlock; -import cn.lypi.contracts.tui.TuiToolState; +import cn.lypi.contracts.tui.TuiMessageBlock; 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,34 +15,77 @@ 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]); + 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)) { - TerminalFrameRenderer frameRenderer = new TerminalFrameRenderer(io, session::updateRenderedRows); + InlineTerminalRenderer terminalRenderer = InlineTerminalRenderer.withStartupBanner( + 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)); - Thread.sleep(100); + 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); + } + } 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(new TuiToolBlock( - "tool:pty", - "message:pty", - "tool-use:pty", - "bash", - TuiToolState.RUNNING, - "tool one\ntool two", - "", - true - )), - new StatusBarState("status", "", "", ""), - runtimeLine, + List.of( + new TuiMessageBlock("history:pty", "message:history", "assistant", "history stable", false), + new TuiMessageBlock("live:pty", "message:live", "assistant", "stream/live row", true) + ), + new StatusBarState(runtimeLine, "", "", ""), + "", List.of(), Optional.empty(), Optional.empty() 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..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 @@ -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; @@ -30,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; @@ -39,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) ); @@ -62,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(), @@ -89,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(), @@ -116,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) ); @@ -131,15 +256,41 @@ void rendersCursorAtCurrentEditorPosition() { assertEquals(inputContent("> alpha be|CURSOR|" + INPUT_CURSOR + "ta"), inputLine(frames.getLast())); } + @Test + void otherKeyDoesNotChangeInputOrRenderedProjection() { + RecordingSubmitHandler submit = new RecordingSubmitHandler(); + List> frames = new ArrayList<>(); + TuiViewModel view = new TuiViewModel( + List.of(), + 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 beforeOtherKey = frames.getLast(); + + loop.acceptKey(TerminalKey.OTHER); + + assertEquals("draft", loop.draft()); + assertEquals(cursor, loop.cursor()); + assertEquals(beforeOtherKey, frames.getLast()); + } + @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) ); @@ -156,11 +307,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) ); @@ -178,8 +327,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); @@ -192,8 +341,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); @@ -207,8 +356,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); @@ -220,12 +369,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")) @@ -243,8 +390,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); @@ -253,7 +400,7 @@ void ctrlCRequestsExitWhenInputIsEmptyAndNoToolIsRunning() { } @Test - void ctrlOTogglesToolOutputExpandedWithoutChangingDraft() { + void ctrlODoesNotExpandHistoricalToolOrChangeDraft() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); TuiViewModel view = new TuiViewModel( @@ -274,11 +421,9 @@ void ctrlOTogglesToolOutputExpandedWithoutChangingDraft() { 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 ); @@ -289,13 +434,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( @@ -330,11 +475,9 @@ void toolOutputToggleAppliesToHistoricalAndRunningToolBlocksTogether() { 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 ); @@ -342,7 +485,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)")); @@ -352,7 +495,7 @@ void toolOutputToggleAppliesToHistoricalAndRunningToolBlocksTogether() { } @Test - void expandToolsActionTogglesToolOutputExpanded() { + void expandToolsActionLeavesHistoricalToolCollapsed() { RecordingSubmitHandler submit = new RecordingSubmitHandler(); List frames = new ArrayList<>(); TuiViewModel view = new TuiViewModel( @@ -373,30 +516,26 @@ void expandToolsActionTogglesToolOutputExpanded() { 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 ); 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")); } @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") ); @@ -411,11 +550,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") ); @@ -434,11 +571,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")) @@ -458,12 +593,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") ); @@ -480,8 +613,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); @@ -501,8 +634,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); @@ -522,11 +655,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) ); @@ -551,11 +682,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) ); @@ -579,11 +708,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")) @@ -606,12 +733,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")) @@ -636,12 +761,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")) @@ -658,11 +781,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()), @@ -688,11 +809,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()), @@ -709,12 +828,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()), @@ -735,11 +852,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( @@ -768,12 +883,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")) @@ -850,11 +963,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")), @@ -902,12 +1013,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")), @@ -994,11 +1103,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")), @@ -1080,12 +1187,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")), @@ -1108,11 +1213,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")), @@ -1128,8 +1231,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); @@ -1148,8 +1251,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/TuiInteractionPtyProbe.java b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInteractionPtyProbe.java new file mode 100644 index 00000000..bdf664c1 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiInteractionPtyProbe.java @@ -0,0 +1,167 @@ +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.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() { + } + + 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 <= 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"); + + AtomicReference controllerFailure = new AtomicReference<>(); + Thread controller = Thread.ofVirtual().start(() -> { + try { + 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)); + transport.flushPendingFrameForTest(); + signal(controlDirectory.resolve("final-emitted")); + } catch (Throwable failure) { + controllerFailure.set(failure); + } + }); + transport.runUntilExit(); + controller.join(); + if (controllerFailure.get() != null) { + throw new IllegalStateException("PTY controller failed", controllerFailure.get()); + } + } + } + + 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() + 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 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; + + @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/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..a1e7b016 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiLayoutTest.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 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 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.allocateSurface( + desiredLiveHeight, + desiredInputHeight, + desiredOverlayHeight + ); + + String scenario = "height=" + height + + ", live=" + desiredLiveHeight + + ", input=" + desiredInputHeight + + ", overlay=" + desiredOverlayHeight; + assertTrue(regions.totalHeight() <= height - 1, scenario); + assertEquals(height > 2 ? 1 : 0, regions.statusHeight(), scenario); + assertTrue(regions.inputHeight() >= 1, scenario); + assertTrue(regions.transcriptHeight() <= desiredLiveHeight, scenario); + } + } + } + } + } + + @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)); + } +} 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()); + } +} 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..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 @@ -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; @@ -32,10 +33,158 @@ 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 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() + ); + + 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.renderSurface( + view, partition.live(), new TuiLayout(60, 14), "", -1, List.of(), true + ).lines(); + + 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"), @@ -44,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)); @@ -57,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"), @@ -66,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")); } @@ -74,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( @@ -94,37 +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 statusBarDoesNotRenderApplicationScrollbackCounter() { - TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); - screen.setTranscript(List.of("old", "current")); - 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(80, 3), ""); - - assertFalse(lines.getLast().contains("scroll +")); - } - @Test void statusBarDoesNotRenderInternalRuntimeFields() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of(), new StatusBarState( @@ -142,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:")); @@ -154,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"), @@ -163,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)); @@ -172,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), @@ -185,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)); @@ -195,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"), @@ -204,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)); @@ -213,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"), @@ -222,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)); @@ -235,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"), @@ -244,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"); @@ -253,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"), @@ -262,7 +381,7 @@ 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(4, lines.size()); assertInputBorder(lines.get(0), 20); @@ -274,7 +393,6 @@ void emptyTranscriptRendersInputBlockAndStatusOnly() { @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"), @@ -284,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")); @@ -295,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"), @@ -304,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"), @@ -323,17 +439,16 @@ 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(TerminalFrameRenderer.CURSOR_MARKER, "")) <= 8); + assertTrue(AnsiWidth.displayWidth(line.replace(TuiRenderFrame.CURSOR_MARKER, "")) <= 8); } } @Test - void longInputSoftWrapsInsideBottomInputBlockWithFullTranscript() { + void longInputIsWindowedWithoutChangingCommittedHistory() { TuiRenderer renderer = new TuiRenderer(); - TuiScreen screen = new TuiScreen(1); TuiViewModel view = new TuiViewModel( List.of( new TuiMessageBlock("b1", "m1", "assistant", "line1", false), @@ -347,24 +462,22 @@ void longInputSoftWrapsInsideBottomInputBlockWithFullTranscript() { Optional.empty() ); - 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")); + 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 + ); + + 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"), @@ -373,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); @@ -384,9 +497,8 @@ void explicitNewlineStartsNewInputRowWithoutSplittingDraftSemantics() { } @Test - void inputViewportShowsLatestRowsWhileKeepingFullTranscript() { + 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"), @@ -395,21 +507,19 @@ void inputViewportShowsLatestRowsWhileKeepingFullTranscript() { 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); - 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(TuiRenderFrame.CURSOR_MARKER))); } @Test - void inputBlockCanUseFullTerminalHeightAfterFullTranscript() { + 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"), @@ -418,19 +528,22 @@ void inputBlockCanUseFullTerminalHeightAfterFullTranscript() { 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 + ); - assertEquals(4, lines.size()); - 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.getLast().contains("ses_1")); + 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(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( @@ -468,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", @@ -506,17 +617,56 @@ 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(); + 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 = render(renderer, view, 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(TuiRenderFrame.CURSOR_MARKER))); + assertTrue(lines.getLast().contains("ses_1")); + assertFalse(lines.stream().anyMatch(line -> line.contains("Option 1"))); } @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"), @@ -537,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"); @@ -552,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"), @@ -567,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)); @@ -576,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), @@ -592,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("line1", lines.get(0)); - assertEquals("line2", lines.get(1)); - assertEquals("line3", 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"), @@ -611,17 +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("line1", withoutRuntimeLines.get(0)); - assertEquals("line2", withoutRuntimeLines.get(1)); - assertEquals("line3", withoutRuntimeLines.get(2)); + 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"), @@ -630,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)); @@ -640,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", @@ -659,9 +799,9 @@ void splitsToolRuntimeAndOverlayLogicalLinesIntoPhysicalFrameLines() { Optional.empty() ); - TuiRenderFrame frame = renderer.renderFrame( + TuiRenderFrame frame = renderFrame( + renderer, view, - screen, new TuiLayout(40, 20), "", 0, @@ -680,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"), @@ -689,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")); @@ -699,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"), @@ -708,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()); } @@ -716,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", @@ -734,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")); @@ -745,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), @@ -758,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")); @@ -768,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", @@ -788,13 +923,13 @@ 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")); 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")); } @@ -802,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", @@ -820,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 {}")); @@ -829,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( @@ -869,14 +1002,16 @@ 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, 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")); @@ -885,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(), @@ -905,9 +1039,9 @@ void expandedToolOutputIsBoundedByAvailableTerminalRows() { Optional.empty() ); - List lines = renderer.renderFrame( + List lines = renderFrame( + renderer, view, - screen, new TuiLayout(80, 12), "draft", 5, @@ -915,17 +1049,106 @@ 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"))); assertTrue(lines.getLast().contains("ses_1")); } + @Test + void collapsedAndExpandedActiveToolFramesShareSurfaceBudget() { + 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.RUNNING, + "call " + index, + details, + true + )) + .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 = renderFrame( + renderer, + view, + new TuiLayout(80, 12), + "", + -1, + List.of(), + false + ); + TuiRenderFrame expanded = renderFrame( + renderer, + view, + new TuiLayout(80, 12), + "", + -1, + List.of(), + true + ); + + assertFrameFitsHeightWithOmissionMarker(collapsed, 12); + 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 = renderFrame( + new TuiRenderer(), + view, + new TuiLayout(20, 20), + "", + -1, + List.of(), + false + ); + + assertTrue(frame.lines().size() <= 19); + assertTrue(frame.lines().stream().anyMatch(line -> line.contains("more lines"))); + } + + private void assertFrameFitsHeightWithOmissionMarker(TuiRenderFrame frame, int height) { + assertTrue(frame.lines().size() <= height - 1); + assertTrue(frame.lines().stream() + .anyMatch(line -> line.contains("more lines") || line.contains("earlier lines"))); + } + @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"), @@ -934,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), @@ -968,16 +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(withoutOverlay.size() + 3, withOverlay.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++) { @@ -996,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"), @@ -1006,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("─")); @@ -1024,4 +1324,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("┄"); + } } 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 81a147c5..00000000 --- a/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiScreenTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package cn.lypi.transport.tui; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.List; -import org.junit.jupiter.api.Test; - -class TuiScreenTest { - @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("c", "d"), screen.visibleTranscript()); - assertEquals(1, screen.linesBelow()); - - screen.scrollDown(1); - - assertEquals(List.of("d", "e"), screen.visibleTranscript()); - assertEquals(0, screen.linesBelow()); - } -} 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]", ""); + } +} 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) + ); + } +} 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) { + } +} 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 new file mode 100644 index 00000000..99877b36 --- /dev/null +++ b/lypi-transport-tui/src/test/java/cn/lypi/transport/tui/TuiTranscriptPartitionerTest.java @@ -0,0 +1,89 @@ +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)); + } + + @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); + } + + 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()); + } +} 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(); + } +} 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..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 @@ -23,61 +23,92 @@ 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" + '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 +FULL_CAPTURE="$TMP_DIR/full-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 - exit 1 -fi -if ! grep -Fxq "LYPI_TUI_FRAME_EXIT=0" "$CAPTURE"; then - echo "tui frame PTY probe failed" >&2 - cat "$CAPTURE" >&2 - exit 1 -fi -if grep -Fq "status-old" "$CAPTURE"; then - echo "stale status-old remained in final pane" >&2 - cat "$CAPTURE" >&2 +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 "$FULL_CAPTURE" >&2 exit 1 fi expected_lines=( - 'running $ tool one' - 'tool two' - '· status-updated' + 'SHELL_SENTINEL' + 'history stable' + 'stream/live row' '> input|' - 'status' + 'status-updated' ) -previous_line=0 for expected in "${expected_lines[@]}"; do - mapfile -t matches < <(grep -nxF "$expected" "$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 "$CAPTURE" >&2 + echo "expected exactly one full-capture line: $expected" >&2 + cat "$FULL_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 +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 + exit 1 +fi + +: >"$EXIT_FILE" +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 -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" "$POST_CLOSE_CAPTURE"; then + echo "tui frame PTY probe did not close cleanly" >&2 + cat "$POST_CLOSE_CAPTURE" >&2 + exit 1 +fi +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 +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 + cat "$POST_CLOSE_CAPTURE" >&2 exit 1 fi - previous_line="$current_line" done echo "tui frame PTY passed" 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..7bc8b7a3 --- /dev/null +++ b/lypi-transport-tui/src/test/resources/run-tui-interaction-pty.sh @@ -0,0 +1,275 @@ +#!/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 +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 + +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 \ + '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 300); do + [[ -f "$path" ]] && return 0 + sleep 0.05 + done + return 1 +} + +wait_for_pane_text() { + local expected="$1" + for _ in $(seq 1 300); do + capture_visible + grep -Fq -- "$expected" "$PANE_CAPTURE" && return 0 + sleep 0.05 + done + return 1 +} + +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 "$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 -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-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 [[ "$(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_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 || 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 + +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 -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 [[ "$(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 ! 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 -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 +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 "$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 bfad451e..a0f47281 100644 --- a/lypi-transport-tui/src/test/resources/run-tui-smoke.sh +++ b/lypi-transport-tui/src/test/resources/run-tui-smoke.sh @@ -42,20 +42,40 @@ 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[?2004h' + $'\033[?25l' + $'\033[>4;2m' + "LYPI_TUI_PTY_OPEN" + $'\033[>4m' + $'\033[?2004l' + $'\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 + +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" echo "tui smoke passed"