@@ -18,7 +18,7 @@ import { WebglAddon } from '@xterm/addon-webgl'
1818import { Terminal } from '@xterm/xterm'
1919import { useTheme } from 'next-themes'
2020import '@xterm/xterm/css/xterm.css'
21- import { MAX_TERMINALS } from '@sim/terminal-protocol'
21+ import { MAX_TERMINALS , type TerminalTabState } from '@sim/terminal-protocol'
2222import { TERMINAL_SESSION_RESOURCE_ID } from '@/lib/copilot/resources/types'
2323import {
2424 closeTerminal ,
@@ -40,6 +40,61 @@ import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store'
4040
4141const logger = createLogger ( 'TerminalSession' )
4242
43+ /**
44+ * How long a command must run before the tab names it.
45+ *
46+ * A tab that says what it is busy with is useful for a build you left running
47+ * in the background, and pure noise for `ls` — swapping the label and spinning
48+ * the icon for thirty milliseconds reads as a glitch. Waiting a beat keeps the
49+ * signal and drops the flicker.
50+ */
51+ const COMMAND_SETTLE_MS = 1_000
52+
53+ function sameIds ( a : ReadonlySet < string > , b : ReadonlySet < string > ) : boolean {
54+ return a . size === b . size && [ ...a ] . every ( ( id ) => b . has ( id ) )
55+ }
56+
57+ /**
58+ * The terminals whose command has been running long enough to show. Returns a
59+ * stable set, so a tab strip that would render identically does not re-render.
60+ */
61+ function useSettledCommands ( tabs : TerminalTabState [ ] ) : ReadonlySet < string > {
62+ const [ settled , setSettled ] = useState < ReadonlySet < string > > ( ( ) => new Set ( ) )
63+ const startedAt = useRef ( new Map < string , number > ( ) )
64+
65+ useEffect ( ( ) => {
66+ const started = startedAt . current
67+ const live = new Set ( tabs . map ( ( tab ) => tab . terminalId ) )
68+ for ( const id of [ ...started . keys ( ) ] ) {
69+ if ( ! live . has ( id ) ) started . delete ( id )
70+ }
71+ for ( const tab of tabs ) {
72+ if ( ! tab . running ) started . delete ( tab . terminalId )
73+ else if ( ! started . has ( tab . terminalId ) ) started . set ( tab . terminalId , Date . now ( ) )
74+ }
75+
76+ const recompute = ( ) => {
77+ const now = Date . now ( )
78+ const next = new Set < string > ( )
79+ let soonest = Number . POSITIVE_INFINITY
80+ for ( const [ id , at ] of started ) {
81+ const elapsed = now - at
82+ if ( elapsed >= COMMAND_SETTLE_MS ) next . add ( id )
83+ else soonest = Math . min ( soonest , COMMAND_SETTLE_MS - elapsed )
84+ }
85+ setSettled ( ( current ) => ( sameIds ( current , next ) ? current : next ) )
86+ return soonest
87+ }
88+
89+ const soonest = recompute ( )
90+ if ( ! Number . isFinite ( soonest ) ) return
91+ const timer = setTimeout ( recompute , Math . max ( 0 , soonest ) )
92+ return ( ) => clearTimeout ( timer )
93+ } , [ tabs ] )
94+
95+ return settled
96+ }
97+
4398/**
4499 * How long the panel must stop changing size before the PTY is told about it.
45100 * Long enough to cover a divider drag, short enough that a deliberate resize
@@ -427,6 +482,7 @@ function TerminalView({
427482 */
428483export function TerminalSession ( ) {
429484 const { tabs, activeTerminalId } = useCopilotTerminalStore ( ( state ) => state . tabs )
485+ const settledCommands = useSettledCommands ( tabs )
430486 const { removeResource } = useMothershipResources ( )
431487 const [ startError , setStartError ] = useState < string | null > ( null )
432488
@@ -456,18 +512,23 @@ export function TerminalSession() {
456512 // It is the only sign that something is running in a tab nobody is looking at.
457513 const items = useMemo < TabStripItem [ ] > (
458514 ( ) =>
459- tabs . map ( ( tab ) => ( {
460- id : tab . terminalId ,
461- title : tab . title ,
462- icon :
463- tab . running && ! tab . interactive ? (
464- < Loader className = 'size-[12px] shrink-0 animate-spin text-[var(--text-icon)]' />
465- ) : (
466- < TerminalWindow className = 'size-[12px] shrink-0 text-[var(--text-icon)]' />
467- ) ,
468- active : tab . terminalId === activeTerminalId ,
469- } ) ) ,
470- [ tabs , activeTerminalId ]
515+ tabs . map ( ( tab ) => {
516+ // A command only reaches the tab once it has run long enough to be
517+ // worth naming; until then the tab stays as its directory.
518+ const naming = settledCommands . has ( tab . terminalId ) ? tab . running : null
519+ return {
520+ id : tab . terminalId ,
521+ title : naming ?? tab . title ,
522+ icon :
523+ naming && ! tab . interactive ? (
524+ < Loader className = 'size-[12px] shrink-0 animate-spin text-[var(--text-icon)]' />
525+ ) : (
526+ < TerminalWindow className = 'size-[12px] shrink-0 text-[var(--text-icon)]' />
527+ ) ,
528+ active : tab . terminalId === activeTerminalId ,
529+ }
530+ } ) ,
531+ [ tabs , activeTerminalId , settledCommands ]
471532 )
472533
473534 const [ contextTerminalId , setContextTerminalId ] = useState < string | null > ( null )
0 commit comments