diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 2da49ad8ca8..e1d6271357b 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -193,11 +193,11 @@ export const TERMINAL_HANDLERS: Record = { 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) diff --git a/src/main/runtime/orca-runtime-create-terminal-desktop.ts b/src/main/runtime/orca-runtime-create-terminal-desktop.ts index 6a56bff56b0..ce0afee9024 100644 --- a/src/main/runtime/orca-runtime-create-terminal-desktop.ts +++ b/src/main/runtime/orca-runtime-create-terminal-desktop.ts @@ -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 { - 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' diff --git a/src/main/runtime/orca-runtime-create-terminal.ts b/src/main/runtime/orca-runtime-create-terminal.ts index d7fc6f3ddc6..72eaa774230 100644 --- a/src/main/runtime/orca-runtime-create-terminal.ts +++ b/src/main/runtime/orca-runtime-create-terminal.ts @@ -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) } } diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/orca-runtime-sync-window-graph.ts index 893c51c68cc..d244e0ff253 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/orca-runtime-sync-window-graph.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts index d568281d00e..1df54c18403 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts index 794aafdd981..3a71afbba95 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts @@ -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, { diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts index bd1c09dded0..4deb117e6fb 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-04.spec.ts @@ -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((_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' }) ) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness.spec.ts index 851023bb3ea..ef343f7b75a 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness.spec.ts @@ -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() }) diff --git a/src/main/runtime/rpc/methods/agent-session.test.ts b/src/main/runtime/rpc/methods/agent-session.test.ts index ed67179be57..f3e63f010ab 100644 --- a/src/main/runtime/rpc/methods/agent-session.test.ts +++ b/src/main/runtime/rpc/methods/agent-session.test.ts @@ -237,7 +237,6 @@ describe('agent session RPC methods', () => { launchAgent: 'codex', title: undefined, focus: false, - rendererBacked: false, activate: false, presentation: undefined, tabId: undefined, diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index bc66b9e55a8..e4d3eb99a79 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -73,7 +73,6 @@ export const TERMINAL_LIFECYCLE_METHODS = [ : {}), title: params.title, focus, - rendererBacked: params.rendererBacked === true, activate, presentation, tabId: params.tabId, diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 1e31d4e922c..bcdc9935204 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -81,7 +81,6 @@ export type TerminalCreateOptions = { launchSource?: string title?: string focus?: boolean - rendererBacked?: boolean activate?: boolean presentation?: RuntimeTerminalPresentation surfaceOwner?: false diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 94b29b544a8..5835d8e6aa7 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -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) } }, diff --git a/src/renderer/src/lib/pane-manager/terminal-grid-size-for-box.test.ts b/src/renderer/src/lib/pane-manager/terminal-grid-size-for-box.test.ts new file mode 100644 index 00000000000..30424ab709f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-grid-size-for-box.test.ts @@ -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)) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-grid-size-for-box.ts b/src/renderer/src/lib/pane-manager/terminal-grid-size-for-box.ts new file mode 100644 index 00000000000..eb8f091d4e3 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-grid-size-for-box.ts @@ -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)) + } +} diff --git a/src/renderer/src/runtime/sync-runtime-graph-terminal-group-grid-sizes.test.ts b/src/renderer/src/runtime/sync-runtime-graph-terminal-group-grid-sizes.test.ts new file mode 100644 index 00000000000..f40c7d4f63a --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-terminal-group-grid-sizes.test.ts @@ -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 + 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 { + 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>() + .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>() + .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') + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 3df1675a217..12a4e75df85 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -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 { if (graphState.syncInFlight) { graphState.syncPendingAfterFlight = true diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts index ab1c7e26ec0..cf058f35e37 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-publication.ts @@ -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 { 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 { 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 ?? {}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts index bd97475cfdd..a7cd35bad39 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts @@ -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. */ diff --git a/src/renderer/src/runtime/sync-runtime-graph/terminal-group-grid-sizes.test.ts b/src/renderer/src/runtime/sync-runtime-graph/terminal-group-grid-sizes.test.ts new file mode 100644 index 00000000000..a1674638362 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/terminal-group-grid-sizes.test.ts @@ -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(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() + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph/terminal-group-grid-sizes.ts b/src/renderer/src/runtime/sync-runtime-graph/terminal-group-grid-sizes.ts new file mode 100644 index 00000000000..0aab7335f56 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/terminal-group-grid-sizes.ts @@ -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 + /** 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 = {} + const shownBodies: { body: HTMLElement; rect: DOMRect }[] = [] + for (const body of document.querySelectorAll('[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 } +} diff --git a/src/shared/rpc-contract/terminal-unary-params.ts b/src/shared/rpc-contract/terminal-unary-params.ts index fe241b27a96..417e4ce8b53 100644 --- a/src/shared/rpc-contract/terminal-unary-params.ts +++ b/src/shared/rpc-contract/terminal-unary-params.ts @@ -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(), diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 09106e78d0f..736ad2ecf21 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -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 + /** 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 & {