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
1919 view.initialBuffer = initialBuffer
2020 }
2121
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+
2230 Prop("fontSize") { (view: T3TerminalView, fontSize: Double) in
2331 view.fontSize = CGFloat(fontSize)
2432 }
apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift+133−24
@@ -196,6 +196,13 @@ private extension UIColor { · 195 unmodified lines
196196 public final class T3TerminalView: ExpoView, UITextFieldDelegate {
197197 private static let minimumVerticalScrollStepPoints: CGFloat = 18
198198 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+ }
199206
200207 private let terminalViewport = UIView()
201208 private let inputField = TerminalInputField()
@@ -204,7 +211,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 2 unmodified lines
204211 private var lastViewportSize: CGSize = .zero
205212 private var lastContentScale: CGFloat = 0
206213 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?
208219 private var pendingVerticalScrollPoints: CGFloat = 0
209220 private var app: ghostty_app_t?
210221 private var surface: ghostty_surface_t?
@@ -221,13 +232,28 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 10 unmodified lines
221232 accessibilityIdentifier = "t3-terminal-\(terminalKey)"
222233 if oldValue != terminalKey {
223234 resetSurface()
235+ enqueueRemoteBufferApply()
224236 }
225237 }
226238 }
227239
228240 var initialBuffer: String = "" {
229241 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()
231257 }
232258 }
233259
@@ -356,6 +382,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 122 unmodified lines
356382 let viewportSize = terminalViewport.bounds.size
357383 if surface == nil {
358384 createSurfaceIfPossible()
385+ if surface != nil {
386+ enqueueRemoteBufferApply()
387+ }
359388 }
360389
361390 guard viewportSize != lastViewportSize || contentScaleFactor != lastContentScale else {
@@ -499,12 +528,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 137 unmodified lines
499528 ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme)
500529 setupWriteCallback()
501530 resizeSurface()
502− feedBuffer(initialBuffer)
503531 }
504532
505533 private func resetSurface() {
534+ surfaceGeneration += 1
506535 destroySurface()
507− lastAppliedBuffer = ""
536+ activeBufferFeed = nil
537+ appliedBufferEpoch = nil
538+ appliedBufferEnd = 0
508539 lastViewportSize = .zero
509540 lastContentScale = 0
510541 lastReportedGrid = nil
@@ -515,6 +546,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 4 unmodified lines
515546 private func refreshSurface() {
516547 resetSurface()
517548 createSurfaceIfPossible()
549+ enqueueRemoteBufferApply()
518550 }
519551
520552 private func destroySurface() {
@@ -529,40 +561,117 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { · 8 unmodified lines
529561 app = nil
530562 }
531563
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)
536574 }
575+ }
537576
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()
543583 }
544584
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+ }
548609
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+ }
553637 }
554638
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+ }
557651
652+ let chunkCount = min(Self.bufferFeedChunkSize, data.count - offset)
558653 data.withUnsafeBytes { buffer in
559654 guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
560655 return
561656 }
562− ghostty_surface_feed_data(surface, pointer, buffer.count)
657+ ghostty_surface_feed_data(surface, pointer.advanced(by: offset), chunkCount)
563658 }
564659
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+ }
566675 }
567676
568677 private func setupWriteCallback() {
apps/mobile/src/features/terminal/NativeTerminalSurface.tsx+14−1
@@ -35,6 +35,8 @@ interface TerminalResizeEvent { · 34 unmodified lines
3535 interface TerminalSurfaceProps extends ViewProps {
3636 readonly terminalKey: string;
3737 readonly buffer: string;
38+ readonly bufferEpoch: number;
39+ readonly bufferStart: number;
3840 readonly fontSize?: number;
3941 readonly isRunning: boolean;
4042 readonly autoFocus?: boolean;
@@ -186,9 +188,18 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 145 unmodified lines
186188 // null = installed binary predates native hardware-key handling (rebuild needed).
187189 hardwareKeyRevision: getNativeTerminalHardwareKeyRevision(),
188190 bufferLen: props.buffer.length,
191+ bufferEpoch: props.bufferEpoch,
192+ bufferStart: props.bufferStart,
189193 isRunning: props.isRunning,
190194 });
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+ ]);
192203 const handleNativeInput = useCallback(
193204 (event: NativeSyntheticEvent<TerminalInputEvent>) => {
194205 if (!props.isRunning) {
@@ -223,6 +234,8 @@ export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurf · 28 unmodified lines
223234 mutedForegroundColor={theme.mutedForeground}
224235 terminalKey={props.terminalKey}
225236 initialBuffer={props.buffer}
237+ bufferEpoch={props.bufferEpoch}
238+ bufferStart={props.bufferStart}
226239 fontSize={fontSize}
227240 style={{ flex: 1 }}
228241 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
241241 <TerminalSurface
242242 terminalKey={terminalKey}
243243 buffer={terminal.buffer}
244+ bufferEpoch={terminal.bufferEpoch}
245+ bufferStart={terminal.bufferStart}
244246 isRunning={isRunning}
245247 onInput={handleInput}
246248 onResize={handleResize}
apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx+7−0
@@ -351,6 +351,11 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) · 350 unmodified lines
351351 replayKey: bufferReplayKey,
352352 readyReplayKey: readyBufferReplayKey,
353353 });
354+ const isTerminalSurfaceBufferHidden = terminalSurfaceBuffer !== terminal.buffer;
355+ const terminalSurfaceBufferEpoch = isTerminalSurfaceBufferHidden
356+ ? terminal.bufferEpoch + 1
357+ : terminal.bufferEpoch;
358+ const terminalSurfaceBufferStart = isTerminalSurfaceBufferHidden ? 0 : terminal.bufferStart;
354359 const isRunning = terminal.status === "running" || terminal.status === "starting";
355360
356361 // 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
12211226 <TerminalSurface
12221227 autoFocus={!SHOWCASE_ENABLED}
12231228 buffer={terminalSurfaceBuffer}
1229+ bufferEpoch={terminalSurfaceBufferEpoch}
1230+ bufferStart={terminalSurfaceBufferStart}
12241231 fontSize={fontSize}
12251232 isRunning={isRunning}
12261233 keyboardFocusRequest={keyboardFocusRequest}
apps/mobile/src/features/terminal/nativeTerminalModule.ts+2−0
@@ -31,6 +31,8 @@ export interface NativeTerminalSurfaceProps extends ViewProps { · 30 unmodified lines
3131 readonly mutedForegroundColor?: string;
3232 readonly terminalKey: string;
3333 readonly initialBuffer: string;
34+ readonly bufferEpoch: number;
35+ readonly bufferStart: number;
3436 readonly fontSize: number;
3537 readonly onInput?: (event: NativeSyntheticEvent<TerminalInputEvent>) => void;
3638 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
5757 }
5858 : null,
5959 buffer: "",
60+ bufferEpoch: 0,
61+ bufferStart: 0,
62+ bufferEnd: 0,
6063 status: input.status,
6164 error: null,
6265 hasRunningSubprocess: false,
apps/web/src/components/ThreadTerminalDrawer.tsx+28−12
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
44 isAtomCommandInterrupted,
55 squashAtomCommandFailure,
66 } from "@t3tools/client-runtime/state/runtime";
7+import { getTerminalBufferUpdate } from "@t3tools/client-runtime/state/terminal";
78 import {
89 Plus,
910 SquareSplitHorizontal,
@@ -365,11 +366,17 @@ export function TerminalViewport({ · 355 unmodified lines
365366 }),
366367 );
367368 const terminalBuffer = terminalSession.buffer;
369+ const terminalBufferEpoch = terminalSession.bufferEpoch;
370+ const terminalBufferStart = terminalSession.bufferStart;
371+ const terminalBufferEnd = terminalSession.bufferEnd;
368372 const terminalError = terminalSession.error;
369373 const terminalStatus = terminalSession.status;
370374 const terminalVersion = terminalSession.version;
371375 const previousSessionRef = useRef({
372376 buffer: terminalBuffer,
377+ bufferEpoch: terminalBufferEpoch,
378+ bufferStart: terminalBufferStart,
379+ bufferEnd: terminalBufferEnd,
373380 error: terminalError,
374381 status: terminalStatus,
375382 version: terminalVersion,
@@ -403,6 +410,9 @@ export function TerminalViewport({ · 27 unmodified lines
403410 fitAddonRef.current = fitAddon;
404411 previousSessionRef.current = {
405412 buffer: "",
413+ bufferEpoch: 0,
414+ bufferStart: 0,
415+ bufferEnd: 0,
406416 status: "closed",
407417 error: null,
408418 version: 0,
@@ -740,6 +750,9 @@ export function TerminalViewport({ · 331 unmodified lines
740750 const terminal = terminalRef.current;
741751 const current = {
742752 buffer: terminalBuffer,
753+ bufferEpoch: terminalBufferEpoch,
754+ bufferStart: terminalBufferStart,
755+ bufferEnd: terminalBufferEnd,
743756 error: terminalError,
744757 status: terminalStatus,
745758 version: terminalVersion,
@@ -750,17 +763,11 @@ export function TerminalViewport({ · 4 unmodified lines
750763 }
751764
752765 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);
764771 }
765772 terminal.clearSelection();
766773
@@ -793,7 +800,16 @@ export function TerminalViewport({ · 26 unmodified lines
793800 });
794801 }
795802 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+ ]);
797813
798814 useEffect(() => {
799815 if (!autoFocus) return;
packages/client-runtime/src/state/terminalSession.test.ts+76−0
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 applyTerminalMetadataStreamEvent,
88 combineTerminalSessionState,
99 EMPTY_TERMINAL_BUFFER_STATE,
10+ getTerminalBufferUpdate,
1011 selectRunningSubprocessTerminalIds,
1112 } from "./terminalSession.ts";
1213
@@ -127,6 +128,9 @@ describe("terminal session reducers", () => { · 114 unmodified lines
127128
128129 expect(output).toMatchObject({
129130 buffer: "lo world",
131+ bufferEpoch: 1,
132+ bufferStart: 3,
133+ bufferEnd: 11,
130134 status: "running",
131135 error: null,
132136 version: 2,
@@ -183,5 +187,77 @@ describe("terminal session reducers", () => { · 50 unmodified lines
183187 );
184188
185189 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+ });
186262 });
187263 });
packages/client-runtime/src/state/terminalSession.ts+70−4
@@ -10,6 +10,9 @@ import type { · 9 unmodified lines
1010 export interface TerminalSessionState {
1111 readonly summary: TerminalSummary | null;
1212 readonly buffer: string;
13+ readonly bufferEpoch: number;
14+ readonly bufferStart: number;
15+ readonly bufferEnd: number;
1316 readonly status: TerminalSessionSnapshot["status"] | "closed";
1417 readonly error: string | null;
1518 readonly hasRunningSubprocess: boolean;
@@ -19,12 +22,27 @@ export interface TerminalSessionState { · 3 unmodified lines
1922
2023 export interface TerminalBufferState {
2124 readonly buffer: string;
25+ readonly bufferEpoch: number;
26+ readonly bufferStart: number;
27+ readonly bufferEnd: number;
2228 readonly status: TerminalSessionSnapshot["status"] | "closed";
2329 readonly error: string | null;
2430 readonly updatedAt: string | null;
2531 readonly version: number;
2632 }
2733
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+
2846 export interface KnownTerminalSessionTarget {
2947 readonly environmentId: EnvironmentId;
3048 readonly threadId: ThreadId;
@@ -46,6 +64,9 @@ export function selectRunningSubprocessTerminalIds( · 15 unmodified lines
4664
4765 export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({
4866 buffer: "",
67+ bufferEpoch: 0,
68+ bufferStart: 0,
69+ bufferEnd: 0,
4970 status: "closed",
5071 error: null,
5172 updatedAt: null,
@@ -55,6 +76,9 @@ export const EMPTY_TERMINAL_BUFFER_STATE = Object.freeze<TerminalBufferState>({ · 3 unmodified lines
5576 export const EMPTY_TERMINAL_SESSION_STATE = Object.freeze<TerminalSessionState>({
5677 summary: null,
5778 buffer: "",
79+ bufferEpoch: 0,
80+ bufferStart: 0,
81+ bufferEnd: 0,
5882 status: "closed",
5983 error: null,
6084 hasRunningSubprocess: false,
@@ -91,9 +115,14 @@ function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { · 30 unmodified lines
91115 export function terminalBufferStateFromSnapshot(
92116 snapshot: TerminalSessionSnapshot,
93117 maxBufferBytes: number,
118+ bufferEpoch = 1,
94119 ): TerminalBufferState {
120+ const buffer = trimBufferToBytes(snapshot.history, maxBufferBytes);
95121 return {
96− buffer: trimBufferToBytes(snapshot.history, maxBufferBytes),
122+ buffer,
123+ bufferEpoch,
124+ bufferStart: snapshot.history.length - buffer.length,
125+ bufferEnd: snapshot.history.length,
97126 status: snapshot.status,
98127 error: null,
99128 updatedAt: snapshot.updatedAt,
@@ -114,6 +143,9 @@ export function combineTerminalSessionState( · 14 unmodified lines
114143 return {
115144 summary,
116145 buffer: buffer.buffer,
146+ bufferEpoch: buffer.bufferEpoch,
147+ bufferStart: buffer.bufferStart,
148+ bufferEnd: buffer.bufferEnd,
117149 status: buffer.version > 0 ? buffer.status : (summary?.status ?? buffer.status),
118150 error: buffer.error,
119151 hasRunningSubprocess: summary?.hasRunningSubprocess ?? false,
@@ -130,19 +162,31 @@ export function applyTerminalAttachStreamEvent( · 10 unmodified lines
130162 switch (event.type) {
131163 case "snapshot":
132164 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);
135173 return {
136174 ...current,
137− buffer: trimBufferToBytes(`${current.buffer}${event.data}`, maxBufferBytes),
175+ buffer,
176+ bufferStart: bufferEnd - buffer.length,
177+ bufferEnd,
138178 status: current.status === "closed" ? "running" : current.status,
139179 error: null,
140180 version: current.version + 1,
141181 };
182+ }
142183 case "cleared":
143184 return {
144185 ...current,
145186 buffer: "",
187+ bufferEpoch: current.bufferEpoch + 1,
188+ bufferStart: 0,
189+ bufferEnd: 0,
146190 error: null,
147191 version: current.version + 1,
148192 };
@@ -172,6 +216,28 @@ export function applyTerminalAttachStreamEvent( · 23 unmodified lines
172216 }
173217 }
174218
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+
175241 export function applyTerminalMetadataStreamEvent(
176242 current: ReadonlyArray<TerminalSummary>,
177243 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