History

fix(terminal): handle retained history rollover

Gabriel De Andrade committed 3 months ago

Commit d9a445c1 parent 08daccb10 files · +343 −41Browse files

Unified · Split

10 files
  1. apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +8 −0
  2. apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +133 −24
  3. apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +14 −1
  4. apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +2 −0
  5. apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +7 −0
  6. apps/mobile/src/features/terminal/nativeTerminalModule.ts +2 −0
  7. apps/mobile/src/features/terminal/terminalMenu.test.ts +3 −0
  8. apps/web/src/components/ThreadTerminalDrawer.tsx +28 −12
  9. packages/client-runtime/src/state/terminalSession.test.ts +76 −0
  10. packages/client-runtime/src/state/terminalSession.ts +70 −4
apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift+8−0
@@ -19,6 +19,14 @@ public class T3TerminalModule: Module { · 18 unmodified lines
19 view.initialBuffer = initialBuffer19 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) in30 Prop("fontSize") { (view: T3TerminalView, fontSize: Double) in
23 view.fontSize = CGFloat(fontSize)31 view.fontSize = CGFloat(fontSize)
24 }32 }
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+133−24
@@ -196,6 +196,13 @@ private extension UIColor { · 195 unmodified lines
196public final class T3TerminalView: ExpoView, UITextFieldDelegate {196public final class T3TerminalView: ExpoView, UITextFieldDelegate {
197 private static let minimumVerticalScrollStepPoints: CGFloat = 18197 private static let minimumVerticalScrollStepPoints: CGFloat = 18
198 private static let verticalScrollStepMultiplier: CGFloat = 1.15198 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 = .zero211 private var lastViewportSize: CGSize = .zero
205 private var lastContentScale: CGFloat = 0212 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 = 0219 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.size382 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 = .zero539 lastViewportSize = .zero
509 lastContentScale = 0540 lastContentScale = 0
510 lastReportedGrid = nil541 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 = nil561 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 return567 }
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 = buffer580 return
542 return581 }
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 = buffer613 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 in653 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 return655 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() {
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx+14−1
@@ -35,6 +35,8 @@ interface TerminalResizeEvent { · 34 unmodified lines
35interface TerminalSurfaceProps extends ViewProps {35interface 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)}
apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx+2−0
@@ -241,6 +241,8 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( · 240 unmodified lines
241 <TerminalSurface241 <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}
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+7−0
@@ -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 <TerminalSurface1226 <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}
apps/mobile/src/features/terminal/nativeTerminalModule.ts+2−0
@@ -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;
apps/mobile/src/features/terminal/terminalMenu.test.ts+3−0
@@ -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,
apps/web/src/components/ThreadTerminalDrawer.tsx+28−12
@@ -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";
7import { getTerminalBufferUpdate } from "@t3tools/client-runtime/state/terminal";
7import {8import {
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;
packages/client-runtime/src/state/terminalSession.test.ts+76−0
@@ -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});
packages/client-runtime/src/state/terminalSession.ts+70−4
@@ -10,6 +10,9 @@ import type { · 9 unmodified lines
10export interface TerminalSessionState {10export 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
20export interface TerminalBufferState {23export 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
34export interface TerminalBufferWindow {
35 readonly buffer: string;
36 readonly bufferEpoch: number;
37 readonly bufferStart: number;
38 readonly bufferEnd: number;
39}
40
41export type TerminalBufferUpdate =
42 | { readonly type: "none" }
43 | { readonly type: "append"; readonly data: string }
44 | { readonly type: "replace"; readonly buffer: string };
45
28export interface KnownTerminalSessionTarget {46export 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
47export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({65export 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
55export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({76export 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
91export function terminalBufferStateFromSnapshot(115export 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
219export 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
175export function applyTerminalMetadataStreamEvent(241export function applyTerminalMetadataStreamEvent(
176 current: ReadonlyArray<TerminalSummary>,242 current: ReadonlyArray<TerminalSummary>,
177 event: TerminalMetadataStreamEvent,243 event: TerminalMetadataStreamEvent,

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t