Unified · Split
| @@ -19,6 +19,14 @@ public class T3TerminalModule: Module { · 18 unmodified lines | ||
| 19 | 19 | view.initialBuffer = initialBuffer |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | + Prop("bufferEpoch") { (view: T3TerminalView, bufferEpoch: Double) in | |
| 23 | + view.bufferEpoch = Int(bufferEpoch) | |
| 24 | + } | |
| 25 | + | |
| 26 | + Prop("bufferStart") { (view: T3TerminalView, bufferStart: Double) in | |
| 27 | + view.bufferStart = Int(bufferStart) | |
| 28 | + } | |
| 29 | + | |
| 22 | 30 | Prop("fontSize") { (view: T3TerminalView, fontSize: Double) in |
| 23 | 31 | view.fontSize = CGFloat(fontSize) |
| 24 | 32 | } |
| @@ -196,6 +196,13 @@ private extension UIColor { · 195 unmodified lines | ||
| 196 | 196 | public final class T3TerminalView: ExpoView, UITextFieldDelegate { |
| 197 | 197 | private static let minimumVerticalScrollStepPoints: CGFloat = 18 |
| 198 | 198 | private static let verticalScrollStepMultiplier: CGFloat = 1.15 |
| 199 | + private static let bufferFeedChunkSize = 8 * 1024 | |
| 200 | + | |
| 201 | + private struct ActiveBufferFeed { | |
| 202 | + let surfaceGeneration: Int | |
| 203 | + let bufferRevision: Int | |
| 204 | + let bufferEpoch: Int | |
| 205 | + } | |
| 199 | 206 | |
| 200 | 207 | private let terminalViewport = UIView() |
| 201 | 208 | private let inputField = TerminalInputField() |
| @@ -204,7 +211,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 2 unmodified lines | ||
| 204 | 211 | private var lastViewportSize: CGSize = .zero |
| 205 | 212 | private var lastContentScale: CGFloat = 0 |
| 206 | 213 | private var lastReportedGrid: (cols: Int, rows: Int)? |
| 207 | − private var lastAppliedBuffer = "" | |
| 214 | + private var appliedBufferEpoch: Int? | |
| 215 | + private var appliedBufferEnd = 0 | |
| 216 | + private var bufferRevision = 0 | |
| 217 | + private var surfaceGeneration = 0 | |
| 218 | + private var activeBufferFeed: ActiveBufferFeed? | |
| 208 | 219 | private var pendingVerticalScrollPoints: CGFloat = 0 |
| 209 | 220 | private var app: ghostty_app_t? |
| 210 | 221 | private var surface: ghostty_surface_t? |
| @@ -221,13 +232,28 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 10 unmodified lines | ||
| 221 | 232 | accessibilityIdentifier = "t3-terminal-\(terminalKey)" |
| 222 | 233 | if oldValue != terminalKey { |
| 223 | 234 | resetSurface() |
| 235 | + enqueueRemoteBufferApply() | |
| 224 | 236 | } |
| 225 | 237 | } |
| 226 | 238 | } |
| 227 | 239 | |
| 228 | 240 | var initialBuffer: String = "" { |
| 229 | 241 | didSet { |
| 230 | − applyRemoteBuffer(initialBuffer) | |
| 242 | + remoteBufferDidChange() | |
| 243 | + } | |
| 244 | + } | |
| 245 | + | |
| 246 | + var bufferEpoch = 0 { | |
| 247 | + didSet { | |
| 248 | + guard oldValue != bufferEpoch else { return } | |
| 249 | + remoteBufferDidChange() | |
| 250 | + } | |
| 251 | + } | |
| 252 | + | |
| 253 | + var bufferStart = 0 { | |
| 254 | + didSet { | |
| 255 | + guard oldValue != bufferStart else { return } | |
| 256 | + remoteBufferDidChange() | |
| 231 | 257 | } |
| 232 | 258 | } |
| 233 | 259 | |
| @@ -356,6 +382,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 122 unmodified lines | ||
| 356 | 382 | let viewportSize = terminalViewport.bounds.size |
| 357 | 383 | if surface == nil { |
| 358 | 384 | createSurfaceIfPossible() |
| 385 | + if surface != nil { | |
| 386 | + enqueueRemoteBufferApply() | |
| 387 | + } | |
| 359 | 388 | } |
| 360 | 389 | |
| 361 | 390 | guard viewportSize != lastViewportSize || contentScaleFactor != lastContentScale else { |
| @@ -499,12 +528,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 137 unmodified lines | ||
| 499 | 528 | ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme) |
| 500 | 529 | setupWriteCallback() |
| 501 | 530 | resizeSurface() |
| 502 | − feedBuffer(initialBuffer) | |
| 503 | 531 | } |
| 504 | 532 | |
| 505 | 533 | private func resetSurface() { |
| 534 | + surfaceGeneration += 1 | |
| 506 | 535 | destroySurface() |
| 507 | − lastAppliedBuffer = "" | |
| 536 | + activeBufferFeed = nil | |
| 537 | + appliedBufferEpoch = nil | |
| 538 | + appliedBufferEnd = 0 | |
| 508 | 539 | lastViewportSize = .zero |
| 509 | 540 | lastContentScale = 0 |
| 510 | 541 | lastReportedGrid = nil |
| @@ -515,6 +546,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 4 unmodified lines | ||
| 515 | 546 | private func refreshSurface() { |
| 516 | 547 | resetSurface() |
| 517 | 548 | createSurfaceIfPossible() |
| 549 | + enqueueRemoteBufferApply() | |
| 518 | 550 | } |
| 519 | 551 | |
| 520 | 552 | private func destroySurface() { |
| @@ -529,40 +561,117 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 8 unmodified lines | ||
| 529 | 561 | app = nil |
| 530 | 562 | } |
| 531 | 563 | |
| 532 | − private func applyRemoteBuffer(_ buffer: String) { | |
| 533 | − guard surface != nil else { | |
| 534 | − createSurfaceIfPossible() | |
| 535 | − return | |
| 564 | + private func remoteBufferDidChange() { | |
| 565 | + bufferRevision += 1 | |
| 566 | + enqueueRemoteBufferApply() | |
| 567 | + } | |
| 568 | + | |
| 569 | + private func enqueueRemoteBufferApply() { | |
| 570 | + let revision = bufferRevision | |
| 571 | + DispatchQueue.main.async { [weak self] in | |
| 572 | + guard let self, self.bufferRevision == revision else { return } | |
| 573 | + self.applyRemoteBuffer(revision: revision) | |
| 536 | 574 | } |
| 575 | + } | |
| 537 | 576 | |
| 538 | − if buffer.hasPrefix(lastAppliedBuffer) { | |
| 539 | − let suffix = String(buffer.dropFirst(lastAppliedBuffer.count)) | |
| 540 | − feedData(Data(suffix.utf8)) | |
| 541 | − lastAppliedBuffer = buffer | |
| 542 | − return | |
| 577 | + private func applyRemoteBuffer(revision: Int) { | |
| 578 | + if let activeBufferFeed { | |
| 579 | + if activeBufferFeed.bufferEpoch == bufferEpoch { | |
| 580 | + return | |
| 581 | + } | |
| 582 | + resetSurface() | |
| 543 | 583 | } |
| 544 | 584 | |
| 545 | − resetSurface() | |
| 546 | − createSurfaceIfPossible() | |
| 547 | − } | |
| 585 | + if surface == nil { | |
| 586 | + createSurfaceIfPossible() | |
| 587 | + } | |
| 588 | + guard surface != nil else { return } | |
| 589 | + | |
| 590 | + let normalizedBufferStart = max(bufferStart, 0) | |
| 591 | + let buffer = initialBuffer as NSString | |
| 592 | + let bufferEnd = normalizedBufferStart + buffer.length | |
| 593 | + let canAppend = | |
| 594 | + appliedBufferEpoch == bufferEpoch && | |
| 595 | + appliedBufferEnd >= normalizedBufferStart && | |
| 596 | + appliedBufferEnd <= bufferEnd | |
| 597 | + | |
| 598 | + let data: Data | |
| 599 | + if canAppend { | |
| 600 | + data = Data(buffer.substring(from: appliedBufferEnd - normalizedBufferStart).utf8) | |
| 601 | + } else { | |
| 602 | + if appliedBufferEpoch != nil { | |
| 603 | + resetSurface() | |
| 604 | + createSurfaceIfPossible() | |
| 605 | + guard surface != nil else { return } | |
| 606 | + } | |
| 607 | + data = Data(initialBuffer.utf8) | |
| 608 | + } | |
| 548 | 609 | |
| 549 | − private func feedBuffer(_ buffer: String) { | |
| 550 | − guard !buffer.isEmpty else { return } | |
| 551 | − feedData(Data(buffer.utf8)) | |
| 552 | − lastAppliedBuffer = buffer | |
| 610 | + let targetSurfaceGeneration = surfaceGeneration | |
| 611 | + activeBufferFeed = ActiveBufferFeed( | |
| 612 | + surfaceGeneration: targetSurfaceGeneration, | |
| 613 | + bufferRevision: revision, | |
| 614 | + bufferEpoch: bufferEpoch | |
| 615 | + ) | |
| 616 | + feedData( | |
| 617 | + data, | |
| 618 | + offset: 0, | |
| 619 | + surfaceGeneration: targetSurfaceGeneration | |
| 620 | + ) { [weak self] in | |
| 621 | + guard let self else { return } | |
| 622 | + guard | |
| 623 | + let activeBufferFeed = self.activeBufferFeed, | |
| 624 | + activeBufferFeed.surfaceGeneration == targetSurfaceGeneration, | |
| 625 | + activeBufferFeed.bufferRevision == revision | |
| 626 | + else { | |
| 627 | + return | |
| 628 | + } | |
| 629 | + | |
| 630 | + self.activeBufferFeed = nil | |
| 631 | + self.appliedBufferEpoch = activeBufferFeed.bufferEpoch | |
| 632 | + self.appliedBufferEnd = bufferEnd | |
| 633 | + if self.bufferRevision != revision { | |
| 634 | + self.enqueueRemoteBufferApply() | |
| 635 | + } | |
| 636 | + } | |
| 553 | 637 | } |
| 554 | 638 | |
| 555 | − private func feedData(_ data: Data) { | |
| 556 | − guard let surface, !data.isEmpty else { return } | |
| 639 | + private func feedData( | |
| 640 | + _ data: Data, | |
| 641 | + offset: Int, | |
| 642 | + surfaceGeneration targetSurfaceGeneration: Int, | |
| 643 | + completion: @escaping () -> Void | |
| 644 | + ) { | |
| 645 | + guard surfaceGeneration == targetSurfaceGeneration, let surface else { return } | |
| 646 | + guard offset < data.count else { | |
| 647 | + redrawSurface() | |
| 648 | + completion() | |
| 649 | + return | |
| 650 | + } | |
| 557 | 651 | |
| 652 | + let chunkCount = min(Self.bufferFeedChunkSize, data.count - offset) | |
| 558 | 653 | data.withUnsafeBytes { buffer in |
| 559 | 654 | guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { |
| 560 | 655 | return |
| 561 | 656 | } |
| 562 | − ghostty_surface_feed_data(surface, pointer, buffer.count) | |
| 657 | + ghostty_surface_feed_data(surface, pointer.advanced(by: offset), chunkCount) | |
| 563 | 658 | } |
| 564 | 659 | |
| 565 | − redrawSurface() | |
| 660 | + let nextOffset = offset + chunkCount | |
| 661 | + if nextOffset >= data.count { | |
| 662 | + redrawSurface() | |
| 663 | + completion() | |
| 664 | + return | |
| 665 | + } | |
| 666 | + | |
| 667 | + DispatchQueue.main.async { [weak self] in | |
| 668 | + self?.feedData( | |
| 669 | + data, | |
| 670 | + offset: nextOffset, | |
| 671 | + surfaceGeneration: targetSurfaceGeneration, | |
| 672 | + completion: completion | |
| 673 | + ) | |
| 674 | + } | |
| 566 | 675 | } |
| 567 | 676 | |
| 568 | 677 | private func setupWriteCallback() { |
| @@ -35,6 +35,8 @@ interface TerminalResizeEvent { · 34 unmodified lines | ||
| 35 | 35 | interface TerminalSurfaceProps extends ViewProps { |
| 36 | 36 | readonly terminalKey: string; |
| 37 | 37 | readonly buffer: string; |
| 38 | + readonly bufferEpoch: number; | |
| 39 | + readonly bufferStart: number; | |
| 38 | 40 | readonly fontSize?: number; |
| 39 | 41 | readonly isRunning: boolean; |
| 40 | 42 | readonly autoFocus?: boolean; |
| @@ -186,9 +188,18 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 145 unmodified lines | ||
| 186 | 188 | // null = installed binary predates native hardware-key handling (rebuild needed). |
| 187 | 189 | hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(), |
| 188 | 190 | bufferLen: props.buffer.length, |
| 191 | + bufferEpoch: props.bufferEpoch, | |
| 192 | + bufferStart: props.bufferStart, | |
| 189 | 193 | isRunning: props.isRunning, |
| 190 | 194 | }); |
| 191 | − }, [hasNativeSurface, props.buffer.length, props.isRunning, props.terminalKey]); | |
| 195 | + }, [ | |
| 196 | + hasNativeSurface, | |
| 197 | + props.buffer.length, | |
| 198 | + props.bufferEpoch, | |
| 199 | + props.bufferStart, | |
| 200 | + props.isRunning, | |
| 201 | + props.terminalKey, | |
| 202 | + ]); | |
| 192 | 203 | const handleNativeInput = useCallback( |
| 193 | 204 | (event: NativeSyntheticEvent<TerminalInputEvent>) => { |
| 194 | 205 | if (!props.isRunning) { |
| @@ -223,6 +234,8 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 28 unmodified lines | ||
| 223 | 234 | mutedForegroundColor={theme.mutedForeground} |
| 224 | 235 | terminalKey={props.terminalKey} |
| 225 | 236 | initialBuffer={props.buffer} |
| 237 | + bufferEpoch={props.bufferEpoch} | |
| 238 | + bufferStart={props.bufferStart} | |
| 226 | 239 | fontSize={fontSize} |
| 227 | 240 | style={{ flex: 1 }} |
| 228 | 241 | themeConfig={buildGhosttyThemeConfig(theme)} |
| @@ -241,6 +241,8 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( · 240 unmodified lines | ||
| 241 | 241 | <TerminalSurface |
| 242 | 242 | terminalKey={terminalKey} |
| 243 | 243 | buffer={terminal.buffer} |
| 244 | + bufferEpoch={terminal.bufferEpoch} | |
| 245 | + bufferStart={terminal.bufferStart} | |
| 244 | 246 | isRunning={isRunning} |
| 245 | 247 | onInput={handleInput} |
| 246 | 248 | onResize={handleResize} |
| @@ -351,6 +351,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) · 350 unmodified lines | ||
| 351 | 351 | replayKey: bufferReplayKey, |
| 352 | 352 | readyReplayKey: readyBufferReplayKey, |
| 353 | 353 | }); |
| 354 | + const isTerminalSurfaceBufferHidden = terminalSurfaceBuffer !== terminal.buffer; | |
| 355 | + const terminalSurfaceBufferEpoch = isTerminalSurfaceBufferHidden | |
| 356 | + ? terminal.bufferEpoch + 1 | |
| 357 | + : terminal.bufferEpoch; | |
| 358 | + const terminalSurfaceBufferStart = isTerminalSurfaceBufferHidden ? 0 : terminal.bufferStart; | |
| 354 | 359 | const isRunning = terminal.status === "running" || terminal.status === "starting"; |
| 355 | 360 | |
| 356 | 361 | // When the process ends while this screen is attached (e.g. typing `exit`), |
| @@ -1221,6 +1226,8 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) · 864 unmodified lines | ||
| 1221 | 1226 | <TerminalSurface |
| 1222 | 1227 | autoFocus={!SHOWCASE_ENABLED} |
| 1223 | 1228 | buffer={terminalSurfaceBuffer} |
| 1229 | + bufferEpoch={terminalSurfaceBufferEpoch} | |
| 1230 | + bufferStart={terminalSurfaceBufferStart} | |
| 1224 | 1231 | fontSize={fontSize} |
| 1225 | 1232 | isRunning={isRunning} |
| 1226 | 1233 | keyboardFocusRequest={keyboardFocusRequest} |
| @@ -31,6 +31,8 @@ export interface NativeTerminalSurfaceProps extends ViewProps { · 30 unmodified lines | ||
| 31 | 31 | readonly mutedForegroundColor?: string; |
| 32 | 32 | readonly terminalKey: string; |
| 33 | 33 | readonly initialBuffer: string; |
| 34 | + readonly bufferEpoch: number; | |
| 35 | + readonly bufferStart: number; | |
| 34 | 36 | readonly fontSize: number; |
| 35 | 37 | readonly onInput?: (event: NativeSyntheticEvent<TerminalInputEvent>) => void; |
| 36 | 38 | readonly onResize?: (event: NativeSyntheticEvent<TerminalResizeEvent>) => void; |
| @@ -57,6 +57,9 @@ function makeKnownSession(input: { · 56 unmodified lines | ||
| 57 | 57 | } |
| 58 | 58 | : null, |
| 59 | 59 | buffer: "", |
| 60 | + bufferEpoch: 0, | |
| 61 | + bufferStart: 0, | |
| 62 | + bufferEnd: 0, | |
| 60 | 63 | status: input.status, |
| 61 | 64 | error: null, |
| 62 | 65 | hasRunningSubprocess: false, |
| @@ -4,6 +4,7 @@ import { · 3 unmodified lines | ||
| 4 | 4 | isAtomCommandInterrupted, |
| 5 | 5 | squashAtomCommandFailure, |
| 6 | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | +import { getTerminalBufferUpdate } from "@t3tools/client-runtime/state/terminal"; | |
| 7 | 8 | import { |
| 8 | 9 | Plus, |
| 9 | 10 | SquareSplitHorizontal, |
| @@ -365,11 +366,17 @@ export function TerminalViewport({ · 355 unmodified lines | ||
| 365 | 366 | }), |
| 366 | 367 | ); |
| 367 | 368 | const terminalBuffer = terminalSession.buffer; |
| 369 | + const terminalBufferEpoch = terminalSession.bufferEpoch; | |
| 370 | + const terminalBufferStart = terminalSession.bufferStart; | |
| 371 | + const terminalBufferEnd = terminalSession.bufferEnd; | |
| 368 | 372 | const terminalError = terminalSession.error; |
| 369 | 373 | const terminalStatus = terminalSession.status; |
| 370 | 374 | const terminalVersion = terminalSession.version; |
| 371 | 375 | const previousSessionRef = useRef({ |
| 372 | 376 | buffer: terminalBuffer, |
| 377 | + bufferEpoch: terminalBufferEpoch, | |
| 378 | + bufferStart: terminalBufferStart, | |
| 379 | + bufferEnd: terminalBufferEnd, | |
| 373 | 380 | error: terminalError, |
| 374 | 381 | status: terminalStatus, |
| 375 | 382 | version: terminalVersion, |
| @@ -403,6 +410,9 @@ export function TerminalViewport({ · 27 unmodified lines | ||
| 403 | 410 | fitAddonRef.current = fitAddon; |
| 404 | 411 | previousSessionRef.current = { |
| 405 | 412 | buffer: "", |
| 413 | + bufferEpoch: 0, | |
| 414 | + bufferStart: 0, | |
| 415 | + bufferEnd: 0, | |
| 406 | 416 | status: "closed", |
| 407 | 417 | error: null, |
| 408 | 418 | version: 0, |
| @@ -740,6 +750,9 @@ export function TerminalViewport({ · 331 unmodified lines | ||
| 740 | 750 | const terminal = terminalRef.current; |
| 741 | 751 | const current = { |
| 742 | 752 | buffer: terminalBuffer, |
| 753 | + bufferEpoch: terminalBufferEpoch, | |
| 754 | + bufferStart: terminalBufferStart, | |
| 755 | + bufferEnd: terminalBufferEnd, | |
| 743 | 756 | error: terminalError, |
| 744 | 757 | status: terminalStatus, |
| 745 | 758 | version: terminalVersion, |
| @@ -750,17 +763,11 @@ export function TerminalViewport({ · 4 unmodified lines | ||
| 750 | 763 | } |
| 751 | 764 | |
| 752 | 765 | const previous = previousSessionRef.current; |
| 753 | − if (current.version === previous.version) { | |
| 754 | − return; | |
| 755 | − } | |
| 756 | − | |
| 757 | − if ( | |
| 758 | − current.buffer.length >= previous.buffer.length && | |
| 759 | − current.buffer.startsWith(previous.buffer) | |
| 760 | − ) { | |
| 761 | − terminal.write(current.buffer.slice(previous.buffer.length)); | |
| 762 | − } else { | |
| 763 | − writeTerminalBuffer(terminal, current.buffer); | |
| 766 | + const bufferUpdate = getTerminalBufferUpdate(previous, current); | |
| 767 | + if (bufferUpdate.type === "append") { | |
| 768 | + terminal.write(bufferUpdate.data); | |
| 769 | + } else if (bufferUpdate.type === "replace") { | |
| 770 | + writeTerminalBuffer(terminal, bufferUpdate.buffer); | |
| 764 | 771 | } |
| 765 | 772 | terminal.clearSelection(); |
| 766 | 773 | |
| @@ -793,7 +800,16 @@ export function TerminalViewport({ · 26 unmodified lines | ||
| 793 | 800 | }); |
| 794 | 801 | } |
| 795 | 802 | previousSessionRef.current = current; |
| 796 | − }, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); | |
| 803 | + }, [ | |
| 804 | + autoFocus, | |
| 805 | + terminalBuffer, | |
| 806 | + terminalBufferEnd, | |
| 807 | + terminalBufferEpoch, | |
| 808 | + terminalBufferStart, | |
| 809 | + terminalError, | |
| 810 | + terminalStatus, | |
| 811 | + terminalVersion, | |
| 812 | + ]); | |
| 797 | 813 | |
| 798 | 814 | useEffect(() => { |
| 799 | 815 | if (!autoFocus) return; |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | applyTerminalMetadataStreamEvent, |
| 8 | 8 | combineTerminalSessionState, |
| 9 | 9 | EMPTY_TERMINAL_BUFFER_STATE, |
| 10 | + getTerminalBufferUpdate, | |
| 10 | 11 | selectRunningSubprocessTerminalIds, |
| 11 | 12 | } from "./terminalSession.ts"; |
| 12 | 13 | |
| @@ -127,6 +128,9 @@ describe("terminal session reducers", () => { · 114 unmodified lines | ||
| 127 | 128 | |
| 128 | 129 | expect(output).toMatchObject({ |
| 129 | 130 | buffer: "lo world", |
| 131 | + bufferEpoch: 1, | |
| 132 | + bufferStart: 3, | |
| 133 | + bufferEnd: 11, | |
| 130 | 134 | status: "running", |
| 131 | 135 | error: null, |
| 132 | 136 | version: 2, |
| @@ -183,5 +187,77 @@ describe("terminal session reducers", () => { · 50 unmodified lines | ||
| 183 | 187 | ); |
| 184 | 188 | |
| 185 | 189 | expect(state.buffer).toBe("🙂"); |
| 190 | + expect(state).toMatchObject({ | |
| 191 | + bufferStart: 2, | |
| 192 | + bufferEnd: 4, | |
| 193 | + }); | |
| 194 | + }); | |
| 195 | + | |
| 196 | + it("keeps rollover output appendable with absolute buffer positions", () => { | |
| 197 | + const beforeRollover = applyTerminalAttachStreamEvent( | |
| 198 | + EMPTY_TERMINAL_BUFFER_STATE, | |
| 199 | + { | |
| 200 | + type: "snapshot", | |
| 201 | + snapshot: { ...BASE_SNAPSHOT, history: "abcdefgh" }, | |
| 202 | + }, | |
| 203 | + 8, | |
| 204 | + ); | |
| 205 | + const afterRollover = applyTerminalAttachStreamEvent( | |
| 206 | + beforeRollover, | |
| 207 | + { | |
| 208 | + type: "output", | |
| 209 | + threadId: TARGET.threadId, | |
| 210 | + terminalId: TARGET.terminalId, | |
| 211 | + data: "ij", | |
| 212 | + }, | |
| 213 | + 8, | |
| 214 | + ); | |
| 215 | + | |
| 216 | + expect(afterRollover).toMatchObject({ | |
| 217 | + buffer: "cdefghij", | |
| 218 | + bufferEpoch: beforeRollover.bufferEpoch, | |
| 219 | + bufferStart: 2, | |
| 220 | + bufferEnd: 10, | |
| 221 | + }); | |
| 222 | + expect(getTerminalBufferUpdate(beforeRollover, afterRollover)).toEqual({ | |
| 223 | + type: "append", | |
| 224 | + data: "ij", | |
| 225 | + }); | |
| 226 | + }); | |
| 227 | + | |
| 228 | + it("replaces the rendered buffer after a reset or when a renderer falls behind", () => { | |
| 229 | + const initial = applyTerminalAttachStreamEvent( | |
| 230 | + EMPTY_TERMINAL_BUFFER_STATE, | |
| 231 | + { | |
| 232 | + type: "snapshot", | |
| 233 | + snapshot: { ...BASE_SNAPSHOT, history: "abcdefgh" }, | |
| 234 | + }, | |
| 235 | + 8, | |
| 236 | + ); | |
| 237 | + const restarted = applyTerminalAttachStreamEvent(initial, { | |
| 238 | + type: "restarted", | |
| 239 | + threadId: TARGET.threadId, | |
| 240 | + terminalId: TARGET.terminalId, | |
| 241 | + snapshot: { ...BASE_SNAPSHOT, history: "fresh" }, | |
| 242 | + }); | |
| 243 | + const beyondRetainedWindow = applyTerminalAttachStreamEvent( | |
| 244 | + initial, | |
| 245 | + { | |
| 246 | + type: "output", | |
| 247 | + threadId: TARGET.threadId, | |
| 248 | + terminalId: TARGET.terminalId, | |
| 249 | + data: "ijklmnop", | |
| 250 | + }, | |
| 251 | + 8, | |
| 252 | + ); | |
| 253 | + | |
| 254 | + expect(getTerminalBufferUpdate(initial, restarted)).toEqual({ | |
| 255 | + type: "replace", | |
| 256 | + buffer: "fresh", | |
| 257 | + }); | |
| 258 | + expect(getTerminalBufferUpdate(EMPTY_TERMINAL_BUFFER_STATE, beyondRetainedWindow)).toEqual({ | |
| 259 | + type: "replace", | |
| 260 | + buffer: "ijklmnop", | |
| 261 | + }); | |
| 186 | 262 | }); |
| 187 | 263 | }); |
| @@ -10,6 +10,9 @@ import type { · 9 unmodified lines | ||
| 10 | 10 | export interface TerminalSessionState { |
| 11 | 11 | readonly summary: TerminalSummary | null; |
| 12 | 12 | readonly buffer: string; |
| 13 | + readonly bufferEpoch: number; | |
| 14 | + readonly bufferStart: number; | |
| 15 | + readonly bufferEnd: number; | |
| 13 | 16 | readonly status: TerminalSessionSnapshot["status"] | "closed"; |
| 14 | 17 | readonly error: string | null; |
| 15 | 18 | readonly hasRunningSubprocess: boolean; |
| @@ -19,12 +22,27 @@ export interface TerminalSessionState { · 3 unmodified lines | ||
| 19 | 22 | |
| 20 | 23 | export interface TerminalBufferState { |
| 21 | 24 | readonly buffer: string; |
| 25 | + readonly bufferEpoch: number; | |
| 26 | + readonly bufferStart: number; | |
| 27 | + readonly bufferEnd: number; | |
| 22 | 28 | readonly status: TerminalSessionSnapshot["status"] | "closed"; |
| 23 | 29 | readonly error: string | null; |
| 24 | 30 | readonly updatedAt: string | null; |
| 25 | 31 | readonly version: number; |
| 26 | 32 | } |
| 27 | 33 | |
| 34 | +export interface TerminalBufferWindow { | |
| 35 | + readonly buffer: string; | |
| 36 | + readonly bufferEpoch: number; | |
| 37 | + readonly bufferStart: number; | |
| 38 | + readonly bufferEnd: number; | |
| 39 | +} | |
| 40 | + | |
| 41 | +export type TerminalBufferUpdate = | |
| 42 | + | { readonly type: "none" } | |
| 43 | + | { readonly type: "append"; readonly data: string } | |
| 44 | + | { readonly type: "replace"; readonly buffer: string }; | |
| 45 | + | |
| 28 | 46 | export interface KnownTerminalSessionTarget { |
| 29 | 47 | readonly environmentId: EnvironmentId; |
| 30 | 48 | readonly threadId: ThreadId; |
| @@ -46,6 +64,9 @@ export function selectRunningSubprocessTerminalIds( · 15 unmodified lines | ||
| 46 | 64 | |
| 47 | 65 | export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ |
| 48 | 66 | buffer: "", |
| 67 | + bufferEpoch: 0, | |
| 68 | + bufferStart: 0, | |
| 69 | + bufferEnd: 0, | |
| 49 | 70 | status: "closed", |
| 50 | 71 | error: null, |
| 51 | 72 | updatedAt: null, |
| @@ -55,6 +76,9 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ · 3 unmodified lines | ||
| 55 | 76 | export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({ |
| 56 | 77 | summary: null, |
| 57 | 78 | buffer: "", |
| 79 | + bufferEpoch: 0, | |
| 80 | + bufferStart: 0, | |
| 81 | + bufferEnd: 0, | |
| 58 | 82 | status: "closed", |
| 59 | 83 | error: null, |
| 60 | 84 | hasRunningSubprocess: false, |
| @@ -91,9 +115,14 @@ function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { · 30 unmodified lines | ||
| 91 | 115 | export function terminalBufferStateFromSnapshot( |
| 92 | 116 | snapshot: TerminalSessionSnapshot, |
| 93 | 117 | maxBufferBytes: number, |
| 118 | + bufferEpoch = 1, | |
| 94 | 119 | ): TerminalBufferState { |
| 120 | + const buffer = trimBufferToBytes(snapshot.history, maxBufferBytes); | |
| 95 | 121 | return { |
| 96 | − buffer: trimBufferToBytes(snapshot.history, maxBufferBytes), | |
| 122 | + buffer, | |
| 123 | + bufferEpoch, | |
| 124 | + bufferStart: snapshot.history.length - buffer.length, | |
| 125 | + bufferEnd: snapshot.history.length, | |
| 97 | 126 | status: snapshot.status, |
| 98 | 127 | error: null, |
| 99 | 128 | updatedAt: snapshot.updatedAt, |
| @@ -114,6 +143,9 @@ export function combineTerminalSessionState( · 14 unmodified lines | ||
| 114 | 143 | return { |
| 115 | 144 | summary, |
| 116 | 145 | buffer: buffer.buffer, |
| 146 | + bufferEpoch: buffer.bufferEpoch, | |
| 147 | + bufferStart: buffer.bufferStart, | |
| 148 | + bufferEnd: buffer.bufferEnd, | |
| 117 | 149 | status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), |
| 118 | 150 | error: buffer.error, |
| 119 | 151 | hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, |
| @@ -130,19 +162,31 @@ export function applyTerminalAttachStreamEvent( · 10 unmodified lines | ||
| 130 | 162 | switch (event.type) { |
| 131 | 163 | case "snapshot": |
| 132 | 164 | case "restarted": |
| 133 | − return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes); | |
| 134 | − case "output": | |
| 165 | + return terminalBufferStateFromSnapshot( | |
| 166 | + event.snapshot, | |
| 167 | + maxBufferBytes, | |
| 168 | + current.bufferEpoch + 1, | |
| 169 | + ); | |
| 170 | + case "output": { | |
| 171 | + const bufferEnd = current.bufferEnd + event.data.length; | |
| 172 | + const buffer = trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes); | |
| 135 | 173 | return { |
| 136 | 174 | ...current, |
| 137 | − buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), | |
| 175 | + buffer, | |
| 176 | + bufferStart: bufferEnd - buffer.length, | |
| 177 | + bufferEnd, | |
| 138 | 178 | status: current.status === "closed" ? "running" : current.status, |
| 139 | 179 | error: null, |
| 140 | 180 | version: current.version + 1, |
| 141 | 181 | }; |
| 182 | + } | |
| 142 | 183 | case "cleared": |
| 143 | 184 | return { |
| 144 | 185 | ...current, |
| 145 | 186 | buffer: "", |
| 187 | + bufferEpoch: current.bufferEpoch + 1, | |
| 188 | + bufferStart: 0, | |
| 189 | + bufferEnd: 0, | |
| 146 | 190 | error: null, |
| 147 | 191 | version: current.version + 1, |
| 148 | 192 | }; |
| @@ -172,6 +216,28 @@ export function applyTerminalAttachStreamEvent( · 23 unmodified lines | ||
| 172 | 216 | } |
| 173 | 217 | } |
| 174 | 218 | |
| 219 | +export function getTerminalBufferUpdate( | |
| 220 | + previous: TerminalBufferWindow, | |
| 221 | + current: TerminalBufferWindow, | |
| 222 | +): TerminalBufferUpdate { | |
| 223 | + if ( | |
| 224 | + previous.bufferEpoch !== current.bufferEpoch || | |
| 225 | + previous.bufferEnd < current.bufferStart || | |
| 226 | + previous.bufferEnd > current.bufferEnd | |
| 227 | + ) { | |
| 228 | + return { type: "replace", buffer: current.buffer }; | |
| 229 | + } | |
| 230 | + | |
| 231 | + if (previous.bufferEnd === current.bufferEnd) { | |
| 232 | + return { type: "none" }; | |
| 233 | + } | |
| 234 | + | |
| 235 | + return { | |
| 236 | + type: "append", | |
| 237 | + data: current.buffer.slice(previous.bufferEnd - current.bufferStart), | |
| 238 | + }; | |
| 239 | +} | |
| 240 | + | |
| 175 | 241 | export function applyTerminalMetadataStreamEvent( |
| 176 | 242 | current: ReadonlyArray<TerminalSummary>, |
| 177 | 243 | event: TerminalMetadataStreamEvent, |