Unified · Split
| @@ -19,6 +19,14 @@ public class T3TerminalModule: Module { · 18 unmodified lines | |||
| 19 | view.initialBuffer = initialBuffer | 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 | Prop("fontSize") { (view: T3TerminalView, fontSize: Double) in | 30 | Prop("fontSize") { (view: T3TerminalView, fontSize: Double) in |
| 23 | view.fontSize = CGFloat(fontSize) | 31 | view.fontSize = CGFloat(fontSize) |
| 24 | } | 32 | } |
| @@ -196,6 +196,13 @@ private extension UIColor { · 195 unmodified lines | |||
| 196 | public final class T3TerminalView: ExpoView, UITextFieldDelegate { | 196 | public final class T3TerminalView: ExpoView, UITextFieldDelegate { |
| 197 | private static let minimumVerticalScrollStepPoints: CGFloat = 18 | 197 | private static let minimumVerticalScrollStepPoints: CGFloat = 18 |
| 198 | private static let verticalScrollStepMultiplier: CGFloat = 1.15 | 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 | private let terminalViewport = UIView() | 207 | private let terminalViewport = UIView() |
| 201 | private let inputField = TerminalInputField() | 208 | private let inputField = TerminalInputField() |
| @@ -204,7 +211,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 2 unmodified lines | |||
| 204 | private var lastViewportSize: CGSize = .zero | 211 | private var lastViewportSize: CGSize = .zero |
| 205 | private var lastContentScale: CGFloat = 0 | 212 | private var lastContentScale: CGFloat = 0 |
| 206 | private var lastReportedGrid: (cols: Int, rows: Int)? | 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 | private var pendingVerticalScrollPoints: CGFloat = 0 | 219 | private var pendingVerticalScrollPoints: CGFloat = 0 |
| 209 | private var app: ghostty_app_t? | 220 | private var app: ghostty_app_t? |
| 210 | private var surface: ghostty_surface_t? | 221 | private var surface: ghostty_surface_t? |
| @@ -221,13 +232,28 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 10 unmodified lines | |||
| 221 | accessibilityIdentifier = "t3-terminal-\(terminalKey)" | 232 | accessibilityIdentifier = "t3-terminal-\(terminalKey)" |
| 222 | if oldValue != terminalKey { | 233 | if oldValue != terminalKey { |
| 223 | resetSurface() | 234 | resetSurface() |
| 235 | enqueueRemoteBufferApply() | ||
| 224 | } | 236 | } |
| 225 | } | 237 | } |
| 226 | } | 238 | } |
| 227 | 239 | ||
| 228 | var initialBuffer: String = "" { | 240 | var initialBuffer: String = "" { |
| 229 | didSet { | 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 | let viewportSize = terminalViewport.bounds.size | 382 | let viewportSize = terminalViewport.bounds.size |
| 357 | if surface == nil { | 383 | if surface == nil { |
| 358 | createSurfaceIfPossible() | 384 | createSurfaceIfPossible() |
| 385 | if surface != nil { | ||
| 386 | enqueueRemoteBufferApply() | ||
| 387 | } | ||
| 359 | } | 388 | } |
| 360 | 389 | ||
| 361 | guard viewportSize != lastViewportSize || contentScaleFactor != lastContentScale else { | 390 | guard viewportSize != lastViewportSize || contentScaleFactor != lastContentScale else { |
| @@ -499,12 +528,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 137 unmodified lines | |||
| 499 | ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme) | 528 | ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme) |
| 500 | setupWriteCallback() | 529 | setupWriteCallback() |
| 501 | resizeSurface() | 530 | resizeSurface() |
| 502 | feedBuffer(initialBuffer) | ||
| 503 | } | 531 | } |
| 504 | 532 | ||
| 505 | private func resetSurface() { | 533 | private func resetSurface() { |
| 534 | surfaceGeneration += 1 | ||
| 506 | destroySurface() | 535 | destroySurface() |
| 507 | lastAppliedBuffer = "" | 536 | activeBufferFeed = nil |
| 537 | appliedBufferEpoch = nil | ||
| 538 | appliedBufferEnd = 0 | ||
| 508 | lastViewportSize = .zero | 539 | lastViewportSize = .zero |
| 509 | lastContentScale = 0 | 540 | lastContentScale = 0 |
| 510 | lastReportedGrid = nil | 541 | lastReportedGrid = nil |
| @@ -515,6 +546,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 4 unmodified lines | |||
| 515 | private func refreshSurface() { | 546 | private func refreshSurface() { |
| 516 | resetSurface() | 547 | resetSurface() |
| 517 | createSurfaceIfPossible() | 548 | createSurfaceIfPossible() |
| 549 | enqueueRemoteBufferApply() | ||
| 518 | } | 550 | } |
| 519 | 551 | ||
| 520 | private func destroySurface() { | 552 | private func destroySurface() { |
| @@ -529,40 +561,117 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 8 unmodified lines | |||
| 529 | app = nil | 561 | app = nil |
| 530 | } | 562 | } |
| 531 | 563 | ||
| 532 | private func applyRemoteBuffer(_ buffer: String) { | 564 | private func remoteBufferDidChange() { |
| 533 | guard surface != nil else { | 565 | bufferRevision += 1 |
| 534 | createSurfaceIfPossible() | 566 | enqueueRemoteBufferApply() |
| 535 | return | 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) { | 577 | private func applyRemoteBuffer(revision: Int) { |
| 539 | let suffix = String(buffer.dropFirst(lastAppliedBuffer.count)) | 578 | if let activeBufferFeed { |
| 540 | feedData(Data(suffix.utf8)) | 579 | if activeBufferFeed.bufferEpoch == bufferEpoch { |
| 541 | lastAppliedBuffer = buffer | 580 | return |
| 542 | return | 581 | } |
| 582 | resetSurface() | ||
| 543 | } | 583 | } |
| 544 | 584 | ||
| 545 | resetSurface() | 585 | if surface == nil { |
| 546 | createSurfaceIfPossible() | 586 | createSurfaceIfPossible() |
| 547 | } | 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) { | 610 | let targetSurfaceGeneration = surfaceGeneration |
| 550 | guard !buffer.isEmpty else { return } | 611 | activeBufferFeed = ActiveBufferFeed( |
| 551 | feedData(Data(buffer.utf8)) | 612 | surfaceGeneration: targetSurfaceGeneration, |
| 552 | lastAppliedBuffer = buffer | 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) { | 639 | private func feedData( |
| 556 | guard let surface, !data.isEmpty else { return } | 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 | data.withUnsafeBytes { buffer in | 653 | data.withUnsafeBytes { buffer in |
| 559 | guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { | 654 | guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { |
| 560 | return | 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 | private func setupWriteCallback() { | 677 | private func setupWriteCallback() { |
| @@ -35,6 +35,8 @@ interface TerminalResizeEvent { · 34 unmodified lines | |||
| 35 | interface TerminalSurfaceProps extends ViewProps { | 35 | interface TerminalSurfaceProps extends ViewProps { |
| 36 | readonly terminalKey: string; | 36 | readonly terminalKey: string; |
| 37 | readonly buffer: string; | 37 | readonly buffer: string; |
| 38 | readonly bufferEpoch: number; | ||
| 39 | readonly bufferStart: number; | ||
| 38 | readonly fontSize?: number; | 40 | readonly fontSize?: number; |
| 39 | readonly isRunning: boolean; | 41 | readonly isRunning: boolean; |
| 40 | readonly autoFocus?: boolean; | 42 | readonly autoFocus?: boolean; |
| @@ -186,9 +188,18 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 145 unmodified lines | |||
| 186 | // null = installed binary predates native hardware-key handling (rebuild needed). | 188 | // null = installed binary predates native hardware-key handling (rebuild needed). |
| 187 | hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(), | 189 | hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(), |
| 188 | bufferLen: props.buffer.length, | 190 | bufferLen: props.buffer.length, |
| 191 | bufferEpoch: props.bufferEpoch, | ||
| 192 | bufferStart: props.bufferStart, | ||
| 189 | isRunning: props.isRunning, | 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 | const handleNativeInput = useCallback( | 203 | const handleNativeInput = useCallback( |
| 193 | (event: NativeSyntheticEvent<TerminalInputEvent>) => { | 204 | (event: NativeSyntheticEvent<TerminalInputEvent>) => { |
| 194 | if (!props.isRunning) { | 205 | if (!props.isRunning) { |
| @@ -223,6 +234,8 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 28 unmodified lines | |||
| 223 | mutedForegroundColor={theme.mutedForeground} | 234 | mutedForegroundColor={theme.mutedForeground} |
| 224 | terminalKey={props.terminalKey} | 235 | terminalKey={props.terminalKey} |
| 225 | initialBuffer={props.buffer} | 236 | initialBuffer={props.buffer} |
| 237 | bufferEpoch={props.bufferEpoch} | ||
| 238 | bufferStart={props.bufferStart} | ||
| 226 | fontSize={fontSize} | 239 | fontSize={fontSize} |
| 227 | style={{ flex: 1 }} | 240 | style={{ flex: 1 }} |
| 228 | themeConfig={buildGhosttyThemeConfig(theme)} | 241 | themeConfig={buildGhosttyThemeConfig(theme)} |
| @@ -241,6 +241,8 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( · 240 unmodified lines | |||
| 241 | <TerminalSurface | 241 | <TerminalSurface |
| 242 | terminalKey={terminalKey} | 242 | terminalKey={terminalKey} |
| 243 | buffer={terminal.buffer} | 243 | buffer={terminal.buffer} |
| 244 | bufferEpoch={terminal.bufferEpoch} | ||
| 245 | bufferStart={terminal.bufferStart} | ||
| 244 | isRunning={isRunning} | 246 | isRunning={isRunning} |
| 245 | onInput={handleInput} | 247 | onInput={handleInput} |
| 246 | onResize={handleResize} | 248 | onResize={handleResize} |
| @@ -351,6 +351,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) · 350 unmodified lines | |||
| 351 | replayKey: bufferReplayKey, | 351 | replayKey: bufferReplayKey, |
| 352 | readyReplayKey: readyBufferReplayKey, | 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 | const isRunning = terminal.status === "running" || terminal.status === "starting"; | 359 | const isRunning = terminal.status === "running" || terminal.status === "starting"; |
| 355 | 360 | ||
| 356 | // When the process ends while this screen is attached (e.g. typing `exit`), | 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 | <TerminalSurface | 1226 | <TerminalSurface |
| 1222 | autoFocus={!SHOWCASE_ENABLED} | 1227 | autoFocus={!SHOWCASE_ENABLED} |
| 1223 | buffer={terminalSurfaceBuffer} | 1228 | buffer={terminalSurfaceBuffer} |
| 1229 | bufferEpoch={terminalSurfaceBufferEpoch} | ||
| 1230 | bufferStart={terminalSurfaceBufferStart} | ||
| 1224 | fontSize={fontSize} | 1231 | fontSize={fontSize} |
| 1225 | isRunning={isRunning} | 1232 | isRunning={isRunning} |
| 1226 | keyboardFocusRequest={keyboardFocusRequest} | 1233 | keyboardFocusRequest={keyboardFocusRequest} |
| @@ -31,6 +31,8 @@ export interface NativeTerminalSurfaceProps extends ViewProps { · 30 unmodified lines | |||
| 31 | readonly mutedForegroundColor?: string; | 31 | readonly mutedForegroundColor?: string; |
| 32 | readonly terminalKey: string; | 32 | readonly terminalKey: string; |
| 33 | readonly initialBuffer: string; | 33 | readonly initialBuffer: string; |
| 34 | readonly bufferEpoch: number; | ||
| 35 | readonly bufferStart: number; | ||
| 34 | readonly fontSize: number; | 36 | readonly fontSize: number; |
| 35 | readonly onInput?: (event: NativeSyntheticEvent<TerminalInputEvent>) => void; | 37 | readonly onInput?: (event: NativeSyntheticEvent<TerminalInputEvent>) => void; |
| 36 | readonly onResize?: (event: NativeSyntheticEvent<TerminalResizeEvent>) => void; | 38 | readonly onResize?: (event: NativeSyntheticEvent<TerminalResizeEvent>) => void; |
| @@ -57,6 +57,9 @@ function makeKnownSession(input: { · 56 unmodified lines | |||
| 57 | } | 57 | } |
| 58 | : null, | 58 | : null, |
| 59 | buffer: "", | 59 | buffer: "", |
| 60 | bufferEpoch: 0, | ||
| 61 | bufferStart: 0, | ||
| 62 | bufferEnd: 0, | ||
| 60 | status: input.status, | 63 | status: input.status, |
| 61 | error: null, | 64 | error: null, |
| 62 | hasRunningSubprocess: false, | 65 | hasRunningSubprocess: false, |
| @@ -4,6 +4,7 @@ import { · 3 unmodified lines | |||
| 4 | isAtomCommandInterrupted, | 4 | isAtomCommandInterrupted, |
| 5 | squashAtomCommandFailure, | 5 | squashAtomCommandFailure, |
| 6 | } from "@t3tools/client-runtime/state/runtime"; | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import { getTerminalBufferUpdate } from "@t3tools/client-runtime/state/terminal"; | ||
| 7 | import { | 8 | import { |
| 8 | Plus, | 9 | Plus, |
| 9 | SquareSplitHorizontal, | 10 | SquareSplitHorizontal, |
| @@ -365,11 +366,17 @@ export function TerminalViewport({ · 355 unmodified lines | |||
| 365 | }), | 366 | }), |
| 366 | ); | 367 | ); |
| 367 | const terminalBuffer = terminalSession.buffer; | 368 | const terminalBuffer = terminalSession.buffer; |
| 369 | const terminalBufferEpoch = terminalSession.bufferEpoch; | ||
| 370 | const terminalBufferStart = terminalSession.bufferStart; | ||
| 371 | const terminalBufferEnd = terminalSession.bufferEnd; | ||
| 368 | const terminalError = terminalSession.error; | 372 | const terminalError = terminalSession.error; |
| 369 | const terminalStatus = terminalSession.status; | 373 | const terminalStatus = terminalSession.status; |
| 370 | const terminalVersion = terminalSession.version; | 374 | const terminalVersion = terminalSession.version; |
| 371 | const previousSessionRef = useRef({ | 375 | const previousSessionRef = useRef({ |
| 372 | buffer: terminalBuffer, | 376 | buffer: terminalBuffer, |
| 377 | bufferEpoch: terminalBufferEpoch, | ||
| 378 | bufferStart: terminalBufferStart, | ||
| 379 | bufferEnd: terminalBufferEnd, | ||
| 373 | error: terminalError, | 380 | error: terminalError, |
| 374 | status: terminalStatus, | 381 | status: terminalStatus, |
| 375 | version: terminalVersion, | 382 | version: terminalVersion, |
| @@ -403,6 +410,9 @@ export function TerminalViewport({ · 27 unmodified lines | |||
| 403 | fitAddonRef.current = fitAddon; | 410 | fitAddonRef.current = fitAddon; |
| 404 | previousSessionRef.current = { | 411 | previousSessionRef.current = { |
| 405 | buffer: "", | 412 | buffer: "", |
| 413 | bufferEpoch: 0, | ||
| 414 | bufferStart: 0, | ||
| 415 | bufferEnd: 0, | ||
| 406 | status: "closed", | 416 | status: "closed", |
| 407 | error: null, | 417 | error: null, |
| 408 | version: 0, | 418 | version: 0, |
| @@ -740,6 +750,9 @@ export function TerminalViewport({ · 331 unmodified lines | |||
| 740 | const terminal = terminalRef.current; | 750 | const terminal = terminalRef.current; |
| 741 | const current = { | 751 | const current = { |
| 742 | buffer: terminalBuffer, | 752 | buffer: terminalBuffer, |
| 753 | bufferEpoch: terminalBufferEpoch, | ||
| 754 | bufferStart: terminalBufferStart, | ||
| 755 | bufferEnd: terminalBufferEnd, | ||
| 743 | error: terminalError, | 756 | error: terminalError, |
| 744 | status: terminalStatus, | 757 | status: terminalStatus, |
| 745 | version: terminalVersion, | 758 | version: terminalVersion, |
| @@ -750,17 +763,11 @@ export function TerminalViewport({ · 4 unmodified lines | |||
| 750 | } | 763 | } |
| 751 | 764 | ||
| 752 | const previous = previousSessionRef.current; | 765 | const previous = previousSessionRef.current; |
| 753 | if (current.version === previous.version) { | 766 | const bufferUpdate = getTerminalBufferUpdate(previous, current); |
| 754 | return; | 767 | if (bufferUpdate.type === "append") { |
| 755 | } | 768 | terminal.write(bufferUpdate.data); |
| 756 | 769 | } else if (bufferUpdate.type === "replace") { | |
| 757 | if ( | 770 | writeTerminalBuffer(terminal, bufferUpdate.buffer); |
| 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); | ||
| 764 | } | 771 | } |
| 765 | terminal.clearSelection(); | 772 | terminal.clearSelection(); |
| 766 | 773 | ||
| @@ -793,7 +800,16 @@ export function TerminalViewport({ · 26 unmodified lines | |||
| 793 | }); | 800 | }); |
| 794 | } | 801 | } |
| 795 | previousSessionRef.current = current; | 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 | useEffect(() => { | 814 | useEffect(() => { |
| 799 | if (!autoFocus) return; | 815 | if (!autoFocus) return; |
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | applyTerminalMetadataStreamEvent, | 7 | applyTerminalMetadataStreamEvent, |
| 8 | combineTerminalSessionState, | 8 | combineTerminalSessionState, |
| 9 | EMPTY_TERMINAL_BUFFER_STATE, | 9 | EMPTY_TERMINAL_BUFFER_STATE, |
| 10 | getTerminalBufferUpdate, | ||
| 10 | selectRunningSubprocessTerminalIds, | 11 | selectRunningSubprocessTerminalIds, |
| 11 | } from "./terminalSession.ts"; | 12 | } from "./terminalSession.ts"; |
| 12 | 13 | ||
| @@ -127,6 +128,9 @@ describe("terminal session reducers", () => { · 114 unmodified lines | |||
| 127 | 128 | ||
| 128 | expect(output).toMatchObject({ | 129 | expect(output).toMatchObject({ |
| 129 | buffer: "lo world", | 130 | buffer: "lo world", |
| 131 | bufferEpoch: 1, | ||
| 132 | bufferStart: 3, | ||
| 133 | bufferEnd: 11, | ||
| 130 | status: "running", | 134 | status: "running", |
| 131 | error: null, | 135 | error: null, |
| 132 | version: 2, | 136 | version: 2, |
| @@ -183,5 +187,77 @@ describe("terminal session reducers", () => { · 50 unmodified lines | |||
| 183 | ); | 187 | ); |
| 184 | 188 | ||
| 185 | expect(state.buffer).toBe("🙂"); | 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 | export interface TerminalSessionState { | 10 | export interface TerminalSessionState { |
| 11 | readonly summary: TerminalSummary | null; | 11 | readonly summary: TerminalSummary | null; |
| 12 | readonly buffer: string; | 12 | readonly buffer: string; |
| 13 | readonly bufferEpoch: number; | ||
| 14 | readonly bufferStart: number; | ||
| 15 | readonly bufferEnd: number; | ||
| 13 | readonly status: TerminalSessionSnapshot["status"] | "closed"; | 16 | readonly status: TerminalSessionSnapshot["status"] | "closed"; |
| 14 | readonly error: string | null; | 17 | readonly error: string | null; |
| 15 | readonly hasRunningSubprocess: boolean; | 18 | readonly hasRunningSubprocess: boolean; |
| @@ -19,12 +22,27 @@ export interface TerminalSessionState { · 3 unmodified lines | |||
| 19 | 22 | ||
| 20 | export interface TerminalBufferState { | 23 | export interface TerminalBufferState { |
| 21 | readonly buffer: string; | 24 | readonly buffer: string; |
| 25 | readonly bufferEpoch: number; | ||
| 26 | readonly bufferStart: number; | ||
| 27 | readonly bufferEnd: number; | ||
| 22 | readonly status: TerminalSessionSnapshot["status"] | "closed"; | 28 | readonly status: TerminalSessionSnapshot["status"] | "closed"; |
| 23 | readonly error: string | null; | 29 | readonly error: string | null; |
| 24 | readonly updatedAt: string | null; | 30 | readonly updatedAt: string | null; |
| 25 | readonly version: number; | 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 | export interface KnownTerminalSessionTarget { | 46 | export interface KnownTerminalSessionTarget { |
| 29 | readonly environmentId: EnvironmentId; | 47 | readonly environmentId: EnvironmentId; |
| 30 | readonly threadId: ThreadId; | 48 | readonly threadId: ThreadId; |
| @@ -46,6 +64,9 @@ export function selectRunningSubprocessTerminalIds( · 15 unmodified lines | |||
| 46 | 64 | ||
| 47 | export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ | 65 | export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ |
| 48 | buffer: "", | 66 | buffer: "", |
| 67 | bufferEpoch: 0, | ||
| 68 | bufferStart: 0, | ||
| 69 | bufferEnd: 0, | ||
| 49 | status: "closed", | 70 | status: "closed", |
| 50 | error: null, | 71 | error: null, |
| 51 | updatedAt: null, | 72 | updatedAt: null, |
| @@ -55,6 +76,9 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ · 3 unmodified lines | |||
| 55 | export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({ | 76 | export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({ |
| 56 | summary: null, | 77 | summary: null, |
| 57 | buffer: "", | 78 | buffer: "", |
| 79 | bufferEpoch: 0, | ||
| 80 | bufferStart: 0, | ||
| 81 | bufferEnd: 0, | ||
| 58 | status: "closed", | 82 | status: "closed", |
| 59 | error: null, | 83 | error: null, |
| 60 | hasRunningSubprocess: false, | 84 | hasRunningSubprocess: false, |
| @@ -91,9 +115,14 @@ function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { · 30 unmodified lines | |||
| 91 | export function terminalBufferStateFromSnapshot( | 115 | export function terminalBufferStateFromSnapshot( |
| 92 | snapshot: TerminalSessionSnapshot, | 116 | snapshot: TerminalSessionSnapshot, |
| 93 | maxBufferBytes: number, | 117 | maxBufferBytes: number, |
| 118 | bufferEpoch = 1, | ||
| 94 | ): TerminalBufferState { | 119 | ): TerminalBufferState { |
| 120 | const buffer = trimBufferToBytes(snapshot.history, maxBufferBytes); | ||
| 95 | return { | 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 | status: snapshot.status, | 126 | status: snapshot.status, |
| 98 | error: null, | 127 | error: null, |
| 99 | updatedAt: snapshot.updatedAt, | 128 | updatedAt: snapshot.updatedAt, |
| @@ -114,6 +143,9 @@ export function combineTerminalSessionState( · 14 unmodified lines | |||
| 114 | return { | 143 | return { |
| 115 | summary, | 144 | summary, |
| 116 | buffer: buffer.buffer, | 145 | buffer: buffer.buffer, |
| 146 | bufferEpoch: buffer.bufferEpoch, | ||
| 147 | bufferStart: buffer.bufferStart, | ||
| 148 | bufferEnd: buffer.bufferEnd, | ||
| 117 | status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), | 149 | status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status), |
| 118 | error: buffer.error, | 150 | error: buffer.error, |
| 119 | hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, | 151 | hasRunningSubprocess: summary?.hasRunningSubprocess ?? false, |
| @@ -130,19 +162,31 @@ export function applyTerminalAttachStreamEvent( · 10 unmodified lines | |||
| 130 | switch (event.type) { | 162 | switch (event.type) { |
| 131 | case "snapshot": | 163 | case "snapshot": |
| 132 | case "restarted": | 164 | case "restarted": |
| 133 | return terminalBufferStateFromSnapshot(event.snapshot, maxBufferBytes); | 165 | return terminalBufferStateFromSnapshot( |
| 134 | case "output": | 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 | return { | 173 | return { |
| 136 | ...current, | 174 | ...current, |
| 137 | buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes), | 175 | buffer, |
| 176 | bufferStart: bufferEnd - buffer.length, | ||
| 177 | bufferEnd, | ||
| 138 | status: current.status === "closed" ? "running" : current.status, | 178 | status: current.status === "closed" ? "running" : current.status, |
| 139 | error: null, | 179 | error: null, |
| 140 | version: current.version + 1, | 180 | version: current.version + 1, |
| 141 | }; | 181 | }; |
| 182 | } | ||
| 142 | case "cleared": | 183 | case "cleared": |
| 143 | return { | 184 | return { |
| 144 | ...current, | 185 | ...current, |
| 145 | buffer: "", | 186 | buffer: "", |
| 187 | bufferEpoch: current.bufferEpoch + 1, | ||
| 188 | bufferStart: 0, | ||
| 189 | bufferEnd: 0, | ||
| 146 | error: null, | 190 | error: null, |
| 147 | version: current.version + 1, | 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 | export function applyTerminalMetadataStreamEvent( | 241 | export function applyTerminalMetadataStreamEvent( |
| 176 | current: ReadonlyArray<TerminalSummary>, | 242 | current: ReadonlyArray<TerminalSummary>, |
| 177 | event: TerminalMetadataStreamEvent, | 243 | event: TerminalMetadataStreamEvent, |