Skip to content

Commit 4ba5456

Browse files
committed
fix(desktop): avoid transient terminal tab labels
1 parent 8e92133 commit 4ba5456

2 files changed

Lines changed: 80 additions & 14 deletions

File tree

  • apps
    • desktop/src/main/terminal
    • sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session

apps/desktop/src/main/terminal/session.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,12 @@ export class TerminalSession {
438438
const directory = this.cwd ? (this.cwd.split('/').filter(Boolean).pop() ?? '/') : null
439439
return {
440440
terminalId: this.terminalId,
441-
title: this.foregroundCommand?.trim() || directory || 'Terminal',
441+
// The directory, always: whether to show the running command instead is
442+
// a presentation choice, and the panel makes it (it holds a label back
443+
// until a command has run long enough to be worth naming). Reporting the
444+
// command here would also mean gating `running` to match, and that is
445+
// data the agent reads — it must stay true the instant a command starts.
446+
title: directory || 'Terminal',
442447
cwd: this.cwd,
443448
running: this.foregroundCommand,
444449
interactive: this.altScreen,

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { WebglAddon } from '@xterm/addon-webgl'
1818
import { Terminal } from '@xterm/xterm'
1919
import { useTheme } from 'next-themes'
2020
import '@xterm/xterm/css/xterm.css'
21-
import { MAX_TERMINALS } from '@sim/terminal-protocol'
21+
import { MAX_TERMINALS, type TerminalTabState } from '@sim/terminal-protocol'
2222
import { TERMINAL_SESSION_RESOURCE_ID } from '@/lib/copilot/resources/types'
2323
import {
2424
closeTerminal,
@@ -40,6 +40,61 @@ import { useCopilotTerminalStore } from '@/stores/copilot-terminal/store'
4040

4141
const 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
*/
428483
export 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

Comments
 (0)