fix(terminal): a slow window no longer makes orca terminal create fail and then duplicate (#28158)

* fix(terminal): start CLI-created workspace terminals in main so a slow window cannot duplicate them

A focused or interactive-agent `orca terminal create` asked the window to make the
terminal and reported failure after 10 s, while the window still made it later; a
retry then made a second one. Main now starts every terminal that names a workspace
and shows it in the window best-effort, as it already did for every other create.

* fix(terminal): start a main-created terminal at its worktree's measured pane size

An unfocused CLI agent opens as an inactive tab that is not resized until the user
opens it, so starting it at 120x40 left its output wrapped to 120 columns. Start it at
the size of the pane active in the worktree's active group, where the new tab opens;
fall back to 120x40 only when that worktree has no measured pane.

* fix(cli): keep sending rendererBacked for hosts before v1.4.211

Hosts without #22384 leave a background-started agent's startup queries unanswered,
so a new CLI talking to an old, still-running host must keep routing interactive agents
to the window. Newer hosts ignore the flag.

* fix(terminal): start host-created terminals at the measured size of their tab group

The window publishes, on its graph sync to main only, the grid an unsplit terminal
would get in each shown tab group, computed by one pure function that mirrors
FitAddon.proposeDimensions and is pinned against it; a ResizeObserver on each group
body republishes when that grid changes. Main starts a new terminal at the size of the
group it opens in (the named group, else the active one) and falls back to 120x40 only
when no window measured it. This replaces the active-pane lookup, which mis-sized split
groups and could read a phone's fitted size.

* fix(terminal): measure group sizes from an untitled default-font pane and use the live active group

A pane with a title or banner pushes its xterm host down, which skewed every group's
row count; skip it like a zoomed pane. Size from the window's latest published active
group, which is where its reveal places the tab, before the saved session's.

* fix(terminal): size a CLI agent in a workspace the window has not shown

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(terminal): learn the single-group inset only from bodies shown in the content area

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: rerun PR checks against main with #28297

Co-Authored-By: Claude <noreply@anthropic.com>

* chore: rerun PR checks against main with #28301

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-13 15:08:45 -04:00
committed by GitHub
co-authored by Claude
parent 32fc38d575
commit 24bda717f4
22 changed files with 754 additions and 221 deletions
+3 -3
View File
@@ -193,11 +193,11 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
command,
...(shell !== undefined ? { shell } : {}),
title: getOptionalStringFlag(flags, 'title'),
// Why: interactive local agent TUIs need the renderer-backed terminal
// path for browser-side features, but CLI creates must stay backgrounded
// unless the caller explicitly asks for focus.
focus,
...(focus ? { presentation: 'focused' } : {}),
// Compat: hosts before v1.4.211 (#22384) leave a background-started agent's startup queries
// unanswered, so they need this to start it in the window; newer hosts ignore it. Delete
// once the minimum supported host version is v1.4.211 or later.
...(useRendererBackedInteractiveTerminal ? { rendererBacked: true, activate: focus } : {})
})
printResult(result, json, formatTerminalCreate)
@@ -2,35 +2,21 @@
import * as dependencies from './orca-runtime-create-terminal-dependencies'
import type { OrcaRuntimeWithCreateTerminal } from './orca-runtime-create-terminal'
import type { RuntimeTerminalPresentation } from '../../shared/runtime-types'
import type { Worktree } from '../../shared/worktree/types'
// A create with no workspace lands in whatever the window is showing, so only the window can make it.
export async function createDesktopTerminal(
runtime: OrcaRuntimeWithCreateTerminal,
worktreeSelector: string | undefined,
opts: dependencies.TerminalCreateOptions,
presentation: RuntimeTerminalPresentation | undefined,
rendererWindow: Electron.BrowserWindow | null,
createdWorktree?: Worktree
presentation: RuntimeTerminalPresentation | undefined
): Promise<dependencies.RuntimeTerminalCreate> {
runtime.assertGraphReady()
const win = rendererWindow ?? runtime.getAuthoritativeWindow()
const workspace = worktreeSelector
? await runtime.resolveTerminalWorkspaceLaunchScope(worktreeSelector, createdWorktree)
: null
const launchOpts = workspace
? await runtime.resolveAgentTerminalCreateOptions(workspace, opts)
: opts
// `resolveAgentTerminalCreateOptions` refuses an unapplicable shell, and it only runs with a
// workspace; a worktree-less create has no execution host to apply one to either.
if (!workspace && opts.shellOverride) {
// A worktree-less create has no execution host to resolve the shell on.
if (opts.shellOverride) {
throw new Error(
`--shell ${opts.shellOverride} needs a workspace, because the shell is resolved on the workspace's execution host. No terminal was created.`
)
}
const worktreeId = workspace?.id
const cwd = workspace
? runtime.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd)
: launchOpts.cwd
runtime.assertGraphReady()
const win = runtime.getAuthoritativeWindow()
const requestId = dependencies.randomUUID()
const reply = await new Promise<{ tabId: string; title: string }>((resolve, reject) => {
const timer = setTimeout(() => {
@@ -54,26 +40,22 @@ export async function createDesktopTerminal(
if (response.error) {
reject(new Error(response.error))
} else {
resolve({ tabId: response.tabId!, title: response.title ?? launchOpts.title ?? '' })
resolve({ tabId: response.tabId!, title: response.title ?? opts.title ?? '' })
}
}
dependencies.getRuntimeDesktopSurface().onIpc('terminal:tabCreateReply', handler)
win.webContents.send('terminal:requestTabCreate', {
requestId,
worktreeId,
command: launchOpts.command,
cwd,
...(launchOpts.env ? { env: launchOpts.env } : {}),
...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}),
...(launchOpts.resumeProviderSession
? { resumeProviderSession: launchOpts.resumeProviderSession }
: {}),
...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}),
...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}),
...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}),
startupCommandDelivery: launchOpts.startupCommandDelivery,
...(launchOpts.shellOverride ? { shellOverride: launchOpts.shellOverride } : {}),
title: launchOpts.title,
command: opts.command,
cwd: opts.cwd,
...(opts.env ? { env: opts.env } : {}),
...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}),
...(opts.resumeProviderSession ? { resumeProviderSession: opts.resumeProviderSession } : {}),
...(opts.launchToken ? { launchToken: opts.launchToken } : {}),
...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}),
...(opts.viewMode ? { viewMode: opts.viewMode } : {}),
startupCommandDelivery: opts.startupCommandDelivery,
title: opts.title,
activate: presentation === 'focused',
...(presentation ? { presentation } : {}),
...dependencies.ownerSurfacing(opts.surfaceOwner !== false)
@@ -83,7 +65,7 @@ export async function createDesktopTerminal(
return {
handle,
tabId: reply.tabId,
worktreeId: worktreeId ?? '',
worktreeId: '',
title: reply.title,
...runtime.getPtyExecutionHostMetadata(runtime.handles.get(handle)?.ptyId ?? null),
surface: 'visible'
@@ -22,15 +22,9 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
dependencies.setPairedViewerColors(callerColors)
}
const presentation = dependencies.resolveTerminalPresentation(opts)
const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true
const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow()
const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null
const shouldCreateInBackground =
worktreeSelector !== undefined &&
(Boolean(opts.agentSessionClaim || opts.requireFreshPane) ||
(!requiresRendererFocus && opts.rendererBacked !== true) ||
availableAuthoritativeWindow === null)
if (shouldCreateInBackground) {
// Why: this process starts every terminal it can place, so a slow window only delays showing
// it; a window-made create that timed out here still ran later, and a retry duplicated it.
if (worktreeSelector !== undefined) {
if (!this.ptyController?.spawn) {
throw new Error('runtime_unavailable')
}
@@ -39,6 +33,23 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
const reportPtySpawnCommitted = createPtySpawnCommitReporter(launchOpts.onPtySpawnCommitted)
const cwd =
this.resolveWorkspaceTerminalStartupCwd(workspace, launchOpts.cwd) ?? workspace.path
const session = this.getWorkspaceSessionForWorktree(workspace.id)
// The window's latest published active group is where its reveal places the tab.
const groupId =
launchOpts.targetGroupId ??
this.mobileSessionTabsByWorktree.get(workspace.id)?.activeGroupId ??
session?.activeGroupIdByWorktree?.[workspace.id]
// A workspace not shown since launch has no measured group; with at most one group, its tab
// opens in a lone group, which every workspace sizes alike.
const singleGroupSize =
(session?.tabGroups?.[workspace.id]?.length ?? 0) <= 1
? this.singleGroupWorkspaceGridSize
: undefined
// Why 120x40: no window measured the group the tab opens in: none attached (serve, orcad), a
// multi-group workspace not shown since launch, or no pane at the default font mounted yet.
// #1707's original size.
const initialSize = (groupId && this.terminalGroupGridSizes?.[groupId]) ||
singleGroupSize || { cols: 120, rows: 40 }
let preAllocatedHandle =
launchOpts.preAllocatedHandle ?? this.createPreAllocatedTerminalHandle()
let { tabId, leafId, paneKey } = dependencies.allocateTerminalPaneIdentity(launchOpts)
@@ -61,8 +72,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
throw new Error('client_disconnected')
}
const adoptedBeforeLaunch = await this.ptyController.adoptStablePane?.({
cols: 120,
rows: 40,
...initialSize,
cwd,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
@@ -124,8 +134,7 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
const { launchAgent } = launchOpts
launchOpts.onPtySpawnDispatched?.({ launchConfig: effectiveLaunchConfig, launchAgent })
result = await dependencies.spawnInAdmittedPane(this.store, this.ptyController, {
cols: 120,
rows: 40,
...initialSize,
cwd,
command: sequencedStartupCommand
? launchOpts.command
@@ -294,13 +303,6 @@ export class OrcaRuntimeWithCreateTerminal extends OrcaRuntimeWithTerminalCreate
}
// The renderer owns this spawn, so this process cannot see when it is requested.
opts.onPtySpawnDispatched?.()
return createDesktopTerminal(
this,
worktreeSelector,
opts,
presentation,
rendererWindow,
created
)
return createDesktopTerminal(this, opts, presentation)
}
}
@@ -70,6 +70,16 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow
this.rendererGeneration = null
}
if (graph.terminalGroupGridSizes) {
// Merged, not replaced: a group in a worktree the window is not showing keeps its last size.
this.terminalGroupGridSizes = {
...this.terminalGroupGridSizes,
...graph.terminalGroupGridSizes
}
}
if (graph.singleGroupWorkspaceGridSize) {
this.singleGroupWorkspaceGridSize = graph.singleGroupWorkspaceGridSize
}
const graphWasReady = this.graphStatus === 'ready'
const previousTabs = this.tabs
const previousLeaves = this.leaves
@@ -706,9 +706,9 @@ describe('OrcaRuntimeService', () => {
})
})
await expect(
runtime.createTerminal(undefined, { startupAgent: 'cursor', rendererBacked: true })
).rejects.toThrow(/requires a workspace selector/)
await expect(runtime.createTerminal(undefined, { startupAgent: 'cursor' })).rejects.toThrow(
/requires a workspace selector/
)
})
// Why: folder workspaces have no repo, so command sniffing skipped them entirely
@@ -5,8 +5,6 @@ import {
FLOATING_TERMINAL_WORKTREE_ID,
OrcaRuntimeService,
SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV,
electronMocks,
ipcMain,
join,
mkdtemp,
randomUUID,
@@ -191,68 +189,6 @@ describe('OrcaRuntimeService', () => {
expect(spawnCall?.env?.ORCA_AGENT_LAUNCH_TOKEN).toBeUndefined()
})
it('sends Settings agent defaults through renderer-backed bare agent terminal creates', async () => {
const runtimeStore = {
...store,
getSettings: () => ({
...store.getSettings(),
disabledTuiAgents: [],
agentCmdOverrides: {},
agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' },
agentDefaultEnv: { codex: { CODEX_PROFILE: 'captured' } }
})
}
const runtime = new OrcaRuntimeService(runtimeStore)
const webContents = { send: vi.fn() }
webContents.send.mockImplementation((_channel: string, payload: { requestId: string }) => {
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [
{
tabId: 'tab-renderer',
worktreeId: TEST_WORKTREE_ID,
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-renderer',
paneTitle: null
}
]
})
ipcMain.emit(
'terminal:tabCreateReply',
{ sender: webContents },
{ requestId: payload.requestId, tabId: 'tab-renderer', title: 'Codex' }
)
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents
})
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'codex',
rendererBacked: true
})
expect(webContents.send).toHaveBeenCalledWith(
'terminal:requestTabCreate',
expect.objectContaining({
worktreeId: TEST_WORKTREE_ID,
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
env: { CODEX_PROFILE: 'captured' },
launchAgent: 'codex',
launchConfig: {
agentCommand: "codex '--dangerously-bypass-approvals-and-sandbox'",
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
agentEnv: { CODEX_PROFILE: 'captured' }
}
})
)
})
it('injects runtime hook receiver env into terminal sessions', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-hooked' })
const runtime = new OrcaRuntimeService(store, undefined, {
@@ -4,11 +4,19 @@ import { join } from 'node:path'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import {
OrcaRuntimeService,
RpcDispatcher,
TERMINAL_METHODS,
electronMocks,
getDefaultWorkspaceSession,
ipcMain,
setPlatform
} from '../orca-runtime-test-mocks.spec'
import { TEST_WORKTREE_ID, TEST_WORKTREE_PATH, store } from '../orca-runtime-test-fixtures.spec'
import {
TEST_WORKTREE_ID,
TEST_WORKTREE_PATH,
makeRuntimeStoreWithWorkspaceSession,
store
} from '../orca-runtime-test-fixtures.spec'
import { wireHeadlessServeRuntime } from '../orca-runtime-test-scenario-builders.spec'
describe('OrcaRuntimeService', () => {
@@ -281,34 +289,6 @@ describe('OrcaRuntimeService', () => {
expect(spawnOptions?.persistHostSessionBinding).toBe(true)
})
it('falls back to background terminal creation for renderer-backed requests without a renderer window', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await expect(
runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'codex',
rendererBacked: true
})
).resolves.toMatchObject({
worktreeId: TEST_WORKTREE_ID,
surface: 'background'
})
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
cwd: TEST_WORKTREE_PATH,
worktreeId: TEST_WORKTREE_ID
})
)
})
it('spawns focus-requested CLI terminal creates in background on headless serve', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-focus-headless' })
const runtime = wireHeadlessServeRuntime()
@@ -442,70 +422,227 @@ describe('OrcaRuntimeService', () => {
expect(tabs.activeTabId).toBe(published?.id)
})
it('keeps focus-requested terminal creates on the renderer when a window exists', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-should-not-spawn' })
const webContents = { send: vi.fn() }
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-focused',
worktreeId: TEST_WORKTREE_ID,
title: 'Focused Terminal',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-focused',
worktreeId: TEST_WORKTREE_ID,
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-focused',
paneTitle: null
}
]
// `orca terminal create` used to ask the window to make the terminal and report failure after
// 10 s, while the window still made it later; a retry then duplicated the terminal.
it.each([
['--focus', { command: 'echo hi', focus: true, presentation: 'focused' }],
// What the CLI sent for an interactive agent before this host ignored `rendererBacked`.
['interactive agent', { command: 'codex', focus: false, rendererBacked: true, activate: false }]
])(
'reports the running CLI terminal, not a failure, when the window is slow (%s)',
async (_case, params) => {
vi.useFakeTimers()
onTestFinished(() => {
vi.useRealTimers()
})
ipcMain.emit(
'terminal:tabCreateReply',
{ sender: webContents },
{ requestId: payload.requestId, tabId: 'tab-focused', title: 'Focused Terminal' }
const spawn = vi.fn().mockResolvedValue({ id: 'pty-cli' })
const windowSend = vi.fn()
// The window answers nothing, so the real reveal times out after 10 s.
const revealTerminalSession = vi.fn(
() =>
new Promise<never>((_resolve, reject) => {
setTimeout(() => reject(new Error('Terminal reveal timed out')), 10_000)
})
)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession,
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents: { send: windowSend }
})
const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS })
const response = dispatcher.dispatch({
id: 'request-1',
authToken: 'token',
method: 'terminal.create',
params: { worktree: `id:${TEST_WORKTREE_ID}`, ...params }
})
// Agent launch resolution does real I/O, so wait for the window to be asked first.
await vi.waitFor(() =>
expect(revealTerminalSession.mock.calls.length + windowSend.mock.calls.length).toBe(1)
)
await vi.advanceTimersByTimeAsync(10_000)
await expect(response).resolves.toMatchObject({
ok: true,
result: {
terminal: {
handle: expect.stringMatching(/^term_/),
worktreeId: TEST_WORKTREE_ID,
surface: 'background',
warning: expect.stringContaining('Terminal reveal timed out')
}
}
})
expect(spawn).toHaveBeenCalledTimes(1)
expect(windowSend).not.toHaveBeenCalledWith('terminal:requestTabCreate', expect.anything())
}
)
it.each([
['the named group', { targetGroupId: 'group-named' }, { cols: 90, rows: 30 }],
['the active group when none is named', {}, { cols: 63, rows: 60 }]
])('starts a host terminal at the measured size of %s', async (_case, opts, expected) => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-cli' })
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-active' }
})
webContents.send = send
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
electronMocks.BrowserWindow.fromId.mockReturnValue({
isDestroyed: () => false,
webContents
})
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await expect(
runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
command: 'echo test',
focus: true
})
).resolves.toMatchObject({
tabId: 'tab-focused',
worktreeId: TEST_WORKTREE_ID,
surface: 'visible'
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
terminalGroupGridSizes: {
'group-active': { cols: 63, rows: 60 },
'group-named': { cols: 90, rows: 30 }
},
// A measured group beats the single-group size.
singleGroupWorkspaceGridSize: { cols: 238, rows: 80 }
})
expect(send).toHaveBeenCalledWith(
'terminal:requestTabCreate',
expect.objectContaining({ activate: true, presentation: 'focused' })
)
expect(spawn).not.toHaveBeenCalled()
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { command: 'codex', ...opts })
expect(spawn).toHaveBeenCalledWith(expect.objectContaining(expected))
})
it('accepts renderer-backed terminal create replies only from the target renderer', async () => {
it("prefers the window's latest published active group over the saved one", async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-cli' })
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-saved' }
})
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'renderer:epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-live',
activeTabId: null,
activeTabType: null,
tabs: []
}
],
terminalGroupGridSizes: {
'group-saved': { cols: 63, rows: 60 },
'group-live': { cols: 70, rows: 50 }
}
})
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { command: 'codex' })
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cols: 70, rows: 50 }))
})
it.each([
['no saved group', undefined, { cols: 238, rows: 80 }],
[
'one saved group',
[{ id: 'group-old', worktreeId: TEST_WORKTREE_ID, activeTabId: null, tabOrder: [] }],
{ cols: 238, rows: 80 }
],
[
'two saved groups',
[
{ id: 'group-left', worktreeId: TEST_WORKTREE_ID, activeTabId: null, tabOrder: [] },
{ id: 'group-right', worktreeId: TEST_WORKTREE_ID, activeTabId: null, tabOrder: [] }
],
{ cols: 120, rows: 40 }
]
])(
'sizes a workspace the window has not shown, with %s, from the single-group size',
async (_case, tabGroups, expected) => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-cli' })
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
...(tabGroups ? { tabGroups: { [TEST_WORKTREE_ID]: tabGroups } } : {})
})
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
terminalGroupGridSizes: { 'group-shown': { cols: 66, rows: 63 } },
singleGroupWorkspaceGridSize: { cols: 238, rows: 80 }
})
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { command: 'codex' })
expect(spawn).toHaveBeenCalledWith(expect.objectContaining(expected))
}
)
it('starts a host terminal at 120x40 when no window measured its group', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-cli' })
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession({
...getDefaultWorkspaceSession(),
activeGroupIdByWorktree: { [TEST_WORKTREE_ID]: 'group-unmeasured' }
})
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
terminalGroupGridSizes: { 'group-other': { cols: 63, rows: 60 } }
})
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { command: 'codex' })
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cols: 120, rows: 40 }))
})
it('accepts workspace-less terminal create replies only from the target renderer', async () => {
const webContents = { send: vi.fn() }
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
ipcMain.emit(
@@ -550,24 +687,21 @@ describe('OrcaRuntimeService', () => {
})
await expect(
runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
runtime.createTerminal(undefined, {
command: 'codex',
rendererBacked: true,
title: 'Renderer Terminal'
})
).resolves.toMatchObject({
handle: expect.stringMatching(/^term_/),
tabId: 'tab-renderer',
title: 'Renderer Terminal',
worktreeId: TEST_WORKTREE_ID,
surface: 'visible'
})
expect(send).toHaveBeenCalledWith(
'terminal:requestTabCreate',
expect.objectContaining({
requestId: expect.any(String),
worktreeId: TEST_WORKTREE_ID,
command: "codex '--dangerously-bypass-approvals-and-sandbox'",
command: 'codex',
title: 'Renderer Terminal'
})
)
@@ -223,9 +223,9 @@ describe('OrcaRuntimeService', () => {
webContents: { send }
})
await expect(
runtime.createTerminal(undefined, { shellOverride: 'cmd.exe', rendererBacked: true })
).rejects.toThrow(/--shell cmd\.exe needs a workspace/)
await expect(runtime.createTerminal(undefined, { shellOverride: 'cmd.exe' })).rejects.toThrow(
/--shell cmd\.exe needs a workspace/
)
expect(send).not.toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
})
@@ -237,7 +237,6 @@ describe('agent session RPC methods', () => {
launchAgent: 'codex',
title: undefined,
focus: false,
rendererBacked: false,
activate: false,
presentation: undefined,
tabId: undefined,
@@ -73,7 +73,6 @@ export const TERMINAL_LIFECYCLE_METHODS = [
: {}),
title: params.title,
focus,
rendererBacked: params.rendererBacked === true,
activate,
presentation,
tabId: params.tabId,
@@ -81,7 +81,6 @@ export type TerminalCreateOptions = {
launchSource?: string
title?: string
focus?: boolean
rendererBacked?: boolean
activate?: boolean
presentation?: RuntimeTerminalPresentation
surfaceOwner?: false
@@ -19,6 +19,7 @@ import { resolveGroupTabFromVisibleId } from './tab-group-visible-id'
import { getTabPaneBodyDroppableId, type HoveredTabInsertion } from './useTabDragSplit'
import { tabGroupBodyAnchorName } from './tab-group-body-anchor'
import { registerTabGroupBody } from './tab-group-body-geometry'
import { observeTabGroupBodyGridSize } from '@/runtime/sync-runtime-graph'
import { translate } from '@/i18n/i18n'
import type { TabGroup } from '../../../../shared/tab-types'
import type { ClientHostedBrowserRow } from '../../../../shared/client-hosted-browser-rows'
@@ -111,8 +112,10 @@ export default function TabGroupPanel({
return undefined
}
const unregisterBody = registerTabGroupBody(groupId, node)
const stopGridSizeReport = observeTabGroupBodyGridSize(node)
return () => {
unregisterBody()
stopGridSizeReport()
setBodyDropRef(null)
}
},
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import { FitAddon } from '@xterm/addon-fit'
import type { Terminal } from '@xterm/xterm'
import { describe, expect, it } from 'vitest'
import { terminalGridSizeForBox, type TerminalFitMetrics } from './terminal-grid-size-for-box'
function fitAddonResult(
box: { width: number; height: number },
metrics: TerminalFitMetrics
): { cols: number; rows: number } | undefined {
const parent = document.createElement('div')
parent.style.width = `${box.width}px`
parent.style.height = `${box.height}px`
const element = document.createElement('div')
const { top, right, bottom, left } = metrics.padding
element.style.padding = `${top}px ${right}px ${bottom}px ${left}px`
parent.appendChild(element)
document.body.appendChild(parent)
const terminal = {
element,
dimensions: { css: { cell: metrics.cell } },
options: { scrollback: metrics.scrollback, scrollbar: metrics.scrollbar }
}
const addon = new FitAddon()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: proposeDimensions reads only element, dimensions and options, all set above.
addon.activate(terminal as unknown as Terminal)
return addon.proposeDimensions()
}
const cases: [string, { width: number; height: number }, TerminalFitMetrics][] = [
[
'a fractional box with the default scrollbar',
{ width: 541.5, height: 963 },
{
cell: { width: 8.4, height: 16 },
padding: { top: 0, right: 0, bottom: 0, left: 0 },
scrollback: 5000,
scrollbar: undefined
}
],
[
'padding and a custom scrollbar width',
{ width: 1200, height: 700 },
{
cell: { width: 7.2, height: 15.5 },
padding: { top: 2, right: 3, bottom: 4, left: 5 },
scrollback: 1000,
scrollbar: { width: 10 }
}
],
[
'no scrollback, so no scrollbar',
{ width: 300, height: 200 },
{
cell: { width: 9, height: 18 },
padding: { top: 0, right: 0, bottom: 0, left: 0 },
scrollback: 0,
scrollbar: undefined
}
],
[
'a hidden scrollbar',
{ width: 640.9, height: 480.9 },
{
cell: { width: 8, height: 17 },
padding: { top: 0, right: 0, bottom: 0, left: 0 },
scrollback: 1000,
scrollbar: { showScrollbar: false }
}
],
[
'a box below the minimum grid',
{ width: 10, height: 5 },
{
cell: { width: 8, height: 16 },
padding: { top: 0, right: 0, bottom: 0, left: 0 },
scrollback: 1000,
scrollbar: undefined
}
]
]
describe('terminalGridSizeForBox', () => {
it.each(cases)('matches FitAddon.proposeDimensions for %s', (_name, box, metrics) => {
expect(terminalGridSizeForBox(box, metrics)).toEqual(fitAddonResult(box, metrics))
})
})
@@ -0,0 +1,30 @@
// Mirrors FitAddon.proposeDimensions in @xterm/addon-fit 0.12.0-beta.301 (xterm 6.1.0-beta.304).
const MINIMUM_COLS = 2
const MINIMUM_ROWS = 1
const DEFAULT_SCROLL_BAR_WIDTH = 14
export type TerminalFitMetrics = {
cell: { width: number; height: number }
padding: { top: number; right: number; bottom: number; left: number }
scrollback: number | undefined
scrollbar: { showScrollbar?: boolean; width?: number } | undefined
}
/** The grid a terminal would fit to if its xterm host element had this box. */
export function terminalGridSizeForBox(
box: { width: number; height: number },
metrics: TerminalFitMetrics
): { cols: number; rows: number } {
const { cell, padding, scrollback, scrollbar } = metrics
const scrollbarWidth =
scrollback === 0 || !(scrollbar?.showScrollbar ?? true)
? 0
: (scrollbar?.width ?? DEFAULT_SCROLL_BAR_WIDTH)
// FitAddon reads the host's computed width/height with parseInt, which truncates.
const width = Math.max(0, Math.trunc(box.width)) - padding.left - padding.right - scrollbarWidth
const height = Math.max(0, Math.trunc(box.height)) - padding.top - padding.bottom
return {
cols: Math.max(MINIMUM_COLS, Math.floor(width / cell.width)),
rows: Math.max(MINIMUM_ROWS, Math.floor(height / cell.height))
}
}
@@ -0,0 +1,103 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
scheduleRuntimeGraphSyncIfGroupGridSizesChanged,
setRuntimeGraphStoreStateGetter,
setRuntimeGraphSyncEnabled
} from './sync-runtime-graph'
import type { AppState } from '../store/types'
import type { RuntimeSyncWindowGraph } from '../../../shared/runtime-types'
type GridSizes =
| {
groups: Record<string, { cols: number; rows: number }>
singleGroupWorkspace?: { cols: number; rows: number }
}
| undefined
const measured = vi.hoisted((): { sizes: GridSizes } => ({ sizes: undefined }))
vi.mock('./sync-runtime-graph/terminal-group-grid-sizes', () => ({
measureTerminalGroupGridSizes: () => measured.sizes
}))
const EMPTY_STATE = {
tabsByWorktree: {},
terminalLayoutsByTabId: {},
runtimePaneTitlesByTabId: {},
groupsByWorktree: {},
activeGroupIdByWorktree: {},
layoutByWorktree: {},
unifiedTabsByWorktree: {},
tabBarOrderByWorktree: {},
activeFileId: null,
activeFileIdByWorktree: {},
openFiles: [],
editorDrafts: {},
activeTabId: null,
agentStatusByPaneKey: {},
browserTabsByWorktree: {}
}
async function flushSync(): Promise<void> {
await vi.advanceTimersByTimeAsync(20)
await Promise.resolve()
await Promise.resolve()
}
afterEach(() => {
setRuntimeGraphSyncEnabled(false)
setRuntimeGraphStoreStateGetter(null)
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('terminal group grid sizes in the window graph', () => {
it('republishes only when a refit changed a group grid', async () => {
const syncWindowGraph = vi
.fn<(graph: RuntimeSyncWindowGraph) => Promise<object>>()
.mockResolvedValue({})
vi.useFakeTimers()
vi.stubGlobal('window', { api: { runtime: { syncWindowGraph } } })
vi.stubGlobal('HTMLElement', class HTMLElement {})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: graph sync reads only these collections of the store.
setRuntimeGraphStoreStateGetter(() => EMPTY_STATE as unknown as AppState)
measured.sizes = {
groups: { 'group-a': { cols: 63, rows: 60 } },
singleGroupWorkspace: { cols: 130, rows: 60 }
}
setRuntimeGraphSyncEnabled(true)
await flushSync()
expect(syncWindowGraph.mock.calls[0]?.[0]).toMatchObject({
terminalGroupGridSizes: { 'group-a': { cols: 63, rows: 60 } },
singleGroupWorkspaceGridSize: { cols: 130, rows: 60 }
})
scheduleRuntimeGraphSyncIfGroupGridSizesChanged()
await flushSync()
expect(syncWindowGraph).toHaveBeenCalledTimes(1)
measured.sizes = {
groups: { 'group-a': { cols: 66, rows: 60 } },
singleGroupWorkspace: { cols: 130, rows: 60 }
}
scheduleRuntimeGraphSyncIfGroupGridSizesChanged()
await flushSync()
expect(syncWindowGraph).toHaveBeenCalledTimes(2)
expect(syncWindowGraph.mock.calls[1]?.[0].terminalGroupGridSizes).toEqual({
'group-a': { cols: 66, rows: 60 }
})
})
it('leaves the field out until a pane can be measured', async () => {
const syncWindowGraph = vi
.fn<(graph: RuntimeSyncWindowGraph) => Promise<object>>()
.mockResolvedValue({})
vi.useFakeTimers()
vi.stubGlobal('window', { api: { runtime: { syncWindowGraph } } })
vi.stubGlobal('HTMLElement', class HTMLElement {})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: graph sync reads only these collections of the store.
setRuntimeGraphStoreStateGetter(() => EMPTY_STATE as unknown as AppState)
measured.sizes = undefined
setRuntimeGraphSyncEnabled(true)
await flushSync()
expect(syncWindowGraph.mock.calls[0]?.[0]).not.toHaveProperty('terminalGroupGridSizes')
})
})
@@ -17,6 +17,7 @@ import {
} from './sync-runtime-graph/sync-key'
import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots'
import { resetRuntimeMobileSyncProjectionCachesForTests } from './sync-runtime-graph/sync-projections'
import { measureTerminalGroupGridSizes } from './sync-runtime-graph/terminal-group-grid-sizes'
export type { RuntimeMobileSessionSyncKey } from './sync-runtime-graph/types'
export {
@@ -69,6 +70,25 @@ export function scheduleRuntimeGraphSync(): void {
}, RUNTIME_GRAPH_SYNC_COALESCE_MS)
}
/** Republishes the graph only when the grid a new tab would get in some group changed. */
export function scheduleRuntimeGraphSyncIfGroupGridSizesChanged(): void {
const state = graphState.getStoreState?.()
if (!state) {
return
}
const sizes = measureTerminalGroupGridSizes(state.settings?.terminalFontSize ?? 14)
if (JSON.stringify(sizes ?? null) !== graphState.publishedTerminalGroupGridSizesKey) {
scheduleRuntimeGraphSync()
}
}
/** A tab group body resized (window, divider, sidebar): its group's grid may have changed. */
export function observeTabGroupBodyGridSize(body: HTMLElement): () => void {
const observer = new ResizeObserver(scheduleRuntimeGraphSyncIfGroupGridSizesChanged)
observer.observe(body)
return () => observer.disconnect()
}
async function runRuntimeGraphSync(): Promise<void> {
if (graphState.syncInFlight) {
graphState.syncPendingAfterFlight = true
@@ -17,6 +17,7 @@ import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution'
import { buildMobileSessionTabSnapshots } from './mobile-session-snapshots'
import { isWebOnlyMirroredTerminalTab } from './mobile-session-surfaces'
import { resolveRuntimeTerminalTitle } from './sync-projections'
import { measureTerminalGroupGridSizes } from './terminal-group-grid-sizes'
import {
collectAmbiguousTerminalTabIds,
graphState,
@@ -58,12 +59,17 @@ export async function syncRuntimeGraph(): Promise<void> {
ambiguousTerminalTabIds
)
const publication = partitionMobileSessionPublication(mobileSessionTabs)
const gridSizes = measureTerminalGroupGridSizes(state.settings?.terminalFontSize ?? 14)
const graph: RuntimeRendererSyncWindowGraph = {
tabs: [],
leaves: [],
rendererGeneration: mobilePublicationEpoch,
mobileSessionTabs: publication.changed,
unchangedMobileSessionWorktrees: publication.unchangedWorktrees
unchangedMobileSessionWorktrees: publication.unchangedWorktrees,
...(gridSizes ? { terminalGroupGridSizes: gridSizes.groups } : {}),
...(gridSizes?.singleGroupWorkspace
? { singleGroupWorkspaceGridSize: gridSizes.singleGroupWorkspace }
: {})
}
for (const [registrationKey, registeredTab] of mountedTerminalTabEntries()) {
@@ -191,6 +197,7 @@ export async function syncRuntimeGraph(): Promise<void> {
try {
const result = await window.api.runtime.syncWindowGraph(graph)
graphState.publishedTerminalGroupGridSizesKey = JSON.stringify(gridSizes ?? null)
commitMobileSessionPublication(mobileSessionTabs, result?.mobileSessionResyncWorktrees)
const currentState = graphState.getStoreState()
currentState?.setRuntimeAgentOrchestrationByPaneKey?.(result?.agentOrchestrationByPaneKey ?? {})
@@ -54,7 +54,9 @@ export const graphState = {
cachedOpenFileIndexesSource: null as AppState['openFiles'] | null,
cachedOpenFileIndexes: null as OpenFileIndexes | null,
cachedEditorDraftHashes: null as EditorDraftHashCache | null,
mobileSessionSnapshotVersion: 0
mobileSessionSnapshotVersion: 0,
/** Last group grid sizes main accepted, so a refit republishes only when one changed. */
publishedTerminalGroupGridSizesKey: ''
}
/** IDs occurring more than once cannot address the legacy tab-keyed runtime maps safely. */
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { measureTerminalGroupGridSizes } from './terminal-group-grid-sizes'
type FakePane = { terminal: unknown; container: HTMLElement }
const panes = vi.hoisted((): { list: FakePane[] } => ({ list: [] }))
vi.mock('../mounted-terminal-tab-registry', () => ({
mountedTerminalTabEntries: () => [
['tab-1', { getManager: () => ({ getPanes: () => panes.list }) }]
]
}))
function withRect<T extends HTMLElement>(element: T, width: number, height: number): T {
element.getBoundingClientRect = () => new DOMRect(0, 0, width, height)
return element
}
/** A pane whose xterm host sits 4px inside it, like `.xterm-container`. */
function pane(opts: { fontSize: number; cellWidth: number; title?: boolean }): FakePane {
const container = withRect(document.createElement('div'), 500, 400)
if (opts.title) {
container.setAttribute('data-has-title', '')
}
const host = withRect(document.createElement('div'), 496, 396)
const element = document.createElement('div')
host.appendChild(element)
container.appendChild(host)
return {
container,
terminal: {
element,
options: { fontSize: opts.fontSize, scrollback: 1000, scrollbar: undefined },
dimensions: { css: { cell: { width: opts.cellWidth, height: 16 } } }
}
}
}
function groupBody(
id: string,
width: number,
height: number,
parent: HTMLElement = document.body
): void {
const body = withRect(document.createElement('div'), width, height)
body.dataset.tabGroupBodyId = id
parent.appendChild(body)
}
function contentArea(width: number, height: number): HTMLElement {
const content = withRect(document.createElement('div'), width, height)
content.setAttribute('data-terminal-workbench-container', '')
document.body.appendChild(content)
return content
}
afterEach(() => {
panes.list = []
document.body.replaceChildren()
})
describe('measureTerminalGroupGridSizes', () => {
it('sizes each group from a pane at the default font with no title', () => {
panes.list = [
pane({ fontSize: 20, cellWidth: 12 }),
pane({ fontSize: 14, cellWidth: 10, title: true }),
pane({ fontSize: 14, cellWidth: 8 })
]
groupBody('group-a', 548, 1004)
groupBody('group-b', 1100, 500)
// (548 - 4 inset - 14 scrollbar) / 8 = 66; (1004 - 4) / 16 = 62.
expect(measureTerminalGroupGridSizes(14)).toEqual({
groups: {
'group-a': { cols: 66, rows: 62 },
'group-b': { cols: 135, rows: 31 }
}
})
})
it("sizes a single-group workspace from the content area minus a lone group's tab row", () => {
panes.list = [pane({ fontSize: 14, cellWidth: 8 })]
const content = contentArea(1930, 1330)
groupBody('group-a', 1929, 1294, content)
// Neither the floating panel's body nor a hidden workspace's body is another group here.
groupBody('floating', 600, 400)
groupBody('hidden', 0, 0, content)
// The lone body's inset (1, 36) is remembered and applied to the content area, so later
// measurements on a split workspace still know what a single-group workspace gets.
expect(measureTerminalGroupGridSizes(14)?.singleGroupWorkspace).toEqual({ cols: 238, rows: 80 })
document.body.replaceChildren()
const splitContent = contentArea(1600, 1330)
groupBody('group-a', 799, 1294, splitContent)
groupBody('group-b', 800, 1294, splitContent)
// (1600 - 1 - 4 inset - 14 scrollbar) / 8 = 197; (1330 - 36 - 4) / 16 = 80.
expect(measureTerminalGroupGridSizes(14)?.singleGroupWorkspace).toEqual({ cols: 197, rows: 80 })
})
it('leaves the sizes out when no pane at the default font is measurable', () => {
panes.list = [pane({ fontSize: 20, cellWidth: 12 })]
groupBody('group-a', 548, 1004)
expect(measureTerminalGroupGridSizes(14)).toBeUndefined()
})
})
@@ -0,0 +1,109 @@
import {
terminalGridSizeForBox,
type TerminalFitMetrics
} from '@/lib/pane-manager/terminal-grid-size-for-box'
import { mountedTerminalTabEntries } from '../mounted-terminal-tab-registry'
type GridSize = { cols: number; rows: number }
/** Metrics a new tab's pane will have, read from a mounted pane shaped like one: default font size
* (a zoomed pane has its own cells) and no title or banner (either pushes its xterm host down). */
function readDefaultPaneFit(
defaultFontSize: number
): { metrics: TerminalFitMetrics; inset: { width: number; height: number } } | null {
for (const [, registered] of mountedTerminalTabEntries()) {
for (const { terminal, container } of registered.getManager()?.getPanes() ?? []) {
const cell = terminal.dimensions?.css.cell
if (
terminal.options.fontSize !== defaultFontSize ||
container.hasAttribute('data-has-title') ||
container.querySelector(':scope > .pane-top-banner') ||
!terminal.element ||
!cell ||
cell.width <= 0 ||
cell.height <= 0
) {
continue
}
const hostRect = terminal.element.parentElement?.getBoundingClientRect()
if (!hostRect || hostRect.width <= 0 || hostRect.height <= 0) {
continue
}
const paneRect = container.getBoundingClientRect()
const style = getComputedStyle(terminal.element)
return {
metrics: {
cell: { width: cell.width, height: cell.height },
padding: {
top: Number.parseInt(style.paddingTop, 10) || 0,
right: Number.parseInt(style.paddingRight, 10) || 0,
bottom: Number.parseInt(style.paddingBottom, 10) || 0,
left: Number.parseInt(style.paddingLeft, 10) || 0
},
scrollback: terminal.options.scrollback,
scrollbar: terminal.options.scrollbar
},
inset: { width: paneRect.width - hostRect.width, height: paneRect.height - hostRect.height }
}
}
}
return null
}
// Content area minus a lone group's body (its tab row and border), last seen on a single-group workspace.
let singleGroupBodyInset: { width: number; height: number } | null = null
export type TerminalGroupGridSizes = {
groups: Record<string, GridSize>
/** What a workspace with one group gets: every workspace shares the same content area. */
singleGroupWorkspace?: GridSize
}
/** The grid an unsplit terminal would get in each shown tab group, and in a single-group workspace
* that is not shown; undefined until a pane at the default font is measurable. */
export function measureTerminalGroupGridSizes(
defaultFontSize: number
): TerminalGroupGridSizes | undefined {
const fit = readDefaultPaneFit(defaultFontSize)
if (!fit) {
return undefined
}
const gridFor = (rect: { width: number; height: number }): GridSize =>
terminalGridSizeForBox(
{ width: rect.width - fit.inset.width, height: rect.height - fit.inset.height },
fit.metrics
)
const groups: Record<string, GridSize> = {}
const shownBodies: { body: HTMLElement; rect: DOMRect }[] = []
for (const body of document.querySelectorAll<HTMLElement>('[data-tab-group-body-id]')) {
const groupId = body.dataset.tabGroupBodyId
const rect = body.getBoundingClientRect()
if (groupId && rect.width > 0 && rect.height > 0) {
groups[groupId] = gridFor(rect)
shownBodies.push({ body, rect })
}
}
const contentElement = document.querySelector('[data-terminal-workbench-container]')
const content = contentElement?.getBoundingClientRect()
if (!contentElement || !content || content.width <= 0 || content.height <= 0) {
return { groups }
}
// The floating terminal panel's body lies outside the content area and is never the lone group.
const contentBodies = shownBodies.filter(({ body }) => contentElement.contains(body))
if (contentBodies.length === 1) {
const body = contentBodies[0].rect
singleGroupBodyInset = {
width: content.width - body.width,
height: content.height - body.height
}
}
return singleGroupBodyInset
? {
groups,
singleGroupWorkspace: gridFor({
width: content.width - singleGroupBodyInset.width,
height: content.height - singleGroupBodyInset.height
})
}
: { groups }
}
@@ -189,6 +189,7 @@ export const TerminalCreateParams = z.object({
terminalColorQueryReplies: TerminalColorQueryReplyColorsParam.optional(),
title: OptionalString,
focus: z.unknown().optional(),
// Ignored here; the CLI still sends it for hosts before v1.4.211, which route on it.
rendererBacked: z.unknown().optional(),
activate: z.unknown().optional(),
presentation: z.enum(['background', 'focused']).optional(),
+5
View File
@@ -161,6 +161,11 @@ export type RuntimeSyncWindowGraph = {
leaves: RuntimeSyncedLeaf[]
mobileSessionTabs?: RuntimeMobileSessionTabsSnapshot[]
unchangedMobileSessionWorktrees?: string[]
/** Terminal grid that fills each shown tab group, by group id, so main starts a new tab's
* terminal at its group's size. Window-to-main only: never copied into session tabs. */
terminalGroupGridSizes?: Record<string, { cols: number; rows: number }>
/** The grid a workspace with a single tab group gets, for one the window is not showing. */
singleGroupWorkspaceGridSize?: { cols: number; rows: number }
}
export type RuntimeRendererSyncWindowGraph = RuntimeSyncWindowGraph & {