From 4033b71d590d0c7ec17f0aa3233234c7fceafd0c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 11 Aug 2026 20:17:39 -0700 Subject: [PATCH 1/2] Update tab spawning --- .../src/main/browser-agent/session.test.ts | 19 ++-- .../desktop/src/main/browser-agent/session.ts | 26 +---- apps/desktop/src/main/terminal/index.ts | 21 +--- apps/desktop/src/main/terminal/registry.ts | 17 +-- .../desktop/src/main/terminal/service.test.ts | 7 +- .../terminal-session/terminal-session.tsx | 14 ++- .../app/workspace/[workspaceId]/home/home.tsx | 9 +- packages/terminal-protocol/package.json | 8 +- packages/terminal-protocol/src/index.test.ts | 51 +++++++++ packages/terminal-protocol/src/index.ts | 101 ++++++++++++++++++ packages/terminal-protocol/vitest.config.ts | 9 ++ 11 files changed, 208 insertions(+), 74 deletions(-) create mode 100644 packages/terminal-protocol/src/index.test.ts create mode 100644 packages/terminal-protocol/vitest.config.ts diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index 11d06defa59..5e96931f06f 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -1099,26 +1099,27 @@ describe('browser-agent session', () => { expect(session.getTabsState().activeTabId).toBe(visible.id) }) - it('moves the next agent action to a background copy after the user claims its tab', () => { + it('keeps agent actions on a tab after the user selects it', () => { const visible = session.ensureTab() session.switchTab(visible.id) const agent = session.ensureAutomationTab() - expect(agent.id).not.toBe(visible.id) + expect(agent.id).toBe(visible.id) expect(session.getTabsState().activeTabId).toBe(visible.id) - expect(session.getTabsState().automationTabId).toBe(agent.id) + expect(session.getTabsState().automationTabId).toBe(visible.id) + expect(session.listTabs()).toHaveLength(1) }) - it('does not create a background tab until the agent resumes after a toolbar action', () => { + it('keeps agent actions on a tab after a toolbar action claims it', () => { const visible = session.ensureTab() expect(session.claimActiveTabForUser()?.id).toBe(visible.id) expect(session.listTabs()).toHaveLength(1) const agent = session.ensureAutomationTab() - expect(agent.id).not.toBe(visible.id) - expect(session.listTabs()).toHaveLength(2) + expect(agent.id).toBe(visible.id) + expect(session.listTabs()).toHaveLength(1) }) it('does not treat a passive native focus event as user takeover', () => { @@ -1134,7 +1135,7 @@ describe('browser-agent session', () => { expect(session.listTabs()).toHaveLength(1) }) - it('moves automation to a background copy after real page interaction', () => { + it('keeps automation on the same tab after real page interaction', () => { const visible = session.ensureTab() const contents = (visible.view as unknown as MockView).webContents const beforeMouse = contents.on.mock.calls.find( @@ -1143,9 +1144,9 @@ describe('browser-agent session', () => { beforeMouse?.({}, { type: 'mouseDown' }) - expect(session.ensureAutomationTab().id).not.toBe(visible.id) + expect(session.ensureAutomationTab().id).toBe(visible.id) expect(session.getTabsState().activeTabId).toBe(visible.id) - expect(session.listTabs()).toHaveLength(2) + expect(session.listTabs()).toHaveLength(1) }) it('clears automation indicators instead of moving them when their tab closes', () => { diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 83f260440e0..3d1b1589e14 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -1489,21 +1489,7 @@ function addTabInternal({ return tab } -/** Forks the agent cursor before the user takes over the same live page. */ -function yieldAutomationTabToUser(tab: AgentTab): void { - if (currentScope.automationTabId !== tab.id) return - const replacement = addTabInternal({ activate: false, notify: false }) - currentScope.automationTabId = replacement.id - const url = sanitizeRestorableUrl(tabUrl(tab)) - if (url && url !== 'about:blank') { - void replacement.view.webContents.loadURL(url).catch(() => {}) - } - applyActiveTabThrottling() - persistBrowserSession() - events?.onTabsChanged() -} - -/** Marks the visible page as user-owned; automation forks only if it acts again. */ +/** Marks the visible page as user-selected without blocking automation on it. */ export function claimActiveTabForUser(): AgentTab | null { const tab = activeTab() if (!tab) return null @@ -1590,10 +1576,6 @@ export function addAutomationTab(): AgentTab { export function ensureAutomationTab(): AgentTab { restoreBrowserSession() let tab = automationTab() - if (tab?.id === currentScope.activeTabId && currentScope.visibleTabUserSelected) { - yieldAutomationTabToUser(tab) - tab = automationTab() - } if (tab) return tab tab = activeTab() if (tab) { @@ -1608,11 +1590,7 @@ export function ensureAutomationTab(): AgentTab { /** Current agent target without creating one. */ export function requireAutomationTab(): AgentTab { restoreBrowserSession() - let tab = automationTab() - if (tab?.id === currentScope.activeTabId && currentScope.visibleTabUserSelected) { - yieldAutomationTabToUser(tab) - tab = automationTab() - } + const tab = automationTab() if (!tab) { throw new SessionError('No page is open yet — call browser_navigate or browser_open_tab first.') } diff --git a/apps/desktop/src/main/terminal/index.ts b/apps/desktop/src/main/terminal/index.ts index 028282ba312..3ac6530f1ed 100644 --- a/apps/desktop/src/main/terminal/index.ts +++ b/apps/desktop/src/main/terminal/index.ts @@ -1133,19 +1133,6 @@ export class TerminalService { private requireSession(args: TerminalToolArgs): TerminalSession { const requested = typeof args.terminalId === 'string' ? args.terminalId : null if (requested) { - if (requested === this.activeId && this.activeTerminalUserSelected) { - const claimed = this.sessions.get(requested) - if (claimed?.alive && requested === this.agentActiveId) { - return this.spawn(claimed.currentCwd ?? this.startingCwd(), claimed.cols, claimed.rows, { - activateVisible: false, - activateAgent: true, - }) - } - throw new TerminalError( - 'INVALID_REQUEST', - 'That terminal is currently being used by the user. Open or switch to another agent terminal first.' - ) - } const session = this.sessions.get(requested) if (!session?.alive) { throw new TerminalError('NO_SUCH_TERMINAL', unknownTerminal(requested)) @@ -1153,13 +1140,7 @@ export class TerminalService { return session } - let active = this.agentActiveId ? this.sessions.get(this.agentActiveId) : null - if (active?.terminalId === this.activeId && this.activeTerminalUserSelected) { - active = this.spawn(active.currentCwd ?? this.startingCwd(), active.cols, active.rows, { - activateVisible: false, - activateAgent: true, - }) - } + const active = this.agentActiveId ? this.sessions.get(this.agentActiveId) : null if (active?.alive) return active const spawned = this.spawn(this.startingCwd(), 80, 24, { diff --git a/apps/desktop/src/main/terminal/registry.ts b/apps/desktop/src/main/terminal/registry.ts index 39951cd436c..673b29beb99 100644 --- a/apps/desktop/src/main/terminal/registry.ts +++ b/apps/desktop/src/main/terminal/registry.ts @@ -1,11 +1,12 @@ import { statSync } from 'node:fs' -import type { - TerminalCommandEvent, - TerminalOperation, - TerminalStartOptions, - TerminalTabsState, - TerminalToolArgs, - TerminalToolResponse, +import { + describeRunningCommand, + type TerminalCommandEvent, + type TerminalOperation, + type TerminalStartOptions, + type TerminalTabsState, + type TerminalToolArgs, + type TerminalToolResponse, } from '@sim/terminal-protocol' import { type BrowserWindow, dialog, type WebContents } from 'electron' import type { TerminalSessionSnapshot } from '@/main/desktop-chat-session-store' @@ -239,7 +240,7 @@ export class TerminalRegistry { dialog.showMessageBoxSync(ownerWindow, { type: 'warning', title: 'Close Running Terminal?', - message: `${running} is still running.`, + message: `${describeRunningCommand(running)} is still running.`, detail: 'Closing this terminal will stop the process.', buttons: ['Close Terminal', 'Cancel'], defaultId: 1, diff --git a/apps/desktop/src/main/terminal/service.test.ts b/apps/desktop/src/main/terminal/service.test.ts index 019ee518890..cef327baa5e 100644 --- a/apps/desktop/src/main/terminal/service.test.ts +++ b/apps/desktop/src/main/terminal/service.test.ts @@ -232,7 +232,7 @@ describe('focus-gated shortcuts', () => { expect(runShortcut(terminal, 'new-tab', renderer.window)).toBe(false) }) - it('moves agent work to a background shell after the user claims the visible terminal', async () => { + it('keeps agent work in the visible terminal after the user focuses it', async () => { const terminal = service() const started = terminal.start({ cols: 80, rows: 24 }) const visibleId = started.activeTerminalId as string @@ -243,9 +243,10 @@ describe('focus-gated shortcuts', () => { const result = response.result as { terminalId: string } | undefined expect(response.ok).toBe(true) - expect(result?.terminalId).not.toBe(visibleId) + expect(result?.terminalId).toBe(visibleId) + expect(terminal.getTabs().tabs).toHaveLength(1) expect(terminal.getTabs().activeTerminalId).toBe(visibleId) - expect(terminal.getTabs().agentActiveTerminalId).toBe(result?.terminalId) + expect(terminal.getTabs().agentActiveTerminalId).toBe(visibleId) }) it('keeps a running terminal open when close confirmation is declined', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx index 5a32639a574..39fefe98e06 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx @@ -35,7 +35,11 @@ import { WebglAddon } from '@xterm/addon-webgl' import { type IBufferRange, Terminal } from '@xterm/xterm' import { useTheme } from 'next-themes' import '@xterm/xterm/css/xterm.css' -import type { TerminalTabState, TerminalTabsState } from '@sim/terminal-protocol' +import { + describeRunningCommand, + type TerminalTabState, + type TerminalTabsState, +} from '@sim/terminal-protocol' import { SIM_RESOURCE_DRAG_TYPE } from '@/lib/copilot/resource-types' import { TERMINAL_SESSION_RESOURCE_ID } from '@/lib/copilot/resources/types' import { getDesktopBridge } from '@/lib/desktop' @@ -686,7 +690,9 @@ const TerminalView = memo(function TerminalView({ const closeThisTerminal = useCallback(() => { if ( running && - !window.confirm(`${running} is still running. Close this terminal and stop it?`) + !window.confirm( + `${describeRunningCommand(running)} is still running. Close this terminal and stop it?` + ) ) { return } @@ -978,7 +984,9 @@ export function TerminalSession({ visible, scopeId }: TerminalSessionProps) { const tab = tabs.find((entry) => entry.terminalId === terminalId) if ( tab?.running && - !window.confirm(`${tab.running} is still running. Close this terminal and stop it?`) + !window.confirm( + `${describeRunningCommand(tab.running)} is still running. Close this terminal and stop it?` + ) ) { return } diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index b274c12b864..dd5d02da37d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -212,14 +212,13 @@ export function Home({ chatId, userName, userId, tableViewsEnabled }: HomeProps) activeResourceParamRef.current = activeResourceParam function handleResourceEvent(resourceId: string) { - // Agent work should always make the resource surface available. Expanding - // the panel is independent from selecting a resource: once the user has - // chosen another resource, the agent may work in the background without - // taking that selection away. + // Agent work should always make the resource surface available, but it + // must never replace an existing selection. Activity in another resource + // stays in the background and gets an attention marker instead. if (isResourceCollapsedRef.current) setIsResourceCollapsed(false) const activeResourceId = activeResourceParamRef.current - if (userOwnsResourceViewRef.current && activeResourceId && activeResourceId !== resourceId) { + if (activeResourceId && activeResourceId !== resourceId) { setResourceActivityIds((current) => new Set(current).add(resourceId)) return } diff --git a/packages/terminal-protocol/package.json b/packages/terminal-protocol/package.json index 2d27bc7bc15..c48e61e9965 100644 --- a/packages/terminal-protocol/package.json +++ b/packages/terminal-protocol/package.json @@ -17,15 +17,19 @@ }, "scripts": { "type-check": "tsc --noEmit", + "test": "vitest run", "lint": "biome check --write --unsafe .", "lint:check": "biome check .", "format": "biome format --write .", "format:check": "biome format ." }, - "dependencies": {}, + "dependencies": { + "@sim/utils": "workspace:*" + }, "devDependencies": { "@sim/tsconfig": "workspace:*", "@types/node": "24.2.1", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vitest": "^4.1.0" } } diff --git a/packages/terminal-protocol/src/index.test.ts b/packages/terminal-protocol/src/index.test.ts new file mode 100644 index 00000000000..b273ff7ba9f --- /dev/null +++ b/packages/terminal-protocol/src/index.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { describeRunningCommand } from './index' + +describe('describeRunningCommand', () => { + it('names the program of a bare command', () => { + expect(describeRunningCommand('bun run dev')).toBe('bun') + }) + + it('takes the basename of an absolute program path', () => { + expect(describeRunningCommand('/opt/homebrew/bin/claude --resume')).toBe('claude') + }) + + it('drops the cd/export preamble an agent-launched terminal carries', () => { + const command = + 'cd /Users/someone/Desktop/sim && export PATH="/opt/homebrew/bin:$PATH" && claude "Fix this runtime error in this repo: file upload fails"' + expect(describeRunningCommand(command)).toBe('claude') + }) + + it('ignores separators inside quotes', () => { + expect(describeRunningCommand('claude "build && test; deploy | ship"')).toBe('claude') + expect(describeRunningCommand("claude 'a && b'")).toBe('claude') + }) + + it('honors backslash escapes only where the shell does', () => { + // Escaped quote keeps the double-quoted run open, so `&&` stays quoted. + expect(describeRunningCommand('claude "say \\" && rm -rf /"')).toBe('claude') + // A backslash is literal inside single quotes, so that run closes and the + // following `&&` is a real separator. + expect(describeRunningCommand("claude 'a\\' && jest")).toBe('jest') + }) + + it('skips environment assignments and wrapper words', () => { + expect(describeRunningCommand('NODE_ENV=test bun test')).toBe('bun') + expect(describeRunningCommand('sudo /usr/bin/docker compose up')).toBe('docker') + expect(describeRunningCommand('env FOO=1 nohup python train.py')).toBe('python') + }) + + it('reads the last stage of a pipeline', () => { + expect(describeRunningCommand('cat log.txt | grep error | less')).toBe('less') + }) + + it('bounds a label it cannot reduce to a program name', () => { + const label = describeRunningCommand(`--${'x'.repeat(200)}`) + expect(label.length).toBeLessThanOrEqual(33) + expect(label.endsWith('…')).toBe(true) + }) + + it('describes an unannounced command rather than returning nothing', () => { + expect(describeRunningCommand(' ')).toBe('a command') + }) +}) diff --git a/packages/terminal-protocol/src/index.ts b/packages/terminal-protocol/src/index.ts index e1fcc101bee..01f0a36e20f 100644 --- a/packages/terminal-protocol/src/index.ts +++ b/packages/terminal-protocol/src/index.ts @@ -19,6 +19,8 @@ * the source of truth for how those calls travel to the desktop main process. */ +import { truncate } from '@sim/utils/string' + /** The single tool the model calls; what it does is in `operation`. */ export const TERMINAL_TOOL_NAME = 'terminal' @@ -292,6 +294,105 @@ export interface TerminalTabState { tmuxSession?: string | null } +/** Longest program name {@link describeRunningCommand} will return. */ +const MAX_RUNNING_COMMAND_LABEL = 32 + +/** + * Shell words that precede the program rather than being it, so a label reads + * `claude` and not `env` or `sudo`. + */ +const COMMAND_PREFIX_WORDS = new Set([ + 'command', + 'doas', + 'env', + 'exec', + 'nice', + 'nohup', + 'sudo', + 'time', +]) + +/** `NAME=value`, the other thing that can sit in front of the program. */ +const ENVIRONMENT_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/ + +/** + * The last command in a shell line, ignoring separators inside quotes. A + * quote-blind split would cut `claude "a && b"` in half and report `b"` as the + * program. + */ +function lastCommandSegment(command: string): string { + let start = 0 + let quote: "'" | '"' | null = null + for (let index = 0; index < command.length; index++) { + const char = command[index] + if (quote) { + // Only double quotes honor backslash escapes; inside single quotes a + // backslash is a literal character and cannot hide the closing quote. + if (char === '\\' && quote === '"') index++ + else if (char === quote) quote = null + continue + } + if (char === "'" || char === '"') { + quote = char + continue + } + if (char === '\\') { + index++ + continue + } + if (char === ';' || char === '&' || char === '|') { + if (command[index + 1] === char) index++ + start = index + 1 + } + } + return command.slice(start).trim() +} + +/** + * A short name for whatever is holding a terminal's foreground. + * + * `running` is the literal line the shell was given, and an agent-launched one + * runs long: `cd && export PATH= && claude ""` + * is a single command several hundred characters wide. That is the right thing + * to hand an agent and the wrong thing to put in a sentence — a confirmation + * built around it stops being a question and becomes a wall of shell. This + * keeps the part a person recognizes, the program they are waiting on, and + * drops the environment preamble around it. + * + * Best-effort by construction: the input is a shell line, not a parsed argv, + * so a command this cannot read falls back to the line itself, bounded. Use it + * for prose about a terminal, never to decide anything. + * + * @example + * describeRunningCommand('cd /repo && export PATH=/bin && claude "fix it"') // 'claude' + * describeRunningCommand('sudo /usr/bin/docker compose up') // 'docker' + */ +export function describeRunningCommand(running: string): string { + const line = running.trim() + if (!line) return 'a command' + + const segment = lastCommandSegment(line) || line + const program = segment + .split(/\s+/) + .filter(Boolean) + .find( + (word) => + !ENVIRONMENT_ASSIGNMENT.test(word) && + !COMMAND_PREFIX_WORDS.has(word) && + !word.startsWith('-') + ) + + const label = + (program + ? (program + .replace(/^['"]|['"]$/g, '') + .split('/') + .filter(Boolean) + .pop() ?? '') + : '') || segment + return truncate(label, MAX_RUNNING_COMMAND_LABEL, '…') +} + /** One tmux pane, as reported by the `panes` operation. */ export interface TerminalPaneState { /** tmux target (`session:window.pane`), usable as the `pane` argument. */ diff --git a/packages/terminal-protocol/vitest.config.ts b/packages/terminal-protocol/vitest.config.ts new file mode 100644 index 00000000000..471771e48fe --- /dev/null +++ b/packages/terminal-protocol/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + include: ['src/**/*.test.ts'], + }, +}) From 048c1b16c11537797d7c62f9b6e9ec74353ff8ca Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 11 Aug 2026 20:24:11 -0700 Subject: [PATCH 2/2] update import --- packages/terminal-protocol/src/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/terminal-protocol/src/index.test.ts b/packages/terminal-protocol/src/index.test.ts index b273ff7ba9f..16961fccbe6 100644 --- a/packages/terminal-protocol/src/index.test.ts +++ b/packages/terminal-protocol/src/index.test.ts @@ -1,5 +1,5 @@ +import { describeRunningCommand } from '@sim/terminal-protocol' import { describe, expect, it } from 'vitest' -import { describeRunningCommand } from './index' describe('describeRunningCommand', () => { it('names the program of a bare command', () => {