Fix terminal width cutoff on wide panes (#24687)

* fix(terminal): let wide panes use up to 1024 columns

Adapt the wider viewport limit proposed in #16578 to the current runtime, shared RPC schemas, and preview sizing.

Co-authored-by: innocarpe <innocarpe@users.noreply.github.com>

* test(terminal): wait for probe output after command echo

* test(terminal): align RPC boundary with wider viewport limit

---------

Co-authored-by: innocarpe <innocarpe@users.noreply.github.com>
This commit is contained in:
Neil
2026-10-02 07:29:07 -07:00
committed by GitHub
co-authored by innocarpe
parent e2f2b707d2
commit de8bffe240
9 changed files with 228 additions and 54 deletions
@@ -154,6 +154,16 @@ describe('remote desktop viewer width driver', () => {
expect(driverEvents).toHaveLength(0)
})
it('keeps a wide desktop viewport through resize', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 500, 40)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 500, rows: 40 })
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 800, 40)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 800, rows: 40 })
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 2000, 40)
expect(runtime.getTerminalSize('pty-1')).toEqual({ cols: 1024, rows: 40 })
})
it('sizes the PTY to the latest active desktop viewer', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 40)
@@ -93,9 +93,16 @@ describe('terminal RPC manifest characterization', () => {
schemaFor('terminal.updateViewport').parse({
terminal: 'term',
client: { id: 'client' },
viewport: { cols: 241, rows: 120 }
viewport: { cols: 1025, rows: 120 }
})
).toThrow()
expect(() =>
schemaFor('terminal.updateViewport').parse({
terminal: 'term',
client: { id: 'client' },
viewport: { cols: 1024, rows: 120 }
})
).not.toThrow()
expect(() =>
schemaFor('terminal.subscribe').parse({
terminal: 'term',
+1 -7
View File
@@ -1,7 +1 @@
// Clamp terminal dimensions to the PTY's supported range (cols 20–240, rows 8–120).
export function clampTerminalViewport(cols: number, rows: number): { cols: number; rows: number } {
return {
cols: Math.max(20, Math.min(240, Math.round(cols))),
rows: Math.max(8, Math.min(120, Math.round(rows)))
}
}
export { clampTerminalViewport } from '../../shared/terminal-viewport'
@@ -13,38 +13,41 @@ describe('createPreviewGridClaim', () => {
vi.useRealTimers()
})
it('waits for a resize signal instead of polling while layout is unmeasurable', async () => {
vi.useFakeTimers()
const fit = vi.fn(async () => ({ cols: 90, rows: 30 }))
Object.assign(window, { api: { terminalPreview: { fit } } })
const box = document.createElement('div')
const container = document.createElement('div')
const screen = document.createElement('div')
screen.className = 'xterm-screen'
box.appendChild(container)
container.appendChild(screen)
dimension(box, 'clientWidth', 900)
dimension(box, 'clientHeight', 480)
dimension(screen, 'offsetWidth', 0)
dimension(screen, 'offsetHeight', 0)
const claim = createPreviewGridClaim({
ptyId: 'pty-1',
container,
getTerminal: () => ({ cols: 80, rows: 24 }) as never
})
it.each([90, 500, 1024, 2000])(
'waits for a measurable layout and fits %i columns',
async (cols) => {
vi.useFakeTimers()
const fit = vi.fn(async () => ({ cols: 90, rows: 30 }))
Object.assign(window, { api: { terminalPreview: { fit } } })
const box = document.createElement('div')
const container = document.createElement('div')
const screen = document.createElement('div')
screen.className = 'xterm-screen'
box.appendChild(container)
container.appendChild(screen)
dimension(box, 'clientWidth', cols * 10)
dimension(box, 'clientHeight', 480)
dimension(screen, 'offsetWidth', 0)
dimension(screen, 'offsetHeight', 0)
const claim = createPreviewGridClaim({
ptyId: 'pty-1',
container,
getTerminal: () => ({ cols: 80, rows: 24 }) as never
})
claim.schedule()
await vi.advanceTimersByTimeAsync(1_000)
expect(fit).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
claim.schedule()
await vi.advanceTimersByTimeAsync(1_000)
expect(fit).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
dimension(screen, 'offsetWidth', 800)
dimension(screen, 'offsetHeight', 384)
claim.schedule()
await vi.advanceTimersByTimeAsync(200)
expect(fit).toHaveBeenCalledWith('pty-1', 90, 30)
claim.dispose()
})
dimension(screen, 'offsetWidth', 800)
dimension(screen, 'offsetHeight', 384)
claim.schedule()
await vi.advanceTimersByTimeAsync(200)
expect(fit).toHaveBeenCalledWith('pty-1', Math.min(cols, 1024), 30)
claim.dispose()
}
)
it('coalesces a continuous resize burst into one settled fit request', async () => {
vi.useFakeTimers()
@@ -1,16 +1,7 @@
import type { Terminal } from '@xterm/xterm'
import { clampTerminalViewport } from '../../../../shared/terminal-viewport'
const FIT_REQUEST_DEBOUNCE_MS = 200
// Mirror the runtime's clampTerminalViewport so a request always matches what lands.
const FIT_MIN_COLS = 20
const FIT_MAX_COLS = 240
const FIT_MIN_ROWS = 8
const FIT_MAX_ROWS = 120
function clampGridAxis(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
/**
* Negotiates the PTY grid for the popout terminal dialog: measures the live
* terminal's cell size, computes the grid the dialog box can hold, and asks
@@ -52,11 +43,9 @@ export function createPreviewGridClaim(args: {
) {
return
}
const cols = clampGridAxis(Math.floor(box.clientWidth / cellWidth), FIT_MIN_COLS, FIT_MAX_COLS)
const rows = clampGridAxis(
Math.floor(box.clientHeight / cellHeight),
FIT_MIN_ROWS,
FIT_MAX_ROWS
const { cols, rows } = clampTerminalViewport(
Math.floor(box.clientWidth / cellWidth),
Math.floor(box.clientHeight / cellHeight)
)
const fitKey = `${cols}x${rows}`
if (fitKey === lastRequestedFit) {
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { TERMINAL_VIEWPORT_MAX_COLS } from '../terminal-viewport'
import { requiredString } from './rpc-param-primitives'
export const TerminalHandle = z.object({ terminal: requiredString('Missing terminal handle') })
@@ -41,7 +42,7 @@ export const TerminalUpdateViewport = TerminalHandle.extend({
type: z.enum(['mobile', 'desktop']).default('mobile').optional()
}),
viewport: z.object({
cols: z.number().int().min(20).max(240),
cols: z.number().int().min(20).max(TERMINAL_VIEWPORT_MAX_COLS),
rows: z.number().int().min(8).max(120)
}),
claim: z.boolean().optional()
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { clampTerminalViewport } from './terminal-viewport'
import { TerminalUpdateViewport } from './rpc-contract/terminal-viewport-schemas-params'
describe('terminal viewport', () => {
it.each([240, 500, 1024])('preserves %i columns in the clamp and RPC', (cols) => {
expect(clampTerminalViewport(cols, 40)).toEqual({ cols, rows: 40 })
expect(
TerminalUpdateViewport.parse({
terminal: 'pty-1',
client: { id: 'desktop-1' },
viewport: { cols, rows: 40 }
}).viewport
).toEqual({ cols, rows: 40 })
})
it('bounds oversized grids and preserves minimum dimensions and rounding', () => {
expect(clampTerminalViewport(2000, 200)).toEqual({ cols: 1024, rows: 120 })
expect(clampTerminalViewport(10, 4)).toEqual({ cols: 20, rows: 8 })
expect(clampTerminalViewport(500.4, 40.6)).toEqual({ cols: 500, rows: 41 })
expect(
TerminalUpdateViewport.safeParse({
terminal: 'pty-1',
client: { id: 'desktop-1' },
viewport: { cols: 1025, rows: 40 }
}).success
).toBe(false)
})
})
+17
View File
@@ -0,0 +1,17 @@
export const TERMINAL_VIEWPORT_MIN_COLS = 20
export const TERMINAL_VIEWPORT_MAX_COLS = 1024
export const TERMINAL_VIEWPORT_MIN_ROWS = 8
export const TERMINAL_VIEWPORT_MAX_ROWS = 120
export function clampTerminalViewport(cols: number, rows: number): { cols: number; rows: number } {
return {
cols: Math.max(
TERMINAL_VIEWPORT_MIN_COLS,
Math.min(TERMINAL_VIEWPORT_MAX_COLS, Math.round(cols))
),
rows: Math.max(
TERMINAL_VIEWPORT_MIN_ROWS,
Math.min(TERMINAL_VIEWPORT_MAX_ROWS, Math.round(rows))
)
}
}
@@ -0,0 +1,124 @@
import { expect, test } from './helpers/orca-app'
import {
createRuntimeDesktopPairingOffer,
launchPairedElectronClient
} from './helpers/paired-electron-client'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
execInTerminal,
focusActiveTerminalInput,
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
test('paired terminal fills a pane wider than 240 columns', async ({
orcaPage,
testRepoPath
}, testInfo) => {
test.skip(process.platform === 'win32', 'The width probe uses POSIX stty')
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage)
const hostPtyId = await waitForActivePanePtyId(orcaPage)
await execInTerminal(orcaPage, hostPtyId, "printf 'WIDE_PROBE_READY\\n'")
await waitForTerminalOutput(orcaPage, 'WIDE_PROBE_READY')
const client = await launchPairedElectronClient(
await createRuntimeDesktopPairingOffer(orcaPage),
testInfo,
'Wide terminal proof'
)
try {
const page = client.page
await page.setViewportSize({ width: 3200, height: 1000 })
await expect
.poll(
() =>
page.evaluate(
(repoPath) =>
window.__store
?.getState()
.allWorktrees()
.find((worktree) => worktree.path === repoPath)?.id,
testRepoPath
),
{ timeout: 60_000 }
)
.toBeTruthy()
await page.evaluate(
({ repoPath, environmentId }) => {
const state = window.__store?.getState()
const worktree = state?.allWorktrees().find((entry) => entry.path === repoPath)
if (!state || !worktree) {
throw new Error('Paired worktree unavailable')
}
state.setActiveWorktree(worktree.id, `runtime:${environmentId}`)
},
{ repoPath: testRepoPath, environmentId: client.environmentId }
)
await ensureTerminalVisible(page, 30_000)
await waitForActiveTerminalManager(page, 30_000)
await waitForTerminalOutput(page, 'WIDE_PROBE_READY', 30_000)
const ptyId = await waitForActivePanePtyId(page, 30_000)
expect(ptyId.startsWith(`remote:${client.environmentId}@@`)).toBe(true)
await expect
.poll(
() =>
page.evaluate(() => {
const state = window.__store?.getState()
const tabId = state?.activeWorktreeId
? state.activeTabIdByWorktree[state.activeWorktreeId]
: null
return tabId
? (window.__paneManagers?.get(tabId)?.getActivePane()?.terminal.cols ?? 0)
: 0
}),
{ timeout: 30_000 }
)
.toBeGreaterThan(240)
await focusActiveTerminalInput(page)
await page.keyboard.type(
"cols=$(stty size | awk '{print $2}'); printf '\\033[2J\\033[HPTY_WIDTH=%s\\r\\n' \"$cols\"; printf '%*s' \"$((cols - 10))\" '' | tr ' ' '='; printf 'RIGHT_EDGE\\r\\n'"
)
await page.keyboard.press('Enter')
await waitForTerminalOutput(page, 'RIGHT_EDGE', 30_000)
const readRendered = () =>
page.evaluate(() => {
const state = window.__store?.getState()
const tabId = state?.activeWorktreeId
? state.activeTabIdByWorktree[state.activeWorktreeId]
: null
const pane = tabId ? window.__paneManagers?.get(tabId)?.getActivePane() : null
if (!pane) {
throw new Error('Terminal unavailable')
}
const buffer = pane.terminal.buffer.active
return {
cols: pane.terminal.cols,
lines: Array.from(
{ length: pane.terminal.rows },
(_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? ''
)
}
})
await expect
.poll(
async () => {
const rendered = await readRendered()
return {
widthMatches: rendered.lines.includes(`PTY_WIDTH=${rendered.cols}`),
rightEdge: rendered.lines.some(
(line) => line.length === rendered.cols && line.endsWith('RIGHT_EDGE')
)
}
},
{ timeout: 30_000 }
)
.toEqual({ widthMatches: true, rightEdge: true })
await page.screenshot({ path: testInfo.outputPath('wide-terminal.png') })
} finally {
await client.dispose()
}
})