diff --git a/CHANGELOG.md b/CHANGELOG.md index c833b80..315cda1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project uses semantic versioning while it remains pre-1.0. ## [Unreleased] +### Added + +- Added opt-in, bounded text-only normal-screen resize recovery for application-owned semantic + history, preserving append chronology, retained-frame cursor/mouse state, redacted diagnostics, + and shared JVM/Scala Native behavior. #57, fixes #56 + ## [0.8.0] - 2026-08-02 ### Added diff --git a/README.md b/README.md index 9319dfc..c089166 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,55 @@ IDs are remapped so retained cleanup cannot remove historical output. A retained append incompatible because iTerm2 provides no reliable relocation cleanup; one-shot appended iTerm2 images remain supported. Real image persistence depends on the terminal emulator. +Applications that retain a bounded semantic transcript can also reconstruct only the durable tail +lost from the active viewport during a normal-screen resize: + +```scala +import scalatui.ansi.Ansi +import scalatui.core.{ + NormalResizeClearPolicy, + NormalResizeRecoveryProvider, + TUI, + TUIOptions +} + +val semanticTranscript = Vector("old result", "newer result") // Application-owned and bounded. +val recovery = NormalResizeRecoveryProvider { context => + val invalidatedEntries = semanticTranscript.reverseIterator + .foldLeft((Vector.empty[String], 0)) { case ((entries, oldRows), entry) => + if oldRows >= context.previousMaxRows then entries -> oldRows + else + val entryRows = Ansi.wrapLogicalLinesWithAnsi(entry, context.previousWidth).length + (entry +: entries) -> (oldRows + entryRows) + } + ._1 + + invalidatedEntries + .flatMap(Ansi.wrapLogicalLinesWithAnsi(_, context.width)) + .takeRight(context.maxRows) // Retains oldest-to-newest order within the newest tail. +} + +val tui = TUI(terminal, TUIOptions( + normalResizeClearPolicy = NormalResizeClearPolicy.PreserveScrollback, + normalResizeRecovery = Some(recovery) +)) +``` + +The provider runs synchronously only for a committed geometry-changing resize, after the live frame +has been rendered and its strict viewport budget is known. `previousWidth`, `previousHeight`, and +`previousMaxRows` describe the old viewport and its maximum durable prefix; `maxRows` is also +bounded by space above the new live frame, preventing viewport growth from replaying older history. +The provider may run again if another resize makes the candidate stale, so it must be fast, +side-effect-light, and retryable. It returns ordinary text lines only: existing SGR/OSC 8 +sanitization is preserved, while typed controls, images, cursors, and raw terminal output are +unavailable. A later append is placed in chronological order as `recovered tail -> newly appended +output -> retained live frame`. + +Siglyph does not retain the transcript or inspect emulator scrollback. The application must select +only the current-width newest tail that belonged in the invalidated viewport; exact survivor and +deduplication behavior remains terminal-dependent. Configuring recovery with alternate-screen mode +or `ClearScrollback` fails before terminal startup. + Applications that need a full-screen terminal experience can opt into alternate-screen mode: ```scala diff --git a/core/src/scalatui/core/NormalResizeRecovery.scala b/core/src/scalatui/core/NormalResizeRecovery.scala new file mode 100644 index 0000000..9f20fe4 --- /dev/null +++ b/core/src/scalatui/core/NormalResizeRecovery.scala @@ -0,0 +1,65 @@ +package scalatui.core + +/** + * Current and previous terminal geometry plus strict row bounds for one normal-screen resize + * recovery attempt. + * + * Siglyph supplies positive current and previous dimensions from the shared JVM/Scala Native + * runtime. `previousMaxRows` is the maximum durable prefix that could have occupied the old + * viewport above the prior live frame. `maxRows` is the smaller of that old capacity and the space + * currently available above the new live frame. Applications use the old bound to select the + * invalidated semantic tail, reflow it at `width`, and return at most `maxRows` display rows in + * oldest-to-newest order. Siglyph does not expose or infer terminal scrollback survivors. + */ +final case class NormalResizeRecoveryContext( + width: Int, + height: Int, + maxRows: Int, + previousWidth: Int, + previousHeight: Int, + previousMaxRows: Int +) derives CanEqual: + require(width > 0, "Normal resize recovery width must be positive") + require(height > 0, "Normal resize recovery height must be positive") + require(maxRows > 0, "Normal resize recovery row budget must be positive") + require(maxRows < height, "Normal resize recovery row budget must leave a live-frame row") + require(previousWidth > 0, "Previous normal resize recovery width must be positive") + require(previousHeight > 0, "Previous normal resize recovery height must be positive") + require(previousMaxRows > 0, "Previous normal resize recovery row capacity must be positive") + require( + previousMaxRows < previousHeight, + "Previous normal resize recovery row capacity must leave a live-frame row" + ) + require( + maxRows <= previousMaxRows, + "Normal resize recovery row budget must not exceed previous row capacity" + ) + +/** + * Synchronous application provider for bounded normal-screen resize recovery. + * + * `render` runs as serialized TUI Render work outside lifecycle and terminal-write locks. A resize + * can discard an unpublished candidate and invoke the provider again, so implementations must be + * fast, side-effect-light, and retryable. Returned strings are ordinary text-only component lines: + * existing SGR/OSC 8 allowlisting and width sanitization apply, while typed terminal controls, + * images, cursors, components, and raw trusted output are intentionally unavailable. + * + * The contract is implemented in shared core on JVM and Scala Native. Applications retain and + * select their own semantic transcript; Siglyph retains no recovery output after publication and + * cannot promise emulator-independent scrollback deduplication. + */ +trait NormalResizeRecoveryProvider: + /** + * Select the old semantic tail using `previousWidth` and `previousMaxRows`, then reflow and + * return at most `context.maxRows` ordinary lines for the current geometry. + * + * Lines use oldest-to-newest order within the selected newest durable tail. Returning too many + * rows fails the runtime before recovery publication; throwing uses normal fail-fast cleanup. + */ + def render(context: NormalResizeRecoveryContext): Vector[String] + +object NormalResizeRecoveryProvider: + /** Build a provider from a synchronous retryable callback. */ + def apply(callback: NormalResizeRecoveryContext => Vector[String]): NormalResizeRecoveryProvider = + new NormalResizeRecoveryProvider: + override def render(context: NormalResizeRecoveryContext): Vector[String] = callback(context) diff --git a/core/src/scalatui/core/TUI.scala b/core/src/scalatui/core/TUI.scala index abe2292..8f68501 100644 --- a/core/src/scalatui/core/TUI.scala +++ b/core/src/scalatui/core/TUI.scala @@ -63,13 +63,21 @@ enum TUIScreenMode derives CanEqual: * @param diagnosticObserver * Optional instance-scoped observer for redacted structured runtime metadata. Observer failures * are contained and permanently disable that observer without preventing terminal cleanup. + * @param normalResizeRecovery + * Optional synchronous provider for reconstructing a bounded durable text tail after a + * geometry-changing normal-screen preserve-scrollback resize. Its context includes previous and + * current geometry with a strict budget bounded by both viewport capacities. The provider is + * retryable Render work and receives no component, cursor, typed-control, or raw-terminal + * authority. Configuring it with alternate screen or scrollback-clearing resize policy fails + * before terminal startup. */ final case class TUIOptions( hardwareCursorPositioning: Boolean = false, screenMode: TUIScreenMode = TUIScreenMode.Normal, mouseInput: Boolean = false, normalResizeClearPolicy: NormalResizeClearPolicy = NormalResizeClearPolicy.ClearScrollback, - diagnosticObserver: Option[TUIDiagnosticObserver] = None + diagnosticObserver: Option[TUIDiagnosticObserver] = None, + normalResizeRecovery: Option[NormalResizeRecoveryProvider] = None ) derives CanEqual /** @@ -130,6 +138,7 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) private var renderRequested = false private var forceRenderRequested = false private var clearRequested = false + private var pendingResizeRecoveryGeneration = Option.empty[Long] private var autoWrapRestoreNeeded = false private var alternateScreenEntered = false private var sanitizationCount = 0 @@ -184,7 +193,10 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) /** Number of final rendered lines sanitized because they exceeded terminal width. */ def sanitizedLineCount: Int = lifecycleLock.synchronized(sanitizationCount) - /** Most recent final rendered line sanitization diagnostic, if any occurred. */ + /** + * Most recent content-bearing retained/append line sanitization diagnostic, if any occurred. + * Resize recovery increments [[sanitizedLineCount]] but intentionally retains no recovery line. + */ def lastSanitizedLine: Option[TUI.RenderSanitization] = lifecycleLock.synchronized(lastSanitization) @@ -346,6 +358,7 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) lifecycleLock.synchronized(inputListeners -= listener) def start(): Unit = + validateNormalResizeRecoveryOptions() val shouldStart = lifecycleLock.synchronized { if (lifecycleState !== TUI.LifecycleState.Stopped) || drainOwned then false else @@ -411,6 +424,18 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) catch case cleanupFailure: Throwable => e.addSuppressed(cleanupFailure) throw runtimeFailure.getOrElse(e) + private def validateNormalResizeRecoveryOptions(): Unit = + options.normalResizeRecovery.foreach { _ => + if options.screenMode !== TUIScreenMode.Normal then + throw IllegalArgumentException( + "Normal resize recovery requires normal-screen mode" + ) + if options.normalResizeClearPolicy !== NormalResizeClearPolicy.PreserveScrollback then + throw IllegalArgumentException( + "Normal resize recovery requires preserve-scrollback resize policy" + ) + } + /** Start, wait for exit, and return only after deferred cleanup reaches `Stopped`. */ def run(): Unit = try @@ -927,6 +952,7 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) renderRequested = true forceRenderRequested = true clearRequested = true + pendingResizeRecoveryGeneration = Some(resizeGeneration) Some(resizeGeneration) else None } @@ -978,23 +1004,24 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) TUI.Work.Done } work match - case TUI.Work.Ingress(ingress) => processIngress(ingress) - case TUI.Work.QueryCompletion(completion) => processQueryCompletion(completion) - case TUI.Work.AppendCompletion(completion) => processAppendCompletion(completion) - case TUI.Work.Structural(claimed) => applyStructural(claimed) - case TUI.Work.Action(action) => + case TUI.Work.Ingress(ingress) => processIngress(ingress) + case TUI.Work.QueryCompletion(completion) => processQueryCompletion(completion) + case TUI.Work.AppendCompletion(completion) => processAppendCompletion(completion) + case TUI.Work.Structural(claimed) => applyStructural(claimed) + case TUI.Work.Action(action) => try action() catch case error: Throwable => recordFailure(error) - case TUI.Work.Control(action) => action() - case TUI.Work.Append(operation) => processAppend(operation) - case TUI.Work.Render(force, clear) => renderNow(force, clear) - case TUI.Work.Cleanup => + case TUI.Work.Control(action) => action() + case TUI.Work.Append(operation) => processAppend(operation) + case TUI.Work.Render(force, clear, recoveryGeneration) => + renderNow(force, clear, recoveryGeneration) + case TUI.Work.Cleanup => emitDiagnostic(TUIDiagnosticEvent.Lifecycle( TUIDiagnosticLifecycleState.Cleaning, options.screenMode )) deferredCleanupFailure = cleanup() - case TUI.Work.Done => continue = false + case TUI.Work.Done => continue = false completed = true catch case e: Throwable => @@ -1383,11 +1410,13 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) TUI.Work.Append(operation) case TUI.OrdinaryCategory.Render => renderRequested = false - val force = forceRenderRequested - val clear = clearRequested + val force = forceRenderRequested + val clear = clearRequested + val recoveryGeneration = pendingResizeRecoveryGeneration forceRenderRequested = false clearRequested = false - TUI.Work.Render(force, clear) + pendingResizeRecoveryGeneration = None + TUI.Work.Render(force, clear, recoveryGeneration) private def ordinaryCategoryReadyLocked(category: TUI.OrdinaryCategory): Boolean = category match case TUI.OrdinaryCategory.Structural => pendingStructural.nonEmpty @@ -1522,6 +1551,7 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) renderRequested = false forceRenderRequested = false clearRequested = false + pendingResizeRecoveryGeneration = None backgroundColorFlight match case Some(flight) if flight.phase === TUI.QueryFlightPhase.Emitted => retainQueryCompletionsLocked(flight.subscribers, TerminalQueryResult.Stopped) @@ -1861,31 +1891,72 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) (eventType !== KeyEventType.Release) && (value === char) && modifiers.ctrl case _ => false - private def renderNow(force: Boolean, clear: Boolean): Unit = - val generation = lifecycleLock.synchronized(resizeGeneration) - val width = positiveDimension(terminal.columns) - val height = positiveDimension(terminal.rows) - val baseFrame = root.renderFrame(width) - val (composed, layouts) = renderOverlays(baseFrame.render, width, height) - val frame = prepareFrame(composed.validated(width), width) + private def renderNow( + force: Boolean, + clear: Boolean, + recoveryGeneration: Option[Long] + ): Unit = + val generation = lifecycleLock.synchronized(resizeGeneration) + val width = positiveDimension(terminal.columns) + val height = positiveDimension(terminal.rows) + val baseFrame = root.renderFrame(width) + val (composed, layouts) = renderOverlays(baseFrame.render, width, height) + val frame = prepareFrame(composed.validated(width), width) validateRetainedKittyOwnership(frame) + val widthChanged = (previousWidth !== 0) && (previousWidth !== width) + val heightChanged = (previousHeight !== 0) && (previousHeight !== height) + val resizeGeometryChanged = widthChanged || heightChanged + val forceForRender = + force && (recoveryGeneration.isEmpty || resizeGeometryChanged) + val clearForRender = + clear && (recoveryGeneration.isEmpty || resizeGeometryChanged) + val recovery = Option.when( + clearForRender && resizeGeometryChanged && previousFrame.nonEmpty && + recoveryGeneration.exists(_ === generation) && + options.normalResizeRecovery.nonEmpty && !isAlternateScreenMode && + options.normalResizeClearPolicy === NormalResizeClearPolicy.PreserveScrollback + ) { + prepareResizeRecovery(frame, width, height, generation) + } - val currentWidth = positiveDimension(terminal.columns) - val currentHeight = positiveDimension(terminal.rows) - val currentGeneration = lifecycleLock.synchronized(resizeGeneration) - if (generation !== currentGeneration) || (width !== currentWidth) || + val currentWidth = positiveDimension(terminal.columns) + val currentHeight = positiveDimension(terminal.rows) + val (currentGeneration, stillRunning) = lifecycleLock.synchronized( + resizeGeneration -> (lifecycleState === TUI.LifecycleState.Running) + ) + if !stillRunning then + recovery.foreach(value => + emitResizeRecoveryDiagnostic( + TUIDiagnosticResizeRecoveryOutcome.Discarded, + failure = None, + value.maxRows, + value.lines.length, + value.generation + ) + ) + else if (generation !== currentGeneration) || (width !== currentWidth) || (height !== currentHeight) then lifecycleLock.synchronized { - renderRequested = true - forceRenderRequested = true - clearRequested = true + if lifecycleState === TUI.LifecycleState.Running then + renderRequested = true + forceRenderRequested = true + clearRequested = true + if recoveryGeneration.nonEmpty then + pendingResizeRecoveryGeneration = Some(resizeGeneration) } + recovery.foreach(value => + emitResizeRecoveryDiagnostic( + TUIDiagnosticResizeRecoveryOutcome.Discarded, + Some(TUIDiagnosticResizeRecoveryFailure.StaleGeometry), + value.maxRows, + value.lines.length, + value.generation + ) + ) else - val widthChanged = (previousWidth !== 0) && (previousWidth !== width) - val heightChanged = (previousHeight !== 0) && (previousHeight !== height) - if previousFrame.isEmpty || force then - val clearReason = Option.when(clear) { + if previousFrame.isEmpty || forceForRender then + val clearReason = Option.when(clearForRender) { if previousFrame.isEmpty then TUIDiagnosticClearReason.Initial else TUIDiagnosticClearReason.Resize } @@ -1897,7 +1968,7 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) firstRow = 0, clearReason ) - fullRender(frame, width, height, clearReason) + publishFullRender(frame, width, height, clearReason, recovery) else if widthChanged || heightChanged then val clearReason = Some(TUIDiagnosticClearReason.Resize) emitRedrawDiagnostic( @@ -1908,7 +1979,7 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) firstRow = 0, clearReason ) - fullRender(frame, width, height, clearReason) + publishFullRender(frame, width, height, clearReason, recovery) else val firstChanged = firstChangedRow(previousFrame.get, frame) if firstChanged >= 0 then @@ -1927,6 +1998,114 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) previousHeight = height latestBaseLayout = Some(baseFrame.layout) latestOverlayLayouts = layouts + recovery.foreach(value => + emitResizeRecoveryDiagnostic( + TUIDiagnosticResizeRecoveryOutcome.Completed, + failure = None, + value.maxRows, + value.lines.length, + value.generation + ) + ) + + private def prepareResizeRecovery( + frame: TUI.PreparedFrame, + width: Int, + height: Int, + generation: Long + ): TUI.PreparedResizeRecovery = + val liveFrameFootprint = math.max(1, frame.lines.length) + val currentMaxRows = math.max(0, height - liveFrameFootprint) + val previousLiveFrameFootprint = previousFrame.fold(1)(value => + math.max(1, value.lines.length) + ) + val previousMaxRows = math.max(0, previousHeight - previousLiveFrameFootprint) + val maxRows = math.min(currentMaxRows, previousMaxRows) + if maxRows === 0 then TUI.PreparedResizeRecovery(Vector.empty, maxRows, generation) + else + val context = NormalResizeRecoveryContext( + width, + height, + maxRows, + previousWidth, + previousHeight, + previousMaxRows + ) + val rawLines = + try + Option(options.normalResizeRecovery.get.render(context)).getOrElse( + throw NullPointerException("Normal resize recovery provider returned null") + ) + catch + case error: Throwable => + emitResizeRecoveryDiagnostic( + TUIDiagnosticResizeRecoveryOutcome.Failed, + Some(TUIDiagnosticResizeRecoveryFailure.Provider), + maxRows, + rowCount = 0, + generation + ) + throw error + if rawLines.length > maxRows then + emitResizeRecoveryDiagnostic( + TUIDiagnosticResizeRecoveryOutcome.Failed, + Some(TUIDiagnosticResizeRecoveryFailure.RowBudget), + maxRows, + rawLines.length, + generation + ) + throw IllegalArgumentException( + s"Normal resize recovery returned ${rawLines.length} rows for budget $maxRows" + ) + val lines = + try applyLineResets(sanitizeLines(rawLines, width, retainDiagnosticContent = false)) + catch + case error: Throwable => + emitResizeRecoveryDiagnostic( + TUIDiagnosticResizeRecoveryOutcome.Failed, + Some(TUIDiagnosticResizeRecoveryFailure.Provider), + maxRows, + rawLines.length, + generation + ) + throw error + TUI.PreparedResizeRecovery(lines, maxRows, generation) + + private def publishFullRender( + frame: TUI.PreparedFrame, + width: Int, + height: Int, + clearReason: Option[TUIDiagnosticClearReason], + recovery: Option[TUI.PreparedResizeRecovery] + ): Unit = + try fullRender(frame, width, height, clearReason, recovery) + catch + case error: Throwable => + recovery.foreach(value => + emitResizeRecoveryDiagnostic( + TUIDiagnosticResizeRecoveryOutcome.Failed, + Some(TUIDiagnosticResizeRecoveryFailure.Write), + value.maxRows, + value.lines.length, + value.generation + ) + ) + throw error + + private def emitResizeRecoveryDiagnostic( + outcome: TUIDiagnosticResizeRecoveryOutcome, + failure: Option[TUIDiagnosticResizeRecoveryFailure], + maxRows: Int, + rowCount: Int, + generation: Long + ): Unit = + emitDiagnostic(TUIDiagnosticEvent.ResizeRecovery( + outcome, + failure, + maxRows, + rowCount, + generation + )) private def publishAppend( appended: TUI.PreparedFrame, @@ -1948,7 +2127,7 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) appendHardwareCursorMove(builder, retained, retainedPaintedRow) appendRenderEnd(builder) writeRenderBuffer(builder.result()) - val totalRows = appended.lines.length + retained.lines.length + val totalRows = appended.lines.length + math.max(1, retained.lines.length) latestFrameStartRow = latestFrameStartRow.map { start => val appendStart = scrolledFrameStart(start, 0, totalRows, terminalHeight) appendStart + appended.lines.length @@ -1966,7 +2145,8 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) frame: TUI.PreparedFrame, width: Int, height: Int, - clearReason: Option[TUIDiagnosticClearReason] + clearReason: Option[TUIDiagnosticClearReason], + recovery: Option[TUI.PreparedResizeRecovery] ): Unit = val clear = clearReason.nonEmpty val startRowBeforeRender = if clear then Some(0) else latestFrameStartRow @@ -1978,17 +2158,30 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) appendVerticalMove(builder, fromRow = cursorRow, toRow = 0) builder.append("\r") } - val paintedRow = appendFrameContent( - builder, - frame, - fromRow = 0, - kittyLifecycleCleanup(previousFrame, frame, fromRow = 0) - ) + val cleanupControls = kittyLifecycleCleanup(previousFrame, frame, fromRow = 0) + val paintedRow = recovery match + case Some(value) => + val recoveryFrame = TUI.PreparedFrame(value.lines, None, Vector.empty) + appendFrameContent(builder, recoveryFrame, fromRow = 0, cleanupControls) + if value.lines.nonEmpty then builder.append("\r\n") + appendFrameContent(builder, frame, fromRow = 0, Vector.empty) + case None => + appendFrameContent(builder, frame, fromRow = 0, cleanupControls) appendHardwareCursorMove(builder, frame, paintedRow) appendRenderEnd(builder) writeRenderBuffer(builder.result()) - latestFrameStartRow = - startRowBeforeRender.map(scrolledFrameStart(_, 0, frame.lines.length, height)) + latestFrameStartRow = recovery match + case Some(value) => + val liveFootprint = math.max(1, frame.lines.length) + val combinedStart = scrolledFrameStart( + frameStartRow = 0, + writeStartFrameRow = 0, + writtenLineCount = value.lines.length + liveFootprint, + terminalHeight = height + ) + Some(combinedStart + value.lines.length) + case None => + startRowBeforeRender.map(scrolledFrameStart(_, 0, frame.lines.length, height)) previousFrame = Some(frame) previousWidth = width previousHeight = height @@ -2183,20 +2376,25 @@ final class TUI(val terminal: Terminal, val options: TUIOptions = TUIOptions()) private def positiveDimension(value: Int): Int = math.max(1, value) - private def sanitizeLines(lines: Vector[String], width: Int): Vector[String] = + private def sanitizeLines( + lines: Vector[String], + width: Int, + retainDiagnosticContent: Boolean = true + ): Vector[String] = lines.zipWithIndex.map { (line, index) => val lineWidth = Ansi.visibleWidth(line) if lineWidth <= width then Ansi.sanitize(line) else val sanitized = Ansi.truncateToWidth(line, width, "") sanitizationCount += 1 - lastSanitization = Some(TUI.RenderSanitization( - lineIndex = index, - originalWidth = lineWidth, - targetWidth = width, - original = line, - sanitized = sanitized - )) + if retainDiagnosticContent then + lastSanitization = Some(TUI.RenderSanitization( + lineIndex = index, + originalWidth = lineWidth, + targetWidth = width, + original = line, + sanitized = sanitized + )) sanitized } @@ -2254,6 +2452,12 @@ object TUI: controls: Vector[TerminalControlPlacement] ) derives CanEqual + private final case class PreparedResizeRecovery( + lines: Vector[String], + maxRows: Int, + generation: Long + ) + private enum LifecycleState derives CanEqual: case Starting, Running, Stopping, Cleaning, Stopped @@ -2269,7 +2473,7 @@ object TUI: case Action(action: () => Unit) case Control(action: () => Unit) case Append(operation: TUI.AppendOperation) - case Render(force: Boolean, clear: Boolean) + case Render(force: Boolean, clear: Boolean, recoveryGeneration: Option[Long]) case Cleanup case Done diff --git a/core/src/scalatui/core/TUIDiagnostics.scala b/core/src/scalatui/core/TUIDiagnostics.scala index c970a42..785f342 100644 --- a/core/src/scalatui/core/TUIDiagnostics.scala +++ b/core/src/scalatui/core/TUIDiagnostics.scala @@ -37,12 +37,20 @@ enum TUIDiagnosticAppendFailure derives CanEqual: case Lifecycle, ScreenMode, ResizePolicy, FrameUnavailable, AttachedComponent, Capacity, RetainedITerm2, Stopped, Context, Render, Validation, Identity, Planning, Write, Callback +/** Bounded outcome for one optional normal-screen resize recovery attempt. */ +enum TUIDiagnosticResizeRecoveryOutcome derives CanEqual: + case Completed, Discarded, Failed + +/** Bounded failure category for normal-screen resize recovery diagnostics. */ +enum TUIDiagnosticResizeRecoveryFailure derives CanEqual: + case StaleGeometry, Provider, RowBudget, Write + /** * Redacted, backend-independent terminal runtime diagnostics. * - * Events contain only bounded enums, dimensions, row indexes, generations, and byte counts. They - * never contain rendered application text, image payloads, raw query replies, or terminal output - * bytes. Events from one observer belong only to its owning TUI instance. + * Events contain only bounded enums, dimensions, row indexes, generations, row budgets/counts, and + * byte counts. They never contain rendered application text, image payloads, raw query replies, or + * terminal output bytes. Events from one observer belong only to its owning TUI instance. */ enum TUIDiagnosticEvent derives CanEqual: case Lifecycle(state: TUIDiagnosticLifecycleState, screenMode: TUIScreenMode) @@ -65,6 +73,13 @@ enum TUIDiagnosticEvent derives CanEqual: screenMode: TUIScreenMode, resizeGeneration: Long ) + case ResizeRecovery( + outcome: TUIDiagnosticResizeRecoveryOutcome, + failure: Option[TUIDiagnosticResizeRecoveryFailure], + maxRows: Int, + rowCount: Int, + resizeGeneration: Long + ) /** * Opt-in observer for one TUI runtime's redacted diagnostic events. diff --git a/core/test/src/scalatui/core/NormalResizeRecoverySuite.scala b/core/test/src/scalatui/core/NormalResizeRecoverySuite.scala new file mode 100644 index 0000000..2052c42 --- /dev/null +++ b/core/test/src/scalatui/core/NormalResizeRecoverySuite.scala @@ -0,0 +1,631 @@ +package scalatui.core + +import scalatui.TestInputStreams + +import scalatui.syntax.Equality.* +import scalatui.terminal.{ + Base64ImagePayload, + MouseInputContext, + Terminal, + TerminalImageProtocol, + TerminalInput, + VirtualTerminal +} + +import java.util.concurrent.{CountDownLatch, TimeUnit} +import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger} +import scala.collection.mutable.ArrayBuffer + +class NormalResizeRecoverySuite extends munit.FunSuite: + private final class MutableFrame(var frame: ComponentRender) extends Component: + override def render(width: Int): ComponentRender = frame + + private final class ProbeTerminal(initialColumns: Int = 20, initialRows: Int = 8) + extends Terminal: + val delegate = VirtualTerminal(initialColumns, initialRows) + var startCount = 0 + val failNextRenderWrite = AtomicBoolean(false) + override def start(onInput: TerminalInput => Unit, onResize: () => Unit): Unit = + startCount += 1 + delegate.start(onInput, onResize) + override def stop(): Unit = delegate.stop() + override def write(data: String): Unit = + if failNextRenderWrite.compareAndSet(true, false) && data.contains(TUI.SyncStart) then + delegate.write(data) + throw IllegalStateException("sensitive write failure") + delegate.write(data) + override def columns: Int = delegate.columns + override def rows: Int = delegate.rows + override def moveBy(lines: Int): Unit = delegate.moveBy(lines) + override def hideCursor(): Unit = delegate.hideCursor() + override def showCursor(): Unit = delegate.showCursor() + override def clearLine(): Unit = delegate.clearLine() + override def clearFromCursor(): Unit = delegate.clearFromCursor() + override def clearScreen(): Unit = delegate.clearScreen() + + def resize(columns: Int, rows: Int): Unit = delegate.resize(columns, rows) + + private def options( + provider: NormalResizeRecoveryProvider, + observer: Option[TUIDiagnosticObserver] = None, + hardwareCursor: Boolean = false, + mouseInput: Boolean = false + ): TUIOptions = TUIOptions( + hardwareCursorPositioning = hardwareCursor, + mouseInput = mouseInput, + normalResizeClearPolicy = NormalResizeClearPolicy.PreserveScrollback, + diagnosticObserver = observer, + normalResizeRecovery = Some(provider) + ) + + private def running( + terminal: VirtualTerminal, + component: Component, + provider: NormalResizeRecoveryProvider, + observer: Option[TUIDiagnosticObserver] = None, + hardwareCursor: Boolean = false, + mouseInput: Boolean = false + ): TUI = + val tui = TUI(terminal, options(provider, observer, hardwareCursor, mouseInput)) + tui.addChild(component) + tui.start() + terminal.clearWrites() + tui + + test("recovery public context validates positive geometry and options remain additive") { + assertEquals(TUIOptions().normalResizeRecovery, None) + val context = NormalResizeRecoveryContext(20, 8, 3, 24, 10, 5) + assertEquals(context.maxRows, 3) + assertEquals(context.previousMaxRows, 5) + intercept[IllegalArgumentException](NormalResizeRecoveryContext(0, 8, 3, 24, 10, 5)) + intercept[IllegalArgumentException](NormalResizeRecoveryContext(20, 0, 3, 24, 10, 5)) + intercept[IllegalArgumentException](NormalResizeRecoveryContext(20, 8, 0, 24, 10, 5)) + intercept[IllegalArgumentException](NormalResizeRecoveryContext(20, 8, 3, 0, 10, 5)) + intercept[IllegalArgumentException](NormalResizeRecoveryContext(20, 8, 3, 24, 0, 5)) + intercept[IllegalArgumentException](NormalResizeRecoveryContext(20, 8, 3, 24, 10, 0)) + intercept[IllegalArgumentException](NormalResizeRecoveryContext(20, 8, 6, 24, 10, 5)) + intercept[IllegalArgumentException](NormalResizeRecoveryContext(20, 3, 3, 24, 10, 3)) + intercept[IllegalArgumentException](NormalResizeRecoveryContext(20, 8, 3, 24, 5, 5)) + } + + test("incompatible recovery options fail before terminal startup or output") { + val provider = NormalResizeRecoveryProvider(_ => Vector.empty) + val clear = ProbeTerminal() + val clearTui = TUI(clear, TUIOptions(normalResizeRecovery = Some(provider))) + intercept[IllegalArgumentException](clearTui.start()) + assertEquals(clear.startCount, 0) + assertEquals(clear.delegate.output, "") + + val alternate = ProbeTerminal() + val alternateTui = TUI( + alternate, + TUIOptions( + screenMode = TUIScreenMode.Alternate, + normalResizeClearPolicy = NormalResizeClearPolicy.PreserveScrollback, + normalResizeRecovery = Some(provider) + ) + ) + intercept[IllegalArgumentException](alternateTui.start()) + assertEquals(alternate.startCount, 0) + assertEquals(alternate.delegate.output, "") + } + + test("provider runs only for committed resize and generic work remains ordinary") { + val calls = AtomicInteger(0) + val provider = NormalResizeRecoveryProvider { _ => + calls.incrementAndGet() + Vector("history") + } + val terminal = VirtualTerminal(20, 8) + val live = MutableFrame(ComponentRender.text("live")) + val tui = running(terminal, live, provider) + + assertEquals(calls.get(), 0) + tui.requestRender(force = true) + tui.flushRender() + assertEquals(calls.get(), 0) + tui.appendToScrollback(MutableFrame(ComponentRender.text("append"))) + assertEquals(calls.get(), 0) + live.frame = ComponentRender.text("changed") + tui.requestRender() + tui.flushRender() + assertEquals(calls.get(), 0) + TestInputStreams.parse("\u001b[6;12;24t").foreach(terminal.sendInput) + assertEquals(calls.get(), 0) + + terminal.clearWrites() + terminal.resize(20, 8) + assertEquals(calls.get(), 0) + assertEquals(terminal.output, "") + + terminal.resize(19, 8) + assertEquals(calls.get(), 1) + tui.stop() + } + + test("height growth bounds recovery by the previous viewport capacity") { + val contexts = ArrayBuffer.empty[NormalResizeRecoveryContext] + val terminal = VirtualTerminal(20, 5) + val tui = running( + terminal, + MutableFrame(ComponentRender.text("live")), + NormalResizeRecoveryProvider { context => + contexts += context + Vector.fill(context.maxRows)("history") + } + ) + + terminal.resize(20, 100) + + assertEquals(contexts.length, 1) + assertEquals(contexts.head.width, 20) + assertEquals(contexts.head.height, 100) + assertEquals(contexts.head.previousWidth, 20) + assertEquals(contexts.head.previousHeight, 5) + assertEquals(contexts.head.previousMaxRows, 4) + assertEquals(contexts.head.maxRows, 4) + assertEquals(terminal.screenLines.count(_ === "history"), 4) + tui.stop() + } + + test("budget follows live footprint overlay extension typed rows and empty anchor") { + val contexts = ArrayBuffer.empty[NormalResizeRecoveryContext] + val provider = NormalResizeRecoveryProvider { context => + contexts += context + Vector.empty + } + val terminal = VirtualTerminal(20, 10) + val live = MutableFrame(ComponentRender.text(Vector("one", "two", "three"))) + val tui = running(terminal, live, provider) + terminal.resize(19, 10) + assertEquals(contexts.last.maxRows, 7) + + tui.showOverlay( + MutableFrame(ComponentRender.text(Vector("overlay-1", "overlay-2"))), + OverlayOptions( + row = Some(OverlaySize.Absolute(6)), + col = Some(OverlaySize.Absolute(0)), + width = Some(OverlaySize.Absolute(10)), + focusCapturing = false + ) + ) + terminal.resize(18, 10) + assertEquals(contexts.last.maxRows, 2) + tui.stop() + + val emptyContexts = ArrayBuffer.empty[NormalResizeRecoveryContext] + val emptyTerminal = VirtualTerminal(20, 10) + val emptyTui = running( + emptyTerminal, + MutableFrame(ComponentRender.empty), + NormalResizeRecoveryProvider { context => + emptyContexts += context + Vector.empty + } + ) + emptyTerminal.resize(19, 10) + assertEquals(emptyContexts.last.maxRows, 9) + emptyTui.stop() + + val payload = Base64ImagePayload.from("YQ==").toOption.get + val kitty = TerminalImageProtocol.encodeKitty(payload, 987654, 1, 3) + val controlFrame = ComponentRender( + Vector.fill(3)(" "), + Vector(TerminalControlPlacement(0, 0, kitty)), + Vector.empty + ) + val controlBudget = ArrayBuffer.empty[Int] + val controlTerm = VirtualTerminal(20, 10) + val controlTui = running( + controlTerm, + MutableFrame(controlFrame), + NormalResizeRecoveryProvider { context => + controlBudget += context.maxRows + Vector.empty + } + ) + controlTerm.resize(19, 10) + assertEquals(controlBudget.last, 7) + controlTui.stop() + } + + test("full live footprint uses zero budget and skips provider") { + val calls = AtomicInteger(0) + val terminal = VirtualTerminal(20, 2) + val tui = running( + terminal, + MutableFrame(ComponentRender.text(Vector("one", "two"))), + NormalResizeRecoveryProvider { _ => + calls.incrementAndGet() + Vector("never") + } + ) + terminal.resize(19, 2) + assertEquals(calls.get(), 0) + assert(terminal.output.contains("one")) + assert(terminal.output.contains("two")) + tui.stop() + } + + test("recovery sanitizes text and commits before retained frame in one write") { + val terminal = VirtualTerminal(6, 6) + val tui = running( + terminal, + MutableFrame(ComponentRender.text("live")), + NormalResizeRecoveryProvider(_ => + Vector("abcdefghi", "\u001b[31mred\u001b[0m", "\u001b]1337;secret\u0007") + ) + ) + + terminal.resize(5, 6) + + val output = terminal.output + assert(output.startsWith(TUI.SyncStart + TUI.AutoWrapOff + TUI.NormalScreenViewportClear)) + assert(!output.contains("\u001b[3J")) + assert(output.indexOf("abcde") < output.indexOf("live")) + assert(output.contains("\\u00")) + assert(!output.contains("secret")) + assert(output.endsWith(TUI.SyncEnd + TUI.AutoWrapOn)) + assertEquals(terminal.writes.length, 1) + assertEquals(tui.sanitizedLineCount, 2) + assertEquals(tui.lastSanitizedLine, None) + tui.stop() + } + + test("empty recovery adds no blank transition") { + val terminal = VirtualTerminal(20, 6) + val tui = running( + terminal, + MutableFrame(ComponentRender.text("live")), + NormalResizeRecoveryProvider(_ => Vector.empty) + ) + terminal.resize(19, 6) + assert(!terminal.output.contains(TUI.NormalScreenViewportClear + "\r\n")) + assertEquals(terminal.screenLines.filter(_.nonEmpty), Vector("live")) + tui.stop() + } + + test("retained image cleanup and iTerm2 append rejection remain live-frame owned") { + val payload = Base64ImagePayload.from("YQ==").toOption.get + val kitty = TerminalImageProtocol.encodeKitty(payload, 246810, 1, 1) + val kittyTerminal = VirtualTerminal(20, 6) + val kittyTui = running( + kittyTerminal, + MutableFrame(ComponentRender( + Vector(" "), + Vector(TerminalControlPlacement(0, 0, kitty)), + Vector.empty + )), + NormalResizeRecoveryProvider(_ => Vector("history")) + ) + kittyTerminal.resize(19, 6) + val kittyOutput = kittyTerminal.output + assert(kittyOutput.indexOf("a=d") < kittyOutput.indexOf("history")) + assert(kittyOutput.indexOf("history") < kittyOutput.indexOf("a=T")) + kittyTui.stop() + + val iterm = TerminalImageProtocol.encodeITerm2(payload, None, 1, 1) + val itermTerminal = VirtualTerminal(20, 6) + val itermTui = running( + itermTerminal, + MutableFrame(ComponentRender( + Vector(" "), + Vector(TerminalControlPlacement(0, 0, iterm)), + Vector.empty + )), + NormalResizeRecoveryProvider(_ => Vector("history")) + ) + itermTerminal.resize(19, 6) + assert(itermTerminal.output.indexOf("history") < itermTerminal.output.indexOf("1337;File=")) + itermTerminal.clearWrites() + var result = Option.empty[AppendResult] + itermTui.appendToScrollback( + MutableFrame(ComponentRender.text("append")), + value => result = Some(value) + ) + assertEquals(result, Some(AppendResult.Rejected(AppendRejection.RetainedITerm2Control))) + assertEquals(itermTerminal.output, "") + itermTui.stop() + } + + test("recovery remains outside baseline and later append preserves chronology") { + val terminal = VirtualTerminal(20, 8) + val live = MutableFrame(ComponentRender.text("live")) + val tui = running( + terminal, + live, + NormalResizeRecoveryProvider(_ => Vector("A")) + ) + terminal.resize(19, 8) + assertEquals(terminal.screenLines.filter(_.nonEmpty), Vector("A", "live")) + + terminal.clearWrites() + live.frame = ComponentRender.text("live-2") + tui.requestRender() + tui.flushRender() + assert(!terminal.output.contains("A")) + assertEquals(terminal.screenLines.filter(_.nonEmpty), Vector("A", "live-2")) + + terminal.clearWrites() + tui.appendToScrollback(MutableFrame(ComponentRender.text("B"))) + assertEquals(terminal.screenLines.filter(_.nonEmpty), Vector("A", "B", "live-2")) + assert(!terminal.output.contains("A")) + tui.stop() + } + + test("empty retained frame reserves an anchor below recovery for later append") { + val terminal = VirtualTerminal(20, 5) + val tui = running( + terminal, + MutableFrame(ComponentRender.empty), + NormalResizeRecoveryProvider(_ => Vector("A")) + ) + terminal.resize(19, 5) + assertEquals(terminal.cursorPosition._1, 1) + + tui.appendToScrollback(MutableFrame(ComponentRender.text("B"))) + assertEquals(terminal.screenLines.filter(_.nonEmpty), Vector("A", "B")) + tui.stop() + } + + test("empty-frame append scrolling preserves the physical anchor for later mouse layout") { + final class MouseLine extends Component, MouseInputHandler: + var received = Option.empty[MouseInputContext] + override def render(width: Int): ComponentRender = ComponentRender.text("live") + override def handleMouse(context: MouseInputContext): InputResult = + received = Some(context) + InputResult.NoRender + + val terminal = VirtualTerminal(20, 3) + val tui = running( + terminal, + MutableFrame(ComponentRender.empty), + NormalResizeRecoveryProvider(context => + Vector.tabulate(context.maxRows)(index => s"A${index + 1}") + ), + mouseInput = true + ) + terminal.resize(19, 3) + tui.appendToScrollback(MutableFrame(ComponentRender.text("B"))) + val target = MouseLine() + tui.addChild(target) + tui.requestRender() + tui.flushRender() + assertEquals(terminal.screenLines.filter(_.nonEmpty), Vector("A2", "B", "live")) + + terminal.sendMouse(TerminalInput.Mouse( + scalatui.terminal.MouseAction.Press(scalatui.terminal.MouseButton.Left), + row = 2, + col = 0 + )) + assertEquals(target.received.map(_.boundsRow), Some(2)) + tui.stop() + } + + test("stale candidate is discarded and yields before retrying latest geometry") { + val contexts = ArrayBuffer.empty[NormalResizeRecoveryContext] + val events = ArrayBuffer.empty[TUIDiagnosticEvent] + val terminal = VirtualTerminal(20, 8) + var tui = Option.empty[TUI] + val provider = NormalResizeRecoveryProvider { context => + contexts += context + if contexts.length === 1 then + terminal.resize(12, 6) + tui.get.addChild(MutableFrame(ComponentRender.text("added-before-retry"))) + Vector(s"history-${context.width}") + } + tui = Some(running( + terminal, + MutableFrame(ComponentRender.text("live")), + provider, + Some(TUIDiagnosticObserver(events += _)) + )) + terminal.resize(16, 7) + + assertEquals(contexts.map(_.width).toVector, Vector(16, 12)) + assert(!terminal.output.contains("history-16")) + assertEquals(terminal.output.split("history-12", -1).length - 1, 1) + assert(terminal.output.contains("added-before")) + assert(events.exists { + case TUIDiagnosticEvent.ResizeRecovery( + TUIDiagnosticResizeRecoveryOutcome.Discarded, + Some(TUIDiagnosticResizeRecoveryFailure.StaleGeometry), + _, + _, + _ + ) => true + case _ => false + }) + assert(events.exists { + case TUIDiagnosticEvent.ResizeRecovery( + TUIDiagnosticResizeRecoveryOutcome.Completed, + None, + _, + 1, + _ + ) => true + case _ => false + }) + tui.get.stop() + } + + test("repeated width and height resizes select only each current semantic tail") { + val contexts = ArrayBuffer.empty[NormalResizeRecoveryContext] + val terminal = VirtualTerminal(20, 8) + val tui = running( + terminal, + MutableFrame(ComponentRender.text("live")), + NormalResizeRecoveryProvider { context => + contexts += context + Vector(s"tail-${context.width}-${context.height}") + } + ) + + terminal.resize(15, 7) + assert(terminal.output.contains("tail-15-7")) + terminal.clearWrites() + terminal.resize(10, 5) + assertEquals( + contexts.map(context => context.width -> context.height).toVector, + Vector(15 -> 7, 10 -> 5) + ) + assert(!terminal.output.contains("tail-15-7")) + assertEquals(terminal.output.split("tail-10-5", -1).length - 1, 1) + tui.stop() + } + + test( + "provider work is owner serialized outside lifecycle lock and follow-up render is non-recursive" + ) { + val calls = AtomicInteger(0) + val lockProbe = CountDownLatch(1) + val terminal = VirtualTerminal(20, 8) + var tui = Option.empty[TUI] + val provider = NormalResizeRecoveryProvider { _ => + calls.incrementAndGet() + val probe = Thread(() => { + tui.get.children + lockProbe.countDown() + }) + probe.start() + assert(lockProbe.await(5, TimeUnit.SECONDS), "lifecycle lock remained held during provider") + tui.get.requestRender() + tui.get.flushRender() + Vector("history") + } + tui = Some(running(terminal, MutableFrame(ComponentRender.text("live")), provider)) + terminal.resize(19, 8) + assertEquals(calls.get(), 1) + tui.get.stop() + } + + test( + "oversized and throwing providers fail before recovery publication with redacted diagnostics" + ) { + def runFailure( + provider: NormalResizeRecoveryProvider + ): (Vector[TUIDiagnosticEvent], String, Boolean) = + val events = ArrayBuffer.empty[TUIDiagnosticEvent] + val terminal = VirtualTerminal(20, 4) + val tui = running( + terminal, + MutableFrame(ComponentRender.text("live")), + provider, + Some(TUIDiagnosticObserver(events += _)) + ) + terminal.resize(19, 4) + (events.toVector, terminal.output, terminal.isRunning) + + val oversized = runFailure(NormalResizeRecoveryProvider(context => + Vector.fill(context.maxRows + 1)("secret-row") + )) + assert(!oversized._2.contains("secret-row")) + assertEquals(oversized._3, false) + assert(oversized._1.exists { + case TUIDiagnosticEvent.ResizeRecovery( + TUIDiagnosticResizeRecoveryOutcome.Failed, + Some(TUIDiagnosticResizeRecoveryFailure.RowBudget), + _, + _, + _ + ) => true + case _ => false + }) + + val thrown = runFailure(NormalResizeRecoveryProvider(_ => + throw IllegalStateException("sensitive provider message") + )) + assertEquals(thrown._3, false) + val diagnostics = thrown._1.mkString + assert(!diagnostics.contains("sensitive")) + assert(thrown._1.exists { + case TUIDiagnosticEvent.ResizeRecovery( + TUIDiagnosticResizeRecoveryOutcome.Failed, + Some(TUIDiagnosticResizeRecoveryFailure.Provider), + _, + 0, + _ + ) => true + case _ => false + }) + } + + test("combined write failure is diagnosed and terminal cleanup completes") { + val events = ArrayBuffer.empty[TUIDiagnosticEvent] + val terminal = ProbeTerminal() + val tui = TUI( + terminal, + options( + NormalResizeRecoveryProvider(_ => Vector("secret-history")), + Some(TUIDiagnosticObserver(events += _)) + ) + ) + tui.addChild(MutableFrame(ComponentRender.text("live"))) + tui.start() + terminal.delegate.clearWrites() + terminal.failNextRenderWrite.set(true) + + terminal.resize(19, 8) + + assertEquals(terminal.delegate.isRunning, false) + assert(terminal.delegate.output.contains("secret-history")) + assert(events.exists { + case TUIDiagnosticEvent.ResizeRecovery( + TUIDiagnosticResizeRecoveryOutcome.Failed, + Some(TUIDiagnosticResizeRecoveryFailure.Write), + _, + 1, + _ + ) => true + case _ => false + }) + } + + test("stop from provider discards candidate before publication") { + val terminal = VirtualTerminal(20, 8) + var tui = Option.empty[TUI] + val provider = NormalResizeRecoveryProvider { _ => + tui.get.stop() + Vector("must-not-publish") + } + tui = Some(running(terminal, MutableFrame(ComponentRender.text("live")), provider)) + + terminal.resize(19, 8) + + assert(!terminal.output.contains("must-not-publish")) + assertEquals(terminal.isRunning, false) + } + + test("hardware cursor and mouse routing use recovered live-frame origin") { + final class MouseCursor extends Component, MouseInputHandler: + var received = Option.empty[MouseInputContext] + override def render(width: Int): ComponentRender = ComponentRender( + Vector("live"), + Vector.empty, + Vector(CursorPlacement(0, 1)) + ) + override def handleMouse(context: MouseInputContext): InputResult = + received = Some(context) + InputResult.NoRender + + val terminal = VirtualTerminal(20, 8) + val target = MouseCursor() + val tui = running( + terminal, + target, + NormalResizeRecoveryProvider(_ => Vector("A", "B")), + hardwareCursor = true, + mouseInput = true + ) + terminal.resize(19, 8) + assertEquals(terminal.cursorPosition, 2 -> 1) + + terminal.sendMouse(TerminalInput.Mouse( + scalatui.terminal.MouseAction.Press(scalatui.terminal.MouseButton.Left), + row = 2, + col = 1 + )) + assertEquals(target.received.map(_.localRow), Some(0)) + assertEquals(target.received.map(_.boundsRow), Some(2)) + tui.stop() + } diff --git a/docs/interactive-smoke.md b/docs/interactive-smoke.md index f997269..75debbf 100644 --- a/docs/interactive-smoke.md +++ b/docs/interactive-smoke.md @@ -68,6 +68,37 @@ Kitty-image, and iTerm2-image components passed to `appendToScrollback`. - Confirm cursor placements and Kitty cleanup controls fail before output and terminal state is restored through normal fail-fast cleanup. +## Normal-screen resize recovery smoke checks + +Use a small normal-screen application with `PreserveScrollback`, a bounded application-owned +semantic transcript, `NormalResizeRecoveryProvider`, one retained editor/status frame, and a button +or key that publishes detached output through `appendToScrollback`. + +- In Kitty, iTerm2, and one conventional terminal (for example Terminal.app, GNOME Terminal, or + xterm), append durable `A`, repeatedly change width and height, and have the provider use + `context.previousWidth` and `context.previousMaxRows` to select the old semantic tail before + reflowing it at current width and taking at most `context.maxRows` rows. Confirm the selected tail + appears immediately above the retained live frame without entering alternate screen or clearing + shell scrollback. +- After recovery of `A`, append durable `B`. Confirm visible chronology is `A`, `B`, then the + retained editor/status frame. Change only retained status and confirm neither `A` nor `B` is + repainted as part of the live frame. +- Repeat shrink/grow cycles with a live frame that leaves recovery capacity, fills the viewport, + exceeds the viewport, and is empty. Confirm zero capacity skips provider invocation and an empty + live frame keeps a blank anchor below recovered output for a later append. +- Grow a short viewport to a much taller one and confirm the provider budget remains bounded by the + old viewport capacity. Trigger a redundant same-size resize notification where possible and + confirm it neither invokes recovery nor clears the viewport. +- Trigger another resize while provider rendering is intentionally delayed. Confirm no stale-width + recovery appears and only latest-width output commits. Provider logic must remain retryable and + free of one-shot side effects. +- Return too many provider rows and throw a provider exception in a disposable test application. + Confirm no recovery lines publish, bounded diagnostics contain no transcript or exception text, + and terminal cursor/modes are restored. +- Treat these as emulator persistence checks. Automated PTY coverage verifies clear/recovery/live/ + append byte ordering, `CSI 3 J` absence, synchronized output, and restoration, but a PTY cannot + prove which rows a real emulator preserves or reflows. + ## Scala Native interactive demo Build: diff --git a/docs/porting-notes.md b/docs/porting-notes.md index e94505e..f613f13 100644 --- a/docs/porting-notes.md +++ b/docs/porting-notes.md @@ -46,6 +46,12 @@ When porting a feature: `TUI.appendToScrollback` when `PreserveScrollback` is enabled. The callback-completed operation is bounded, keeps control encoding private, remaps appended Kitty IDs, and rejects retained iTerm2 relocation rather than exposing a raw protocol escape path. +- Normal-screen resize recovery is a siglyph extension beyond current `pi-tui`. An opt-in + `NormalResizeRecoveryProvider` can reconstruct an application-owned, current-width newest durable + tail before the retained live frame when `PreserveScrollback` clears the active viewport. The + provider is synchronous, retryable, text-only, and strictly bounded by both previous and current + live-frame viewport capacity; its context exposes prior geometry for semantic tail selection. + siglyph retains no transcript and makes no terminal-independent survivor or deduplication claim. - Kitty retransmission differs from the earlier lowercase targeted cleanup: every old positive ID retransmitted by `a=T` is deleted first with one uppercase `a=d,d=I,i=` command, and removed old IDs receive the same cleanup. Cleanup order follows previous-frame control order. New IDs and unchanged IDs outside a partial redraw range receive no cleanup. Delete-all remains `a=d,d=A`. - Typed validation failures and `TerminalRenderControl.toString` retain only bounded semantic kind, optional ID, geometry, frame dimensions, and duplicate coordinates. They do not retain or print image payloads, filenames, controls, placements, or application text. - Terminal runtime now sends a cell-size query (`ESC[16t`) on start and consumes valid terminal replies (`ESC[6;H;Wt`) for runtime image sizing. `ImageRenderOptions` uses fixed `ImageCellDimensions` by default; low-level protocol helpers opt into queried dimensions with `ImageCellDimensionsSource.Runtime`. When reply data is missing or invalid, runtime image layout keeps deterministic fallback dimensions (`9x18`). diff --git a/docs/runtime-diagnostics.md b/docs/runtime-diagnostics.md index 7ad5a12..9cc0624 100644 --- a/docs/runtime-diagnostics.md +++ b/docs/runtime-diagnostics.md @@ -1,9 +1,10 @@ # Runtime diagnostics and resize clearing `TUIOptions.diagnosticObserver` enables structured diagnostics for one TUI instance. The observer -receives lifecycle, resize, redraw, append-outcome, and terminal-write metadata in runtime order. Events expose -only enums, terminal/frame geometry, resize generations, row indexes, and output byte counts. They -do not include application text, image payloads, raw terminal-query replies, or output bytes. +receives lifecycle, resize, redraw, resize-recovery, append-outcome, and terminal-write metadata in +runtime order. Events expose only enums, terminal/frame geometry, resize generations, row indexes, +budgets, row counts, and output byte counts. They do not include application text, image payloads, +raw terminal-query replies, or output bytes. The callback runs synchronously outside the runtime lifecycle lock and terminal-write lock. If it throws, Siglyph swallows that observer failure and permanently disables the observer for that TUI; @@ -17,6 +18,27 @@ This retains shell history where the terminal supports it, but a terminal with u semantics may briefly show stale rows. Alternate-screen resize behavior is unchanged and never clears normal-screen scrollback. +Optional `TUIOptions.normalResizeRecovery` adds a text-only recovery phase to committed +normal-screen `PreserveScrollback` geometry changes. Redundant same-size resize notifications do not +invoke recovery or clear the viewport. The runtime first prepares the retained live frame, reserves +its physical footprint (including one cursor anchor for an empty frame), and bounds `maxRows` by +both the old viewport's maximum durable-prefix capacity and space above the new live frame. The +context also supplies previous dimensions and that old capacity so applications can select the old +semantic tail before reflowing it at current width. Returning more rows fails before output rather +than being silently truncated. A stale geometry candidate is discarded and may invoke the provider +again for latest dimensions. + +Recovery diagnostics report bounded `Completed`, `Discarded`, or `Failed` outcomes, provider, +row-budget, stale-geometry, or write categories where applicable, the strict budget, recovered row +count, and resize generation. They never retain provider lines, transcript entries, exception +messages, escape source, object references, typed controls, payloads, filenames, or terminal bytes. +Provider failure and row-budget violation use normal fail-fast cleanup; write failure makes no +rollback claim. + +Siglyph cannot inspect emulator scrollback or know which rows survived resize reflow. Applications +own semantic transcript retention, current-width reflow, and newest-tail selection. Diagnostics and +the recovery API therefore do not promise terminal-independent deduplication. + Append diagnostics expose only bounded outcome/failure categories, row and control counts, screen mode, and resize generation. They never retain component text, exception messages, image payloads, filenames, encoded controls, remapped image IDs, or terminal-write contents. The application-owned diff --git a/openspec/changes/add-normal-resize-recovery/.openspec.yaml b/openspec/changes/add-normal-resize-recovery/.openspec.yaml new file mode 100644 index 0000000..1c37182 --- /dev/null +++ b/openspec/changes/add-normal-resize-recovery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-05 diff --git a/openspec/changes/add-normal-resize-recovery/design.md b/openspec/changes/add-normal-resize-recovery/design.md new file mode 100644 index 0000000..786ef9c --- /dev/null +++ b/openspec/changes/add-normal-resize-recovery/design.md @@ -0,0 +1,250 @@ +## Context + +`TUI.appendToScrollback` can commit detached durable output above one retained normal-screen frame, but a `PreserveScrollback` resize clears the active viewport before repainting that frame. Siglyph intentionally retains no append transcript, so it cannot reconstruct durable rows that occupied the invalidated viewport. Applications may retain the semantic transcript and reflow its newest tail, but today they cannot publish that tail through the single runtime owner. + +The existing runtime already has the required authority boundary: one drain owner serializes application code and render work; one terminal-write lock protects complete writes; `ComponentRender` ordinary lines are sanitized; typed controls remain private until final encoding; resize generation rejects stale candidates; `previousFrame`, `cursorRow`, layouts, and `latestFrameStartRow` describe only the retained live frame. The change must preserve those invariants and the append callback/FIFO contract on JVM and Scala Native. + +There is an unavoidable terminal boundary: Siglyph cannot inspect emulator scrollback or know which semantic transcript rows survived terminal-specific resize reflow. Recovery can therefore provide a strict budget and safe publication point, but the application remains responsible for selecting only the newest durable tail that belonged in the cleared viewport. + +## Goals / Non-Goals + +**Goals:** + +- Add an opt-in, TUI-owned recovery phase for geometry-changing normal-screen `PreserveScrollback` redraws. +- Let an application reflow a bounded semantic transcript at current width without transferring retained component authority. +- Publish recovery and the retained frame in one synchronized render write. +- Keep the semantic differential baseline, typed controls, cursor, layout, focus, and input ownership attached only to the live frame. +- Preserve chronological placement for a later `appendToScrollback` operation. +- Reject stale, oversized, or failed candidates before output where possible and use normal fail-fast cleanup otherwise. +- Expose bounded, redacted diagnostics and shared JVM/Native behavior without a runtime dependency. + +**Non-Goals:** + +- Retaining appended components, payloads, output bytes, or a semantic transcript inside `TUI`. +- Detecting which terminal scrollback rows survived resize or guaranteeing emulator-specific reflow behavior. +- Recovering on startup, forced renders, ordinary differential redraws, appends, font/cell-size updates without terminal geometry change, alternate-screen redraws, or `ClearScrollback` redraws. +- Asynchronous recovery, cancellation, completion callbacks, or a new scheduler category. +- Typed recovery controls, images, cursor placements, focus, overlays, mouse targets, or interactive recovered content in the first version. +- Rolling back a backend write that accepts only a prefix. + +## Decisions + +### 1. Expose a synchronous text-only provider through `TUIOptions` + +Add shared-core public types shaped as: + +```scala +trait NormalResizeRecoveryProvider: + def render(context: NormalResizeRecoveryContext): Vector[String] + +final case class NormalResizeRecoveryContext( + width: Int, + height: Int, + maxRows: Int, + previousWidth: Int, + previousHeight: Int, + previousMaxRows: Int +) + +final case class TUIOptions( + // existing fields, + normalResizeRecovery: Option[NormalResizeRecoveryProvider] = None +) +``` + +The provider receives positive current and previous terminal dimensions, the maximum durable prefix +that could have occupied the old viewport above its live frame, and a positive strict publication +budget bounded by both old and new capacities. It returns ordinary lines in oldest-to-newest display +order. Returning `Vector[String]` rather than `Component`, `ComponentRender`, or raw terminal bytes +makes the first contract structurally text-only: recovery cannot attach context, retain component +identity, acquire cursor ownership, or introduce a typed control whose later cleanup would need +historical ownership. + +Ordinary lines still support the existing bounded SGR and OSC 8 allowlist. Every other terminal-looking sequence remains inert, and final width sanitization and line resets use the same path as retained and append output. + +The provider runs synchronously as application-controlled render code on the existing drain owner and outside lifecycle and terminal-write locks. It may be called more than once when geometry invalidates a candidate, so it must derive output from current semantic state rather than perform one-shot side effects. It has no completion callback because its success is part of the resize redraw commit. + +A configured provider is valid only with `TUIScreenMode.Normal` and `NormalResizeClearPolicy.PreserveScrollback`. `TUI.start()` fails before backend startup or terminal output when the option combination is incompatible, rather than silently ignoring a misconfigured provider. + +Alternatives considered: + +- Return `ComponentRender`: rejected for version one because controls and cursors would then need rejection despite being representable, and image identity/cleanup semantics would duplicate append complexity. +- Accept a detached `Component`: rejected because recovery needs a row budget and no retained or restricted context authority. +- Accept raw terminal bytes: rejected because it bypasses the trusted-output boundary. +- Register a mutable provider after startup: rejected because immutable options provide one lifecycle-stable source and avoid registration races. + +### 2. Track resize recovery eligibility separately from generic force/clear rendering + +`publishResize` continues to coalesce resize work and increment `resizeGeneration`, but records that the latest pending forced-clear render came from terminal geometry change. Generic `requestRender(force = true)`, initial rendering, image cell-dimension changes, appends, and ordinary full redraws do not set recovery eligibility. + +A redundant resize callback whose positive width and height equal the committed dimensions remains +observable through ordinary resize diagnostics but is not a geometry-changing recovery event. It +does not invoke the provider or perform the destructive viewport clear; any coalesced forced render +may still repaint through the ordinary owned path. + +When Render work is claimed, it snapshots the eligible resize generation. A later resize supersedes that marker. Recovery is attempted only when all of these hold: + +- lifecycle is `Running`; +- a prior live frame is committed; +- the current work represents a terminal width or height change; +- normal screen and `PreserveScrollback` are active; and +- a provider is configured. + +This keeps provider invocation tied to the event that destroys the active viewport and avoids treating every forced redraw as durable-history loss. + +Alternative: infer recovery from `force && clear`. Rejected because startup, alternate-screen work, and application-forced clear/render paths can share those flags without representing an invalidated normal viewport. + +### 3. Render the live frame first and enforce a strict row budget + +The owner renders, composes, validates, and prepares the current live frame before invoking recovery. The strict budget is: + +```text +liveFrameFootprintRows = max(1, liveFrameRowCount) +currentMaxRows = max(0, terminalHeight - liveFrameFootprintRows) +previousLiveFrameFootprintRows = max(1, previousLiveFrameRowCount) +previousMaxRows = max(0, previousTerminalHeight - previousLiveFrameFootprintRows) +maxRows = min(currentMaxRows, previousMaxRows) +``` + +`ComponentRender.lines` already includes rows reserved by typed controls, so a non-empty +`liveFrameRowCount` is the complete retained frame height. An empty retained frame still needs one +physical cursor anchor: append and differential movement must have a live-frame row below recovery +rather than treating the final recovery row as replaceable live output. If the resulting footprint +fills or exceeds the viewport, recovery is empty and the provider is not invoked. + +For a positive budget, the provider receives current width and height, previous width and height, +`previousMaxRows`, and `maxRows`. The old capacity lets it identify the newest semantic entries that +could have occupied the invalidated old viewport before reflowing those entries at current width. +Bounding `maxRows` by both old and current capacities prevents a large viewport growth from +replaying rows that necessarily predated the old viewport tail. Siglyph sanitizes every returned +line at current width, adds the normal line reset, and rejects the candidate if the provider returned +more than `maxRows`. Recovery sanitization increments the existing aggregate count but does not +retain provider source or sanitized text in the content-bearing `lastSanitizedLine` sample. It does +not silently truncate or `takeRight`, because only the application knows semantic entry boundaries +and whether dropping a prefix would split an entry. Empty output is valid and introduces no extra +blank row. + +The provider is contractually responsible for using `context.previousWidth` and +`context.previousMaxRows` to select the semantic entries that could have occupied the invalidated +old viewport, reflowing that selected tail at `context.width`, selecting at most the newest +`context.maxRows` current-width rows, and returning them oldest-to-newest. Siglyph does not ask for +or retain the complete transcript and does not infer survivors from terminal scrollback. + +Alternatives: + +- Budget before live rendering: rejected because overlays and current component state determine the actual live row count. +- Clamp oversized output: rejected because silent row loss can split semantic output and obscure provider bugs. +- Expose an exact survivor count: rejected because terminal emulators can reflow scrollback + differently. Previous geometry and the old live-frame-derived maximum are structural upper bounds, + not claims about emulator scrollback survivors. + +### 4. Publish clear, recovery, and live frame as one render commit + +For an eligible committed candidate, one synchronized output buffer performs: + +1. synchronized-output start and autowrap disable; +2. normal active-viewport clear and home, without `CSI 3 J`; +3. existing retained Kitty replacement cleanup required by the new live frame; +4. zero or more sanitized recovery rows; +5. exactly one row transition when non-empty recovery is followed by live output or its empty-frame anchor; +6. the prepared retained live frame and its typed controls; +7. retained structured hardware-cursor restoration; and +8. synchronized-output end and autowrap restoration. + +No bytes are written between recovery and live output, and recovery never enters `previousFrame`. After success: + +- `previousFrame` is the newly prepared live frame only; +- `previousWidth` and `previousHeight` are current dimensions; +- retained base/overlay layouts and focus remain live-frame-only; +- `cursorRow` remains frame-relative to the live frame; +- `latestFrameStartRow` becomes the physical start of the live frame after accounting for the recovery prefix and any terminal scrolling; and +- the committed recovery row count is emitted diagnostically. + +For a clear starting at terminal row zero, live-frame origin is computed from the combined recovery/live write and then offset by recovery row count. An empty live frame emits or moves to its reserved blank anchor after recovery, so later append cannot overwrite the last recovered row. This also handles an over-height live frame, for which recovery is zero and the origin may be conceptually above the visible viewport as today. + +Keeping `cursorRow` live-relative is essential: later differential redraw and append code can move to live row zero and clear only the replaceable frame. The recovery prefix then remains above that region. + +Alternative: concatenate recovery into `previousFrame`. Rejected because ordinary redraw would repaint durable history and later append would place new output above recovered old output. + +### 5. Preserve append chronology without changing append admission or identity ownership + +After a successful recovery commit, `appendToScrollback(B)` continues to operate on the semantic live frame. It moves to live row zero, clears only the live region, emits `B`, and redraws the live frame. The physical order is therefore: + +```text +recovered durable tail -> B -> retained live frame +``` + +Recovery has no Kitty/iTerm2 controls or ownership ledger, so append admission, retained-iTerm2 rejection, append Kitty ID remapping, the 64-operation bound, callbacks, and FIFO ordering remain unchanged. Successfully recovered text is detached one-shot output and is not addressed by cleanup. + +Repeated resizes may ask the provider to reconstruct the same semantic newest tail because the active viewport is cleared each time. That is intentional recovery of invalidated viewport content, not TUI transcript replay. The provider must not return older rows already known to be outside that tail. Real emulator reflow can still preserve or move rows differently; docs and smoke tests state that limitation rather than promise impossible deduplication. + +### 6. Reject stale candidates at the existing pre-write boundary + +The render attempt snapshots generation, width, and height before live-frame and provider rendering. Immediately before output and baseline mutation, it compares all three with current runtime state. + +If stale, Siglyph emits no candidate bytes, leaves the prior semantic baseline and layouts committed, coalesces a forced clear render for latest dimensions, and returns to the ordinary scheduler. The provider may be invoked again for the latest geometry. There is no owner-local retry loop and no new fairness category because recovery remains part of Render work. + +If stop or runtime failure wins before commit, the candidate is discarded with queued ordinary render work. No recovery callback must be retained during Cleaning. + +### 7. Use fail-fast failures and redacted recovery diagnostics + +Add bounded diagnostic models shaped around: + +```scala +enum TUIDiagnosticResizeRecoveryOutcome: + case Completed, Discarded, Failed + +enum TUIDiagnosticResizeRecoveryFailure: + case StaleGeometry, Provider, RowBudget, Write + +case ResizeRecovery( + outcome: TUIDiagnosticResizeRecoveryOutcome, + failure: Option[TUIDiagnosticResizeRecoveryFailure], + maxRows: Int, + rowCount: Int, + resizeGeneration: Long +) +``` + +Exact names may follow local naming conventions, but the public event remains additive and structural. A successful eligible redraw emits `Completed`, including zero recovered rows. A stale attempt may emit `Discarded/StaleGeometry`; the later committed attempt emits its own completion. Provider exceptions and row-budget violations emit `Failed` before output and enter normal runtime failure/cleanup. A backend write failure emits `Failed/Write`, restores terminal state, and makes no rollback claim. + +Diagnostics never contain provider lines, transcript entries, exception messages, terminal bytes, SGR/OSC contents, controls, or application object references. Existing Resize, Redraw, and Write events remain available and retain their current meaning. + +Alternative: add a recovery row field to the existing `Redraw` enum case. Rejected because changing a public enum-case arity is more disruptive than adding a new event case and cannot represent failed/discarded attempts cleanly. + +### 8. Validate portable semantics and terminal-boundary behavior + +Shared `VirtualTerminal` tests cover provider eligibility, budget, chronology, baseline separation, ordinary redraw non-invocation, repeated/coalesced resize, stale retry, zero budget, over-budget/provider failure, diagnostics, hardware cursor, mouse origin, stop races, and JVM/Native parity. + +JVM PTY coverage verifies clear/recovery/live/append byte ordering, absence of `CSI 3 J`, synchronized output, and terminal restoration. PTY tests do not claim emulator scrollback persistence or deduplication. Manual Kitty, iTerm2, and one conventional terminal smoke checks exercise preserved scrollback and repeated width/height changes. + +## Risks / Trade-offs + +- **Terminal resize reflow can preserve different physical rows across emulators** → Define recovery as provider-selected reconstruction, avoid survivor inference, and document/manual-smoke emulator behavior. +- **A slow provider blocks input and other callbacks** → Keep the provider synchronous, bounded in output, owner-serialized, and document that it must be fast and side-effect-light. +- **Provider state locking can reproduce application lock inversion** → Invoke outside runtime locks and document the same application-lock discipline as component rendering. +- **Oversized recovery could overwrite live-frame geometry** → Validate row count strictly before write and fail fast rather than truncate. +- **Viewport growth could offer space for history older than the invalidated old tail** → Bound the + publication budget by both previous and current live-frame-derived capacities and expose previous + width/capacity for application semantic selection. +- **Live frame fills the viewport** → Compute zero budget, skip provider invocation, and perform the existing resize redraw safely. +- **Resize changes during provider execution** → Discard before output and retry through coalesced Render scheduling. +- **Backend accepts only part of the combined write** → Run normal terminal restoration and report write-category diagnostics without rollback claims. +- **Adding a provider to `TUIOptions` changes binary shape** → Keep source-compatible trailing default, document the pre-1.0 additive API, and release it in a minor version. + +## Migration Plan + +1. Add shared public provider/context and diagnostic models with Scaladoc, plus incompatible-option validation before terminal startup. +2. Add resize-origin tracking that distinguishes geometry-change recovery from generic forced rendering. +3. Add failing shared tests for budget computation, invocation boundaries, stale candidates, failure handling, and diagnostics. +4. Implement text-only provider rendering, sanitization, strict row validation, and the combined recovery/live full-render planner. +5. Update live-frame physical-origin, cursor, mouse, differential baseline, append chronology, and cleanup tests. +6. Add JVM PTY ordering/restoration coverage and manual terminal-emulator smoke instructions. +7. Update README, runtime diagnostics, porting notes if parity differs, Scaladoc, and changelog. +8. Run full JVM/Native, PTY, formatting, lint, and strict OpenSpec validation. + +Rollback removes the provider/context option and recovery diagnostic cases before release. It restores the existing preserve-scrollback full redraw path; no transcript migration or persisted runtime state exists. + +## Open Questions + +None. Version one is intentionally synchronous, text-only, resize-only, normal-screen-only, preserve-scrollback-only, provider-selected, strictly row-bounded, and outside retained frame state. diff --git a/openspec/changes/add-normal-resize-recovery/proposal.md b/openspec/changes/add-normal-resize-recovery/proposal.md new file mode 100644 index 0000000..e566a1c --- /dev/null +++ b/openspec/changes/add-normal-resize-recovery/proposal.md @@ -0,0 +1,36 @@ +## Why + +Normal-screen applications can combine durable `appendToScrollback` history with one mutable retained frame, but `PreserveScrollback` resize currently clears the active viewport without giving the TUI owner a safe place to reconstruct the durable tail that occupied it. Putting that tail in the retained tree reverses later append chronology, while direct terminal writes bypass Siglyph's serialization, sanitization, cursor accounting, diagnostics, and cleanup ownership. + +## What Changes + +- Add an opt-in `NormalResizeRecoveryProvider` and bounded `NormalResizeRecoveryContext` to shared core, configured through `TUIOptions` only for normal-screen `PreserveScrollback` sessions. +- Invoke recovery only for a committed width or height delta; redundant same-size notifications do not recover or clear the viewport. Render and validate the retained live frame first, reserve its physical rows (including one cursor anchor for an empty frame), and ask the provider for current-width durable tail lines only when a strict budget bounded by both previous and current viewport capacities is positive. Expose previous geometry/capacity so the application can select the old semantic tail before reflowing it. +- Make the first recovery API text-only: the provider returns ordinary line strings, not a component or `ComponentRender`, so it cannot acquire cursor or typed-control ownership. Existing ordinary-line ANSI allowlisting, sanitization, width handling, and line resets still apply. +- Reject provider output that exceeds `maxRows` before terminal output. Provider exceptions and invalid output use normal fail-fast runtime cleanup and bounded redacted diagnostics. +- After clearing and homing the active viewport, emit recovery lines followed by the retained live frame in one TUI-owned synchronized render write. Keep only the live frame, controls, cursor, layouts, and focus in retained semantic state. +- Track the live frame's physical origin after the recovery prefix so differential redraw, hardware cursor placement, coordinate-aware mouse input, cleanup, and later appends remain live-frame-relative. +- Preserve chronological append behavior: after recovery, a later append is inserted between the recovered durable tail and the retained live frame. +- Recheck resize generation and dimensions before publication. A stale recovery/live candidate is discarded and recomputed through the existing coalesced Render work rather than published at stale geometry. +- Add redaction-safe recovery diagnostics, shared JVM/Scala Native tests, focused PTY ordering/restoration coverage, manual normal-screen smoke coverage, public Scaladoc, runtime documentation, and a changelog entry. +- Keep transcript storage, survivor inference, asynchronous provider completion, alternate-screen recovery, typed recovery controls/images, and new runtime dependencies out of scope. + +## Capabilities + +### New Capabilities + +- `normal-resize-recovery`: Defines opt-in, bounded, text-only reconstruction of durable normal-screen viewport history before the retained live frame. + +### Modified Capabilities + +- `append-only-output`: Define append placement and ordering when a resize-recovered durable prefix exists above the retained live frame. +- `component-rendering`: Keep recovery output outside the retained differential baseline while preserving cursor, layout, and frame-origin behavior. +- `terminal-runtime`: Extend preserve-scrollback geometry-change redraws with an optional owner-serialized recovery phase and stale-candidate handling. + +## Impact + +- Public shared-core API: new provider/context types and one additive `TUIOptions` field. +- Shared runtime: resize cause tracking, render planning, row-budget validation, physical live-frame origin accounting, diagnostics, and failure handling in `TUI`. +- Append integration: later append publication clears and relocates only the retained frame and leaves the recovered prefix in chronological history. +- Tests and docs: shared `VirtualTerminal` suites, concurrency/lifecycle tests, JVM PTY conformance, runtime diagnostics, interactive smoke instructions, README/Scaladoc, and changelog. +- Platform and dependencies: identical JVM/Scala Native core behavior with no backend API change and no new runtime dependency. diff --git a/openspec/changes/add-normal-resize-recovery/specs/append-only-output/spec.md b/openspec/changes/add-normal-resize-recovery/specs/append-only-output/spec.md new file mode 100644 index 0000000..6e6a0ee --- /dev/null +++ b/openspec/changes/add-normal-resize-recovery/specs/append-only-output/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Append output follows committed resize recovery +When a successful normal-screen resize recovery has placed detached durable rows immediately above the retained live frame, later append-only output SHALL operate only on the live-frame region and SHALL preserve chronological durable-output order. + +#### Scenario: Append follows recovered history +- **WHEN** durable `A` is recovered during resize and durable `B` is later published through `appendToScrollback` +- **THEN** visible physical order SHALL be recovered `A`, appended `B`, then the retained live frame + +#### Scenario: Append does not clear recovered rows +- **WHEN** append publication moves from the retained cursor to live-frame row zero +- **THEN** it SHALL clear and relocate only the replaceable live-frame region and SHALL NOT repaint, erase, or include recovery rows in `previousFrame` + +#### Scenario: Multiple appends follow recovery +- **WHEN** multiple append operations are accepted after one recovery commit +- **THEN** they SHALL retain existing FIFO completion/publication order between the recovered prefix and retained live frame + +#### Scenario: Empty recovery changes no append behavior +- **WHEN** an eligible resize commits zero recovery rows +- **THEN** later append admission, output planning, frame relocation, callbacks, and typed-control ownership SHALL remain unchanged + +#### Scenario: Recovery adds no image identity ownership +- **WHEN** text-only recovery precedes a retained frame +- **THEN** append Kitty ID remapping, the bounded append ownership ledger, retained-iTerm2 rejection, and retained cleanup semantics SHALL remain unchanged diff --git a/openspec/changes/add-normal-resize-recovery/specs/component-rendering/spec.md b/openspec/changes/add-normal-resize-recovery/specs/component-rendering/spec.md new file mode 100644 index 0000000..06e61c5 --- /dev/null +++ b/openspec/changes/add-normal-resize-recovery/specs/component-rendering/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Resize recovery prefix is detached from differential frame state +The TUI renderer SHALL treat normal resize recovery rows as a one-shot physical prefix and SHALL keep all retained rendering, comparison, control, cursor, and layout state frame-relative to the live component frame. + +#### Scenario: Recovery is not a differential baseline row +- **WHEN** a successful resize redraw emits recovery rows before the live frame +- **THEN** `previousFrame` SHALL contain only prepared live-frame lines, controls, and selected cursor metadata + +#### Scenario: Live frame changes after recovery +- **WHEN** a later render changes one retained line or control +- **THEN** first-changed-row calculation and partial output SHALL use live-frame-relative rows and SHALL not compare or repaint recovery rows + +#### Scenario: Live frame is unchanged after recovery +- **WHEN** a later ordinary render produces the same prepared live frame +- **THEN** no frame repaint SHALL occur solely because recovery rows were previously emitted + +#### Scenario: Retained control replacement follows recovery +- **WHEN** a retained Kitty control changes after recovery +- **THEN** replacement cleanup and retransmission SHALL use only retained old/new frames and SHALL not treat recovery as control-owned state + +#### Scenario: Retained layout follows relocated frame +- **WHEN** recovery changes the physical start row of the live frame +- **THEN** semantic base and overlay layout bounds SHALL remain frame-relative while mouse routing applies the updated physical frame origin + +### Requirement: Recovery rendering remains part of serialized Render work +The live-frame render, recovery provider invocation, stale-geometry check, and combined redraw planning SHALL execute as one ordinary Render work unit under the existing deterministic single-owner scheduler. + +#### Scenario: Other categories are continuously ready +- **WHILE** Structural, Action, Ingress, Control, Append, or later Render work remains ready during recovery rendering +- **WHEN** the current recovery Render work completes or is discarded as stale +- **THEN** owner selection SHALL return to the existing six-category fairness cycle + +#### Scenario: Provider publishes a render request +- **WHEN** provider code requests follow-up rendering while the current Render work owns the drain +- **THEN** the request SHALL coalesce for later processing and SHALL not recursively invoke component or provider rendering + +#### Scenario: Resize coalesces during provider rendering +- **WHEN** one or more resize notifications arrive while provider code runs +- **THEN** resize work SHALL remain capacity-free and coalesced, the stale candidate SHALL not mutate differential state, and a latest-geometry Render SHALL remain pending diff --git a/openspec/changes/add-normal-resize-recovery/specs/normal-resize-recovery/spec.md b/openspec/changes/add-normal-resize-recovery/specs/normal-resize-recovery/spec.md new file mode 100644 index 0000000..756c66e --- /dev/null +++ b/openspec/changes/add-normal-resize-recovery/specs/normal-resize-recovery/spec.md @@ -0,0 +1,218 @@ +## ADDED Requirements + +### Requirement: TUI exposes opt-in normal resize recovery +Siglyph SHALL expose a shared-core `NormalResizeRecoveryProvider`, `NormalResizeRecoveryContext`, and additive `TUIOptions.normalResizeRecovery` option for reconstructing durable normal-screen viewport history during resize. The option SHALL default to absent and SHALL NOT change existing applications. + +#### Scenario: Existing options have no provider +- **WHEN** an application constructs `TUIOptions` without normal resize recovery +- **THEN** normal-screen and alternate-screen rendering SHALL retain their existing resize behavior + +#### Scenario: Compatible recovery is configured +- **WHEN** a provider is configured with `TUIScreenMode.Normal` and `NormalResizeClearPolicy.PreserveScrollback` +- **THEN** the TUI SHALL make that provider eligible only for committed geometry-changing resize redraws + +#### Scenario: Recovery is configured with scrollback clearing +- **WHEN** a provider is configured with `NormalResizeClearPolicy.ClearScrollback` +- **THEN** TUI startup SHALL fail before backend startup or terminal output rather than silently discard recovery output or promise persistence + +#### Scenario: Recovery is configured for alternate screen +- **WHEN** a provider is configured with `TUIScreenMode.Alternate` +- **THEN** TUI startup SHALL fail before backend startup or terminal output because alternate-screen output is not normal shell scrollback + +#### Scenario: Initial render occurs +- **WHEN** a compatible TUI starts and commits its initial frame +- **THEN** the provider SHALL NOT be invoked and initial output SHALL retain existing first-render behavior + +#### Scenario: Ordinary render work occurs +- **WHEN** an input, action, overlay, structure mutation, append, image cell-size update, ordinary differential redraw, or application-forced render occurs without terminal geometry change +- **THEN** the provider SHALL NOT be invoked + +#### Scenario: Backend reports unchanged geometry +- **WHEN** a resize callback reports the same positive width and height as the committed frame +- **THEN** the provider SHALL NOT be invoked and the active viewport SHALL NOT be destructively cleared + +### Requirement: Recovery uses strict previous/current live-frame-derived row bounds +Siglyph SHALL render, compose, validate, and prepare the retained live frame first. It SHALL +calculate current capacity as `max(0, terminalHeight - max(1, liveFrameRowCount))`, previous +capacity as `max(0, previousTerminalHeight - max(1, previousLiveFrameRowCount))`, and recovery +`maxRows` as the smaller capacity. The context SHALL expose positive current and previous dimensions +plus positive previous capacity whenever the provider is invoked. The minimum one-row footprint +SHALL reserve a physical cursor anchor for an empty retained frame. + +#### Scenario: Live frame leaves viewport rows available +- **WHEN** the previous and prepared live frames each have 3 rows in terminals with height 10 +- **THEN** the provider SHALL receive current and previous positive dimensions, `previousMaxRows` 7, and `maxRows` 7 + +#### Scenario: Viewport grows substantially +- **WHEN** a one-row live frame moves from a terminal of height 5 to a terminal of height 100 +- **THEN** `previousMaxRows` and `maxRows` SHALL both be 4 rather than allowing 99 rows of older history to replay + +#### Scenario: Overlay extends the live frame +- **WHEN** a visible overlay causes the final prepared retained frame to occupy additional rows +- **THEN** those rows SHALL reduce `maxRows` before the provider is invoked + +#### Scenario: Typed retained control reserves rows +- **WHEN** a valid retained typed control reserves rows represented by the prepared frame +- **THEN** every reserved frame row SHALL count toward the live-frame row count and reduce recovery capacity + +#### Scenario: Retained frame is empty +- **WHEN** the previous and prepared retained frames have zero semantic rows in terminals with height 10 +- **THEN** Siglyph SHALL reserve one physical live-frame anchor row and the provider SHALL receive `previousMaxRows` and `maxRows` 9 + +#### Scenario: Live frame fills or exceeds viewport +- **WHEN** the live-frame physical footprint is greater than or equal to terminal height +- **THEN** recovery SHALL use zero rows, SHALL NOT invoke the provider, and SHALL redraw the live frame safely + +#### Scenario: Provider returns empty output +- **WHEN** a provider is invoked with positive `maxRows` and returns no lines +- **THEN** recovery SHALL add no blank line or row transition and the retained frame SHALL be emitted as in the existing preserve-scrollback redraw + +#### Scenario: Provider stays within budget +- **WHEN** the provider returns at most `maxRows` lines in oldest-to-newest order +- **THEN** Siglyph SHALL preserve that line order immediately before the retained live frame + +#### Scenario: Provider exceeds budget +- **WHEN** the provider returns more than `maxRows` lines +- **THEN** Siglyph SHALL fail the resize candidate before terminal output rather than truncating, dropping, or partially publishing provider output + +### Requirement: Applications select the semantic recovery tail +The recovery contract SHALL require the provider to use `context.previousWidth` and +`context.previousMaxRows` to select the application-owned semantic tail that could have occupied the +invalidated old viewport, reflow that tail at `context.width`, and return only its newest rows bounded +by `context.maxRows`. Siglyph SHALL NOT retain a transcript, infer emulator scrollback survivors, +request the complete history, or claim terminal-independent deduplication. + +#### Scenario: Older history is already above the viewport +- **WHEN** older durable rows remain in preserved terminal scrollback and only a newer tail occupied the invalidated viewport +- **THEN** the provider SHALL return only that newer tail and Siglyph SHALL publish only the returned rows + +#### Scenario: Semantic entry wraps at current width +- **WHEN** a durable entry wraps differently at the resized width +- **THEN** the provider MAY reflow its semantic model and SHALL return at most the newest `maxRows` resulting display rows without requiring Siglyph to retain prior rendered bytes + +#### Scenario: Emulator reflows scrollback differently +- **WHEN** terminal-specific resize behavior preserves or moves physical rows differently from another emulator +- **THEN** Siglyph SHALL make no claim that it can inspect those survivors or eliminate duplicates the provider selected + +### Requirement: Recovery providers are owner-serialized and retryable +Siglyph SHALL invoke the provider synchronously as application-controlled Render work on the existing single drain owner, outside lifecycle and terminal-write locks, and SHALL allow no concurrent application callback or component render. + +#### Scenario: Provider requests follow-up work through captured application state +- **WHEN** provider code causes ordinary runtime work to be published while it owns Render work +- **THEN** that work SHALL be recorded for later owner processing without recursive drain or concurrent application execution + +#### Scenario: Resize invalidates provider output +- **WHEN** resize generation, width, or height changes after live-frame or provider rendering but before publication +- **THEN** Siglyph SHALL publish none of the stale recovery/live candidate, preserve the committed semantic baseline, and schedule a forced recovery redraw at latest dimensions + +#### Scenario: Provider is invoked again after invalidation +- **WHEN** a stale candidate is retried for latest dimensions +- **THEN** the provider MAY run more than once, exactly one latest candidate SHALL commit, and there SHALL be no owner-local retry loop + +#### Scenario: Stop wins before recovery publication +- **WHEN** lifecycle leaves `Running` after provider rendering but before the synchronized publication boundary +- **THEN** the candidate SHALL publish nothing, queued ordinary recovery work SHALL be discarded, and cleanup SHALL not retain a recovery callback + +### Requirement: Resize recovery is structurally text-only +`NormalResizeRecoveryProvider.render` SHALL return only `Vector[String]` ordinary lines. Recovery SHALL expose no `Component`, `ComponentRender`, `TerminalRenderControl`, `CursorPlacement`, raw trusted writer, or terminal-control encoder authority. + +#### Scenario: Recovery line contains supported styling +- **WHEN** a returned line contains bounded valid ESC-form SGR or OSC 8 metadata +- **THEN** existing ordinary-line allowlisting SHALL preserve that supported metadata and close/reset it at line boundaries + +#### Scenario: Recovery line resembles a terminal protocol +- **WHEN** a returned line contains image, cursor, CSI, non-OSC-8 OSC, APC, DCS, C0, DEL, C1, or other unsupported terminal-control-looking data +- **THEN** existing trusted-output sanitization SHALL keep it inert and SHALL infer no typed authority + +#### Scenario: Recovery line exceeds current width +- **WHEN** a returned line has visible width greater than `context.width` +- **THEN** the existing ANSI- and Unicode-aware runtime safety path SHALL sanitize it to current width and increment aggregate sanitization accounting +- **AND** Siglyph SHALL NOT retain the provider source or sanitized line in the content-bearing last-sanitization sample + +#### Scenario: Application needs typed recovery images +- **WHEN** an application wants Kitty, iTerm2, cleanup, or cursor metadata in recovery output +- **THEN** the first recovery API SHALL provide no such path and SHALL require a future separately specified ownership contract + +### Requirement: Recovery and live frame commit as one owned redraw +For a valid current candidate, Siglyph SHALL clear and home only the active normal-screen viewport, emit recovery rows followed by the retained live frame in one synchronized TUI-owned render write, and SHALL omit `CSI 3 J`. + +#### Scenario: Recovery prefix and live frame are committed +- **WHEN** a compatible resize candidate contains recovery rows and a live frame +- **THEN** terminal output order SHALL be synchronized-output start, viewport clear/home, required retained-control replacement cleanup, recovery rows, live-frame rows and controls, retained hardware cursor placement, synchronized-output end, and autowrap restoration + +#### Scenario: No writer interleaves output +- **WHEN** input, append, control, query, render, or stop work is concurrently ready +- **THEN** no terminal bytes SHALL interleave between the recovery prefix and retained live frame + +#### Scenario: Backend write fails +- **WHEN** the backend throws after the combined recovery/live write begins +- **THEN** Siglyph SHALL record runtime failure, perform normal terminal restoration, and SHALL NOT report successful recovery or claim rollback of bytes already accepted + +### Requirement: Recovery remains outside retained semantic state +After a successful recovery redraw, Siglyph SHALL retain only the prepared live frame and its component-owned metadata as the differential baseline. Recovery lines SHALL remain detached one-shot durable output. + +#### Scenario: Successful recovery commits baseline +- **WHEN** recovery rows and a live frame are written successfully +- **THEN** `previousFrame`, retained controls, selected cursor, base layout, overlay layouts, focus, and input targets SHALL describe only the live frame + +#### Scenario: Ordinary differential redraw follows recovery +- **WHEN** retained live state changes after recovery +- **THEN** the differential renderer SHALL move and repaint relative to the live frame without repainting or clearing the recovered prefix + +#### Scenario: Hardware cursor follows recovery +- **WHEN** hardware cursor positioning is enabled and the retained frame has a selected structured cursor +- **THEN** final cursor placement and logical cursor accounting SHALL remain relative to the relocated live frame + +#### Scenario: Mouse routing follows recovery +- **WHEN** coordinate-aware mouse routing has a known frame origin and recovery precedes the live frame +- **THEN** Siglyph SHALL update the physical live-frame origin so retained bounds continue receiving correct terminal coordinates + +#### Scenario: Live frame exceeds viewport +- **WHEN** recovery budget is zero because the live frame exceeds terminal height +- **THEN** existing scrolled frame-origin, cursor, and retained-layout behavior SHALL remain valid + +#### Scenario: Empty live frame follows recovery +- **WHEN** recovery rows precede an empty retained frame +- **THEN** Siglyph SHALL leave the cursor on the reserved live-frame anchor below recovery so a later append does not clear or overwrite the final recovered row + +### Requirement: Recovery diagnostics are bounded and redacted +Siglyph SHALL expose additive structured diagnostics for completed, stale-discarded, and failed recovery attempts, including only bounded outcome/failure category, strict row budget, recovered row count, and resize generation. + +#### Scenario: Recovery commits successfully +- **WHEN** an eligible recovery/live redraw commits +- **THEN** diagnostics SHALL report completed outcome, `maxRows`, exact committed recovery row count including zero, and the committed resize generation + +#### Scenario: Geometry discards a candidate +- **WHEN** latest geometry invalidates a rendered candidate before output +- **THEN** diagnostics SHALL report a stale/discarded structural outcome without reporting provider content + +#### Scenario: Provider throws +- **WHEN** provider rendering throws +- **THEN** diagnostics SHALL classify provider failure without retaining the exception message or provider object + +#### Scenario: Row budget is violated +- **WHEN** provider output exceeds `maxRows` +- **THEN** diagnostics SHALL classify row-budget validation failure with bounded counts and no line content + +#### Scenario: Diagnostic content is redacted +- **WHEN** any recovery event is observed +- **THEN** it SHALL contain no transcript entry, rendered line, SGR/OSC source, exception message, raw byte, component, control, image payload, filename, or application object reference + +### Requirement: Normal resize recovery is portable and documented +Siglyph SHALL implement eligibility, provider invocation, validation, planning, state commit, and diagnostics in canonical shared core for JVM and Scala Native without a new runtime dependency. + +#### Scenario: JVM and Native execute the same recovery contract +- **WHEN** equivalent JVM and Scala Native TUI sessions receive the same semantic provider output and resize sequence +- **THEN** they SHALL use the same budget, ordering, baseline, stale-candidate, failure, and diagnostic semantics + +#### Scenario: PTY conformance validates terminal boundary +- **WHEN** automated JVM PTY tests exercise recovery and a later append +- **THEN** they SHALL verify clear/recovery/live/append byte ordering, synchronized output, `CSI 3 J` absence, and terminal restoration without claiming emulator scrollback persistence + +#### Scenario: Emulator persistence is claimed +- **WHEN** documentation or release validation claims preserved recovery behavior in Kitty, iTerm2, or another emulator +- **THEN** that claim SHALL come from documented manual smoke coverage naming the terminal and repeated width/height resize sequence + +#### Scenario: Public API is documented +- **WHEN** recovery public types and options are added +- **THEN** Scaladoc and project documentation SHALL explain invocation, row ownership, retryability, provider speed expectations, terminal-dependent survivor limits, JVM/Native scope, text-only non-goals, and the absence of transcript retention diff --git a/openspec/changes/add-normal-resize-recovery/specs/terminal-runtime/spec.md b/openspec/changes/add-normal-resize-recovery/specs/terminal-runtime/spec.md new file mode 100644 index 0000000..def71ea --- /dev/null +++ b/openspec/changes/add-normal-resize-recovery/specs/terminal-runtime/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: Height-aware resize redraws +The TUI runtime SHALL track both terminal width and terminal height changes across renders and SHALL repaint after dimension changes according to the active screen mode and configured normal-screen resize clear policy. Existing callers that do not configure the policy SHALL retain full-clear behavior. A configured normal resize recovery provider SHALL add a bounded recovery prefix only to committed normal-screen preserve-scrollback geometry-change redraws. + +#### Scenario: Normal-screen width resize redraws with full clear +- **WHEN** terminal width changes after a previous render in normal-screen mode with default options +- **THEN** the TUI emits synchronized output with autowrap disabled, clears the viewport and scrollback with `CSI 2 J`, `CSI H`, and `CSI 3 J`, and writes the recomputed frame without entering alternate screen + +#### Scenario: Normal-screen height resize redraws with full clear +- **WHEN** terminal height changes after a previous render in normal-screen mode with default options +- **THEN** the TUI emits synchronized output with autowrap disabled, clears the viewport and scrollback with `CSI 2 J`, `CSI H`, and `CSI 3 J`, and writes the recomputed frame without entering alternate screen + +#### Scenario: Preserve-scrollback resize omits scrollback clearing +- **WHEN** terminal dimensions change in normal-screen mode with the preserve-scrollback policy configured and no recovery provider +- **THEN** the TUI clears and homes the active viewport, omits `CSI 3 J`, and writes the recomputed frame without entering alternate screen + +#### Scenario: Preserve-scrollback resize includes configured recovery +- **WHEN** terminal dimensions change after a committed frame in normal-screen mode with preserve-scrollback policy and a recovery provider configured +- **THEN** the TUI clears and homes the active viewport, omits `CSI 3 J`, writes at most the provider's strict recovery row budget, then writes the recomputed retained frame in the same synchronized output without entering alternate screen + +#### Scenario: Resize with overlay recomputes layout +- **WHEN** terminal dimensions change while an autocomplete overlay is visible in normal-screen mode +- **THEN** the overlay is re-resolved and composited into the resize redraw using the configured normal-screen clear policy, its final rows reduce any recovery budget, and output does not enter alternate-screen mode + +#### Scenario: Alternate-screen resize redraw clears active viewport +- **WHEN** terminal dimensions change after a previous render while alternate-screen mode is active +- **THEN** the TUI emits synchronized output with autowrap disabled, clears the active alternate-screen viewport, homes the cursor, and writes the recomputed frame without emitting another alternate-screen enter sequence, `CSI 3 J`, or normal-screen recovery output + +#### Scenario: Redundant resize notification preserves the viewport +- **WHEN** a backend resize callback reports dimensions equal to the committed width and height +- **THEN** the TUI SHALL NOT invoke recovery or destructively clear the active viewport + +#### Scenario: Generic forced redraw does not recover +- **WHEN** application or runtime work requests a forced or cleared redraw without a terminal width or height change +- **THEN** the TUI SHALL preserve existing redraw behavior and SHALL NOT invoke the normal resize recovery provider + +#### Scenario: Coalesced resizes recover latest geometry +- **WHEN** multiple terminal resize notifications invalidate an unpublished recovery/live candidate +- **THEN** the TUI SHALL emit no stale candidate, preserve the prior committed baseline, and perform recovery for the latest coalesced positive dimensions and generation diff --git a/openspec/changes/add-normal-resize-recovery/tasks.md b/openspec/changes/add-normal-resize-recovery/tasks.md new file mode 100644 index 0000000..3de6967 --- /dev/null +++ b/openspec/changes/add-normal-resize-recovery/tasks.md @@ -0,0 +1,62 @@ +## 1. Public recovery and diagnostics API + +- [x] 1.1 Add shared `NormalResizeRecoveryProvider` and `NormalResizeRecoveryContext` public types with explicit Scaladoc for synchronous invocation, retryability, previous/current strict row ownership, text-only output, platform scope, and non-goals. +- [x] 1.2 Add trailing `TUIOptions.normalResizeRecovery` with an absent default and source-compatibility coverage for existing option construction. +- [x] 1.3 Add fail-fast startup validation for provider combinations other than normal-screen `PreserveScrollback`, with tests proving failure precedes backend startup and terminal output. +- [x] 1.4 Add bounded resize-recovery outcome/failure diagnostic enums and an additive `TUIDiagnosticEvent` case without changing existing event arities. + +## 2. Resize eligibility and owner scheduling + +- [x] 2.1 Add focused tests showing only a committed width/height delta can invoke recovery, while same-size notifications, startup, append, ordinary input/action/structure/overlay rendering, image cell-size updates, and generic forced redraws cannot. +- [x] 2.2 Track coalesced geometry-change recovery eligibility and generation separately from existing force/clear flags without consuming ingress capacity or adding a scheduler category. +- [x] 2.3 Snapshot positive width, height, and resize generation for one recovery Render attempt and preserve existing six-category owner fairness. +- [x] 2.4 Test and preserve owner serialization, lock separation, non-recursive follow-up work, and absence of concurrent callbacks/component renders while provider code executes. +- [x] 2.5 Add deterministic stale-candidate tests proving resize during live/provider rendering emits no stale bytes, mutates no baseline, yields to ordinary selection, and retries only latest coalesced geometry. + +## 3. Budgeted text-only provider rendering + +- [x] 3.1 Add tests that render/compose/validate the live frame before provider invocation and calculate `maxRows` from both previous and current terminal height minus each live-frame physical footprint, including viewport growth, the empty-frame anchor, overlay extension, and typed-control reserved rows. +- [x] 3.2 Implement positive-context creation and skip provider invocation when the live frame fills or exceeds the viewport. +- [x] 3.3 Invoke the provider on the owner outside lifecycle/write locks and retain no provider result beyond the current unpublished Render candidate. +- [x] 3.4 Apply existing ordinary-line ANSI allowlisting, Unicode-aware width sanitization, aggregate sanitization accounting, redacted content-sample handling, and line resets to recovery lines. +- [x] 3.5 Reject output above `maxRows` before terminal output without truncation or partial publication; cover empty, exact-boundary, oversized, hostile-control-looking, styled, Unicode, and over-wide lines. + +## 4. Combined recovery/live render commit + +- [x] 4.1 Add raw-output and virtual-viewport tests for exact synchronized clear/cleanup/recovery/live/cursor ordering, `CSI 3 J` absence, autowrap restoration, and no extra row transition for empty recovery. +- [x] 4.2 Extend the preserve-scrollback full-render planner to assemble recovery and the retained live frame in one TUI-owned terminal write while keeping typed controls attached only to the live frame. +- [x] 4.3 Commit only the prepared live frame to `previousFrame`, previous dimensions, retained control state, base layout, and overlay layouts after successful output. +- [x] 4.4 Update live-frame-relative `cursorRow` and physical `latestFrameStartRow` for recovery prefixes, terminal scrolling, zero-row frames, and over-height live frames. +- [x] 4.5 Add hardware-cursor and coordinate-aware mouse tests proving structured cursor placement and retained layout routing use the relocated live-frame origin. +- [x] 4.6 Preserve retained Kitty replacement/retransmission and iTerm2 live-frame behavior during recovery without assigning image identity or cleanup ownership to recovery text. + +## 5. Append, failure, lifecycle, and diagnostics integration + +- [x] 5.1 Add shared chronology coverage for append `A`, resize recovery of `A`, append `B`, and retained live output in exact `A -> B -> live frame` order. +- [x] 5.2 Verify later partial/full retained redraws and multiple FIFO appends clear only the live region and never compare, repaint, or erase the committed recovery prefix. +- [x] 5.3 Test provider exceptions and row-budget violations as pre-output fail-fast failures with normal single-owner cleanup and no leaked recovery content. +- [x] 5.4 Test backend failure during the combined write as write-category failure with idempotent terminal restoration and no rollback/success claim. +- [x] 5.5 Cover stop and runtime-failure races before recovery publication, proving queued ordinary recovery work is discarded and no recovery callback extends Stopping or Cleaning. +- [x] 5.6 Emit completed, stale-discarded, provider, row-budget, and write recovery diagnostics with exact bounded counts/generation and verify text, exception messages, escape source, objects, controls, and payloads remain redacted. + +## 6. Portable and terminal conformance coverage + +- [x] 6.1 Keep all semantic `VirtualTerminal` recovery contracts in shared core tests and run the same suites on JVM and Scala Native. +- [x] 6.2 Extend JVM PTY conformance to verify viewport-clear/recovery/live/append byte ordering, synchronized output, forbidden `CSI 3 J` absence, and terminal lifecycle restoration without emulator-persistence claims. +- [x] 6.3 Add repeated/coalesced width and height resize coverage, including large height growth bounded by the previous viewport, with bounded semantic transcript selection and verify no TUI-owned complete transcript or provider output cache is introduced. +- [x] 6.4 Confirm the implementation changes no terminal backend API and adds no third-party runtime dependency. + +## 7. Documentation and release notes + +- [x] 7.1 Document configuration and a bounded semantic-tail provider example in README, including current-width reflow, oldest-to-newest output, retryability, speed expectations, and append chronology. +- [x] 7.2 Update `docs/runtime-diagnostics.md` for recovery events, redaction, strict row-budget failure, and terminal-dependent survivor/deduplication limits. +- [x] 7.3 Extend `docs/interactive-smoke.md` with repeated normal-screen recovery checks in Kitty, iTerm2, and a conventional terminal, clearly separating PTY ordering from emulator persistence. +- [x] 7.4 Update porting notes where recovery intentionally extends or differs from current `pi-tui`, and add a user-visible changelog entry referencing issue #56. +- [x] 7.5 Verify all new public types and options have Scaladoc covering JVM/Native parity, lifecycle eligibility, failure behavior, and text-only/typed-control non-goals. + +## 8. Validation + +- [x] 8.1 Run focused shared recovery, append, TUI concurrency, diagnostics, virtual-terminal, and JVM PTY suites. +- [x] 8.2 Run `mill core.test`, all relevant Scala Native tests with supported Clang 16+, and `mill __.compile`. +- [x] 8.3 Run `mill scalafmtCheck`, `mill scalafixCheck`, `scripts/test-terminal-pty.sh`, and any documented manual emulator smoke checks available in the environment. +- [x] 8.4 Run `openspec validate --all --strict`, `git diff --check`, and a final dependency/public-API/redaction review before reporting implementation complete. diff --git a/terminalJvm/test/src/scalatui/terminal/jvm/SttyTerminalPtySuite.scala b/terminalJvm/test/src/scalatui/terminal/jvm/SttyTerminalPtySuite.scala index ff8cdd9..20bf5ce 100644 --- a/terminalJvm/test/src/scalatui/terminal/jvm/SttyTerminalPtySuite.scala +++ b/terminalJvm/test/src/scalatui/terminal/jvm/SttyTerminalPtySuite.scala @@ -5,7 +5,11 @@ import scalatui.core.{ Component, ComponentRender, NormalResizeClearPolicy, + NormalResizeRecoveryProvider, TUI, + TUIDiagnosticEvent, + TUIDiagnosticObserver, + TUIDiagnosticResizeRecoveryOutcome, TUIOptions, TerminalControlPlacement } @@ -110,6 +114,71 @@ class SttyTerminalPtySuite extends munit.FunSuite: restoreStty(originalState, originalRows, originalColumns) } + test("PTY resize recovery precedes later append and restores terminal"): + withPtyTest { + val originalState = runStty("-g") + val originalStableState = stableSttyState() + val (originalRows, originalColumns) = querySize() + val targetRows = if originalRows > 2 then originalRows - 1 else 7 + val targetColumns = if originalColumns > 2 then originalColumns - 1 else 29 + val output = ByteArrayOutputStream() + val recovered = CountDownLatch(1) + val terminal = SttyTerminal( + input = InputStream.nullInputStream(), + output = output + ) + val tui = TUI( + terminal, + TUIOptions( + normalResizeClearPolicy = NormalResizeClearPolicy.PreserveScrollback, + diagnosticObserver = Some(TUIDiagnosticObserver { + case TUIDiagnosticEvent.ResizeRecovery( + TUIDiagnosticResizeRecoveryOutcome.Completed, + _, + _, + _, + _ + ) => recovered.countDown() + case _ => () + }), + normalResizeRecovery = Some(NormalResizeRecoveryProvider(_ => Vector("recovered-row"))) + ) + ) + var stopped = false + + try + tui.addChild(new Component: + override def render(width: Int): ComponentRender = ComponentRender.text("live-frame")) + tui.start() + output.reset() + runStty(s"rows $targetRows cols $targetColumns") + assert(recovered.await(5, TimeUnit.SECONDS), "resize recovery did not run within 5s") + tui.appendToScrollback(new Component: + override def render(width: Int): ComponentRender = ComponentRender.text("append-row")) + tui.stop() + stopped = true + + val written = output.toString(java.nio.charset.StandardCharsets.UTF_8) + val clear = written.indexOf(TUI.NormalScreenViewportClear) + val recovery = written.indexOf("recovered-row") + val firstLive = written.indexOf("live-frame", recovery) + val append = written.indexOf("append-row", firstLive) + val relocatedLive = written.indexOf("live-frame", append) + assert(clear >= 0, written) + assert(recovery > clear, written) + assert(firstLive > recovery, written) + assert(append > firstLive, written) + assert(relocatedLive > append, written) + assert(!written.contains("\u001b[3J"), written) + assert(written.contains(TUI.SyncStart), written) + assert(written.contains(TUI.SyncEnd + TUI.AutoWrapOn), written) + assert(written.contains("\u001b[?25h"), written) + assertEquals(stableSttyState(), originalStableState) + finally + if !stopped then scala.util.Try(tui.stop()) + restoreStty(originalState, originalRows, originalColumns) + } + test("PTY cleanup retries an injected restoration failure and restores stty"): withPtyTest { val originalState = runStty("-g")