From 24bda717f48550985aec6002c644d12efb69435a Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:08:45 -0400 Subject: [PATCH] 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 * fix(terminal): learn the single-group inset only from bodies shown in the content area Co-Authored-By: Claude * chore: rerun PR checks against main with #28297 Co-Authored-By: Claude * chore: rerun PR checks against main with #28301 Co-Authored-By: Claude --------- Co-authored-by: Claude --- src/cli/handlers/terminal.ts | 6 +- .../orca-runtime-create-terminal-desktop.ts | 54 ++-- .../runtime/orca-runtime-create-terminal.ts | 44 +-- .../runtime/orca-runtime-sync-window-graph.ts | 10 + ...nal-creation-and-readiness-part-02.spec.ts | 6 +- ...nal-creation-and-readiness-part-03.spec.ts | 64 ---- ...nal-creation-and-readiness-part-04.spec.ts | 306 +++++++++++++----- .../terminal-creation-and-readiness.spec.ts | 6 +- .../runtime/rpc/methods/agent-session.test.ts | 1 - .../terminal/terminal-lifecycle-methods.ts | 1 - .../runtime/runtime-terminal-contracts.ts | 1 - .../components/tab-group/TabGroupPanel.tsx | 3 + .../terminal-grid-size-for-box.test.ts | 87 +++++ .../terminal-grid-size-for-box.ts | 30 ++ ...me-graph-terminal-group-grid-sizes.test.ts | 103 ++++++ .../src/runtime/sync-runtime-graph.ts | 20 ++ .../sync-runtime-graph/graph-publication.ts | 9 +- .../runtime/sync-runtime-graph/graph-state.ts | 4 +- .../terminal-group-grid-sizes.test.ts | 105 ++++++ .../terminal-group-grid-sizes.ts | 109 +++++++ .../rpc-contract/terminal-unary-params.ts | 1 + src/shared/runtime-session-contracts.ts | 5 + 22 files changed, 754 insertions(+), 221 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-grid-size-for-box.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-grid-size-for-box.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph-terminal-group-grid-sizes.test.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/terminal-group-grid-sizes.test.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/terminal-group-grid-sizes.ts 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 & {