11'use client'
22
33import {
4+ memo ,
45 type DragEvent as ReactDragEvent ,
56 type MouseEvent as ReactMouseEvent ,
67 useCallback ,
@@ -120,6 +121,16 @@ function useSettledCommands(tabs: TerminalTabState[]): ReadonlySet<string> {
120121 */
121122const RESIZE_SETTLE_MS = 120
122123
124+ /**
125+ * How much output an offscreen terminal banks before it gives up and asks the
126+ * desktop app for the screen again on the way back in.
127+ *
128+ * Matched to the scrollback the desktop app retains: past that, replaying the
129+ * bank costs more than the snapshot and cannot show anything the snapshot
130+ * would not.
131+ */
132+ const MAX_BANKED_CHARS = 256_000
133+
123134const LIGHT_THEME = {
124135 background : '#ffffff' ,
125136 foreground : '#1f2328' ,
@@ -237,21 +248,26 @@ function handleTerminalShortcut(event: KeyboardEvent, terminal: Terminal): boole
237248 }
238249}
239250
240- function TerminalView ( {
251+ const TerminalView = memo ( function TerminalView ( {
241252 terminalId,
242253 active,
243- canClose ,
254+ visible ,
244255} : {
245256 terminalId : string
246257 active : boolean
247- canClose : boolean
258+ visible : boolean
248259} ) {
249260 const { resolvedTheme } = useTheme ( )
250261 const hostRef = useRef < HTMLDivElement > ( null )
251262 const terminalRef = useRef < Terminal | null > ( null )
252263 const fitRef = useRef < FitAddon | null > ( null )
253- const activeRef = useRef ( active )
254- activeRef . current = active
264+ // Being the selected tab is not enough to be on screen: the whole panel is
265+ // hidden whenever another resource is open.
266+ const onscreen = active && visible
267+ const onscreenRef = useRef ( onscreen )
268+ onscreenRef . current = onscreen
269+ const showRef = useRef < ( ( ) => void ) | null > ( null )
270+ const hideRef = useRef < ( ( ) => void ) | null > ( null )
255271
256272 useEffect ( ( ) => {
257273 const host = hostRef . current
@@ -292,41 +308,94 @@ function TerminalView({
292308 const disposeResize = terminal . onResize ( ( { cols, rows } ) =>
293309 resizeTerminal ( terminalId , cols , rows )
294310 )
295- // The desktop app owns the scrollback, so a view opening onto a shell that
296- // has been running without it paints from what is already on that screen.
311+ // Output is only parsed into a terminal that is on screen.
312+ //
313+ // `write` parses whether or not the view is painting, and parsing is the
314+ // expensive half — pausing the renderer for a hidden tab saved the drawing
315+ // and none of the decoding. So every open terminal was decoding its
316+ // shell's output at full rate behind whatever the user was actually
317+ // looking at, and the cost grew with the number of tabs rather than with
318+ // the one in front of them. An offscreen view banks its bytes and replays
319+ // them on the way back in, which is indistinguishable on screen.
297320 //
298- // Live bytes are held until that paint lands rather than written straight
299- // through: the snapshot is a moment in time, and anything arriving while
300- // it is in flight would either be wiped by the reset or, written first,
301- // appear above the history it followed. Buffering keeps the order true.
321+ // Bytes are banked during the opening snapshot too. The desktop app owns
322+ // the scrollback, so a view opening onto a shell that has been running
323+ // without it paints from what is already on that screen — but the snapshot
324+ // is a moment in time, and anything arriving while it is in flight would
325+ // either be wiped by the reset or, written first, appear above the history
326+ // it followed. Banking keeps the order true.
327+ let writable = false
328+ let banked = ''
329+ let overflowed = false
302330 let painted = false
303- let buffered = ''
331+
304332 const unsubscribeData = onTerminalData ( terminalId , ( data ) => {
305- if ( painted ) terminal . write ( data )
306- else buffered += data
333+ if ( writable ) {
334+ terminal . write ( data )
335+ return
336+ }
337+ if ( overflowed ) return
338+ banked += data
339+ if ( banked . length > MAX_BANKED_CHARS ) {
340+ banked = ''
341+ overflowed = true
342+ }
307343 } )
308344
309- void getTerminalScrollback ( terminalId )
310- . catch ( ( error : Error ) => {
311- // Logged rather than swallowed: a failure here is indistinguishable
312- // on screen from a shell that has printed nothing, so the panel comes
313- // up blank over a live terminal with no clue why. The usual cause is
314- // a desktop build older than this renderer, which has no scrollback
315- // channel to answer with.
316- logger . warn ( 'Could not read terminal scrollback; the panel will start empty' , {
317- terminalId,
318- error : error . message ,
345+ // Guarded because a snapshot is a round trip to the desktop app, and a tab
346+ // switched away from and back to during one would otherwise start a second
347+ // that resets and repaints over the first.
348+ let repainting = false
349+ const repaint = ( ) => {
350+ if ( repainting ) return
351+ repainting = true
352+ return getTerminalScrollback ( terminalId )
353+ . catch ( ( error : Error ) => {
354+ // Logged rather than swallowed: a failure here is indistinguishable
355+ // on screen from a shell that has printed nothing, so the panel comes
356+ // up blank over a live terminal with no clue why. The usual cause is
357+ // a desktop build older than this renderer, which has no scrollback
358+ // channel to answer with.
359+ logger . warn ( 'Could not read terminal scrollback; the panel will start empty' , {
360+ terminalId,
361+ error : error . message ,
362+ } )
363+ return ''
319364 } )
320- return ''
321- } )
322- . then ( ( scrollback ) => {
323- if ( disposed ) return
324- terminal . reset ( )
325- if ( scrollback ) terminal . write ( scrollback )
326- if ( buffered ) terminal . write ( buffered )
327- buffered = ''
328- painted = true
329- } )
365+ . then ( ( scrollback ) => {
366+ repainting = false
367+ if ( disposed ) return
368+ terminal . reset ( )
369+ if ( scrollback ) terminal . write ( scrollback )
370+ if ( banked ) terminal . write ( banked )
371+ banked = ''
372+ overflowed = false
373+ painted = true
374+ // Only now: bytes that landed mid-snapshot are in the bank, and
375+ // writing them straight through would have put them out of order.
376+ // Read live rather than assumed — the tab may have been switched
377+ // away from while this was in flight.
378+ writable = onscreenRef . current
379+ } )
380+ }
381+
382+ const show = ( ) => {
383+ if ( writable || disposed ) return
384+ if ( ! painted || overflowed ) {
385+ void repaint ( )
386+ return
387+ }
388+ if ( banked ) terminal . write ( banked )
389+ banked = ''
390+ writable = true
391+ }
392+
393+ const hide = ( ) => {
394+ writable = false
395+ }
396+
397+ showRef . current = show
398+ hideRef . current = hide
330399
331400 // Resizing is debounced, and deliberately not applied to hidden tabs.
332401 //
@@ -346,7 +415,7 @@ function TerminalView({
346415 // A zero-sized host means this terminal is off screen — either behind
347416 // another tab or with the whole panel hidden behind another resource —
348417 // not that it shrank. Fitting to that would resize the pty to nonsense.
349- if ( ! activeRef . current || host . clientWidth <= 0 || host . clientHeight <= 0 ) return
418+ if ( ! onscreenRef . current || host . clientWidth <= 0 || host . clientHeight <= 0 ) return
350419 try {
351420 fit . fit ( )
352421 } catch {
@@ -358,6 +427,8 @@ function TerminalView({
358427
359428 return ( ) => {
360429 disposed = true
430+ showRef . current = null
431+ hideRef . current = null
361432 reportTerminalFocused ( false )
362433 terminal . textarea ?. removeEventListener ( 'focus' , reportFocused )
363434 terminal . textarea ?. removeEventListener ( 'blur' , reportBlurred )
@@ -375,6 +446,12 @@ function TerminalView({
375446 // eslint-disable-next-line react-hooks/exhaustive-deps
376447 } , [ terminalId ] )
377448
449+ // Runs after the effect above, which is what installs these.
450+ useEffect ( ( ) => {
451+ if ( onscreen ) showRef . current ?.( )
452+ else hideRef . current ?.( )
453+ } , [ onscreen , terminalId ] )
454+
378455 useEffect ( ( ) => {
379456 const terminal = terminalRef . current
380457 if ( ! terminal ) return
@@ -391,15 +468,15 @@ function TerminalView({
391468 // back to the DOM. Handing the renderer to whichever tab is visible keeps
392469 // one context for the whole panel.
393470 useEffect ( ( ) => {
394- if ( ! active ) return
471+ if ( ! onscreen ) return
395472 const terminal = terminalRef . current
396473 if ( ! terminal ) return
397474 const disposeRenderer = attachWebglRenderer ( terminal )
398475 return ( ) => disposeRenderer ?.( )
399- } , [ active ] )
476+ } , [ onscreen ] )
400477
401478 useEffect ( ( ) => {
402- if ( ! active ) return
479+ if ( ! onscreen ) return
403480 // Measure after the browser has laid the newly shown terminal out.
404481 const frame = requestAnimationFrame ( ( ) => {
405482 const host = hostRef . current
@@ -412,7 +489,7 @@ function TerminalView({
412489 }
413490 } )
414491 return ( ) => cancelAnimationFrame ( frame )
415- } , [ active ] )
492+ } , [ onscreen ] )
416493
417494 const {
418495 isOpen : isMenuOpen ,
@@ -466,6 +543,10 @@ function TerminalView({
466543 } , [ ] )
467544
468545 // Scoped to the terminal that was right-clicked, not the active one.
546+ // Offered even for the only terminal: closing the last one restarts its
547+ // shell in place rather than removing the tab, so there is always something
548+ // for the action to do — and hiding it here while the tab strip's own close
549+ // stays available would just be the two menus disagreeing.
469550 const closeThisTerminal = useCallback ( ( ) => {
470551 void closeTerminal ( terminalId )
471552 } , [ terminalId ] )
@@ -493,18 +574,18 @@ function TerminalView({
493574 onPaste = { pasteClipboard }
494575 onClear = { clearScreen }
495576 onNewTab = { newTab }
496- { ... ( canClose ? { onCloseTerminal : closeThisTerminal } : { } ) }
577+ onCloseTerminal = { closeThisTerminal }
497578 />
498579 </ >
499580 )
500- }
581+ } )
501582
502583/**
503584 * The terminal panel. Unlike the browser panel, nothing native is overlaid
504585 * here: xterm.js renders each PTY's bytes in the DOM, so the panel is an
505586 * ordinary React subtree that happens to be a set of working terminals.
506587 */
507- export function TerminalSession ( ) {
588+ export function TerminalSession ( { visible } : { visible : boolean } ) {
508589 const { tabs, activeTerminalId } = useCopilotTerminalStore ( ( state ) => state . tabs )
509590 const settledCommands = useSettledCommands ( tabs )
510591 const { removeResource } = useMothershipResources ( )
@@ -644,7 +725,7 @@ export function TerminalSession() {
644725 key = { tab . terminalId }
645726 terminalId = { tab . terminalId }
646727 active = { tab . terminalId === activeTerminalId }
647- canClose = { tabs . length > 1 }
728+ visible = { visible }
648729 />
649730 ) ) }
650731 { startError && (
0 commit comments