From 4c0b4517f617d702f3f6244035a3d282aa86cef5 Mon Sep 17 00:00:00 2001 From: sionic-khope Date: Thu, 16 Jul 2026 16:55:20 +0900 Subject: [PATCH] fix: support full-screen terminal TUI --- .../Terminal/Core/TerminalBuffer.swift | 3 +- .../TerminalBufferTests.swift | 46 +++++++++++++++++++ .../TerminalSessionPersistenceTests.swift | 20 ++++---- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/Sources/MikuCodeApp/Terminal/Core/TerminalBuffer.swift b/Sources/MikuCodeApp/Terminal/Core/TerminalBuffer.swift index 850b7ef..a036633 100644 --- a/Sources/MikuCodeApp/Terminal/Core/TerminalBuffer.swift +++ b/Sources/MikuCodeApp/Terminal/Core/TerminalBuffer.swift @@ -305,7 +305,8 @@ struct TerminalBuffer: Sendable { let viewportEnd = (fixedScreen || isAlternateScreen) ? min(currentLines.count - 1, viewportStart + rows - 1) : currentLines.count - 1 - setCursor(row: min(viewportEnd, max(viewportStart, cursorRow + distance)), column: cursorColumn) + let column = min(cursorColumn, max(0, columns - 1)) + setCursor(row: min(viewportEnd, max(viewportStart, cursorRow + distance)), column: column) } private mutating func handleCSI(parameters values: [Int], final: UInt8, isPrivate: Bool) { diff --git a/Tests/MikuCodeAppTests/TerminalBufferTests.swift b/Tests/MikuCodeAppTests/TerminalBufferTests.swift index 9778827..cf877c3 100644 --- a/Tests/MikuCodeAppTests/TerminalBufferTests.swift +++ b/Tests/MikuCodeAppTests/TerminalBufferTests.swift @@ -245,6 +245,52 @@ final class TerminalBufferTests: XCTestCase { XCTAssertEqual(buffer.snapshot.plainText, "three") } + func testAlternateScreenIsolatedFromPrimaryScrollbackAndSupportsRowAddressing() { + var buffer = TerminalBuffer(columns: 16, rows: 3) + buffer.ingest("shell prompt>") + + buffer.ingest("\u{1B}[?1049h\u{1B}[2J\u{1B}[Htop\u{1B}[2;1Hbottom") + + XCTAssertEqual( + buffer.snapshot.lines.prefix(2).map(\.plainText), + ["top", "bottom"], + "A full-screen TUI must draw on an isolated two-dimensional alternate screen." + ) + XCTAssertEqual(buffer.snapshot.lines.count, 3) + + buffer.ingest("\u{1B}[?1049l") + + XCTAssertEqual( + buffer.snapshot.lines[0].plainText, + "shell prompt>", + "Leaving the alternate screen must restore the shell screen instead of retaining TUI frames." + ) + } + + func testAlternateScreenVerticalMovementNormalizesPendingFinalColumn() { + var buffer = TerminalBuffer(columns: 3, rows: 2) + + buffer.ingest("\u{1B}[?1049habc\u{1B}[1BZ") + + XCTAssertEqual( + buffer.snapshot.lines.prefix(2).map(\.plainText), + ["abc", " Z"], + "Moving vertically from the last column must not trigger a phantom wrap and scroll." + ) + } + + func testFixedPrimaryVerticalMovementNormalizesPendingFinalColumnAfterScrollback() { + var buffer = TerminalBuffer(columns: 3, rows: 2, maxLines: 8, maxCells: 256) + + buffer.ingest("one\ntwo\nabc\u{1B}[1AZ") + + XCTAssertEqual( + buffer.visibleSnapshot.lines.map(\.plainText), + ["twZ", "abc"], + "Vertical motion within a scrolled primary viewport must clear pending wrap before the next glyph." + ) + } + func testClampsLargeCursorForwardWithinCellLimit() { let maxCells = 32 var buffer = TerminalBuffer(maxCells: maxCells) diff --git a/Tests/MikuCodeAppTests/TerminalSessionPersistenceTests.swift b/Tests/MikuCodeAppTests/TerminalSessionPersistenceTests.swift index 3d15cf6..bb132fe 100644 --- a/Tests/MikuCodeAppTests/TerminalSessionPersistenceTests.swift +++ b/Tests/MikuCodeAppTests/TerminalSessionPersistenceTests.swift @@ -197,21 +197,21 @@ final class TerminalSessionPersistenceTests: XCTestCase { withIntermediateDirectories: true ) let persistence = TerminalSessionPersistence(url: temporaryURL()) - let session = TerminalSessionModel(persistence: persistence, shell: "/bin/sh") - session.start() - for byte in Data("cd \(directory.path)".utf8) { - session.sendRawInput(Data([byte])) - } - session.sendRawInput(Data([0x0D])) - session.stop() + try persistence.save(PersistedTerminalSession( + shell: "/bin/sh", + columns: 80, + rows: 24, + snapshot: TerminalSnapshot(lines: [TerminalLine(text: "saved")]), + workingDirectory: directory.path + )) let reopened = TerminalSessionModel(persistence: persistence, shell: "/bin/sh") reopened.start() defer { reopened.stop() } - reopened.sendRawInput(Data("pwd\n".utf8)) - try await waitUntil { - reopened.snapshot.plainText.contains(directory.lastPathComponent) + reopened.submit("pwd") + try await waitUntil(timeout: .seconds(10)) { + reopened.snapshot.lines.map(\.plainText).joined().contains(directory.path) } }