diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 2bfb60e0b2aa..35a422e1b39f 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -643,9 +643,10 @@ describe('ghExecFileAsync WSL fallback', () => { callback(new Error('Wrong distro fallback')) }) - await expect( - ghExecFileAsync(['auth', 'status']) - ).resolves.toEqual({ stdout: 'Logged in to github.com as override', stderr: '' }) + await expect(ghExecFileAsync(['auth', 'status'])).resolves.toEqual({ + stdout: 'Logged in to github.com as override', + stderr: '' + }) expect(execFileMock).toHaveBeenCalledTimes(2) expect(execFileMock).toHaveBeenNthCalledWith( @@ -672,9 +673,10 @@ describe('ghExecFileAsync WSL fallback', () => { callback(new Error('Wrong distro fallback')) }) - await expect( - ghExecFileAsync(['auth', 'status']) - ).resolves.toEqual({ stdout: 'Logged in to github.com as default', stderr: '' }) + await expect(ghExecFileAsync(['auth', 'status'])).resolves.toEqual({ + stdout: 'Logged in to github.com as default', + stderr: '' + }) expect(execFileMock).toHaveBeenCalledTimes(2) expect(execFileMock).toHaveBeenNthCalledWith( diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 7cd465e6d545..8d41c81a3a85 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -3652,7 +3652,9 @@ describe('Store', () => { store.removeProjectForHost('shared', 'ssh:ssh-a') // The removed SSH host's session is pruned; the surviving local session stays. - expect(store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo']).toBeUndefined() + expect( + store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo'] + ).toBeUndefined() expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(111) }) @@ -3684,7 +3686,9 @@ describe('Store', () => { store.removeProjectForHost('shared', 'local') expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['shared::/repo']).toBeUndefined() - expect(store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(222) + expect( + store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo'] + ).toBe(222) }) it('removeProjectForHost prunes only the removed host when a third host also shares the owner key', async () => { @@ -3734,9 +3738,13 @@ describe('Store', () => { store.removeProjectForHost('shared', 'ssh:ssh-a') // Only the removed host's partition is pruned; local and the other SSH host survive. - expect(store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo']).toBeUndefined() + expect( + store.getWorkspaceSession('ssh:ssh-a').lastVisitedAtByWorktreeId?.['shared::/repo'] + ).toBeUndefined() expect(store.getWorkspaceSession().lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(111) - expect(store.getWorkspaceSession('ssh:ssh-b').lastVisitedAtByWorktreeId?.['shared::/repo']).toBe(333) + expect( + store.getWorkspaceSession('ssh:ssh-b').lastVisitedAtByWorktreeId?.['shared::/repo'] + ).toBe(333) }) it('reorderReposForHost independently reorders local and SSH rows with shared ids', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index ef22ddcc276b..64b58d28300e 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -2499,7 +2499,9 @@ function removeWorkspaceSessionOwners( for (const ownerKey of ownerKeys) { deleteOwnerKeyedSessionFields(next, ownerKey, removedTabIds) } - deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) => ownerKeys.has(worktreeId)) + deleteScannedSessionFieldsForOwners(next, removedTabIds, (worktreeId) => + ownerKeys.has(worktreeId) + ) return next } diff --git a/src/main/rate-limits/claude-pty-reset-parser.ts b/src/main/rate-limits/claude-pty-reset-parser.ts index 7cadfe3fba4a..43137a8cea54 100644 --- a/src/main/rate-limits/claude-pty-reset-parser.ts +++ b/src/main/rate-limits/claude-pty-reset-parser.ts @@ -15,6 +15,9 @@ const MONTH_DAY_TIME_RE = new RegExp( const WEEKDAY_TIME_RE = /\b(sun(?:day)?|mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?)\.?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i const TIME_ONLY_RE = /\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i +// Why: newer Codex CLIs print 24-hour reset times ("10:21 on 28 Jul") with no am/pm. +const TIME_24H_RE = /\b(\d{1,2}):(\d{2})\b/ +const DAY_MONTH_RE = new RegExp(`\\b(?:on\\s+)?(\\d{1,2})\\s+(${MONTH_PATTERN})\\b`, 'i') const RELATIVE_RESET_RE = /^(?:\s*\d+\s*(?:d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)\s*)+$/i const RELATIVE_RESET_TOKEN_RE = /(\d+)\s*(d(?:ays?)?|h(?:ours?|rs?)?|m(?:in(?:ute)?s?)?)/gi const IANA_TIME_ZONE_RE = /\(([^()]*)\)?\s*$/ @@ -94,13 +97,16 @@ export function extractClaudePtyResetMetadata( function normalizeResetDescription(raw: string): string { // Why: Claude's TUI occasionally drops spaces around the Fable reset date // when copied from the PTY buffer, but the value still encodes a real reset. - return raw - .trim() - .replace(/[)]+$/, '') - .replace(/\s+/g, ' ') - .replace(MONTH_DAY_COMPACT_RE, '$1 $2') - .replace(/(\d{1,2})\s*at\s*(\d{1,2}(?::\d{2})?\s*(?:am|pm))/i, '$1 at $2') - .replace(/(\d)(am|pm)\(/gi, '$1$2 (') + return ( + raw + .trim() + // Why: PTY captures keep trailing box-border glyphs from framed status panels. + .replace(/[)\s│]+$/, '') + .replace(/\s+/g, ' ') + .replace(MONTH_DAY_COMPACT_RE, '$1 $2') + .replace(/(\d{1,2})\s*at\s*(\d{1,2}(?::\d{2})?\s*(?:am|pm))/i, '$1 at $2') + .replace(/(\d)(am|pm)\(/gi, '$1$2 (') + ) } function parseResetTimestamp(resetDescription: string | null): number | null { @@ -112,10 +118,53 @@ function parseResetTimestamp(resetDescription: string | null): number | null { parseRelativeResetTimestamp(resetDescription) ?? parseMonthDayResetTimestamp(resetDescription) ?? parseWeekdayResetTimestamp(resetDescription) ?? - parseTimeOnlyResetTimestamp(resetDescription) + parseTimeOnlyResetTimestamp(resetDescription) ?? + parseTwentyFourHourResetTimestamp(resetDescription) ) } +function parseTwentyFourHourResetTimestamp(resetDescription: string): number | null { + const resetText = stripResetTimeZone(resetDescription) + const timeMatch = TIME_24H_RE.exec(resetText) + if (!timeMatch) { + return null + } + const hour = Number(timeMatch[1]) + const minute = Number(timeMatch[2]) + if (!isValidClockTime(hour, minute)) { + return null + } + + const dayMonthMatch = DAY_MONTH_RE.exec(resetText) + if (dayMonthMatch) { + const day = Number(dayMonthMatch[1]) + const monthIndex = MONTH_INDEX_BY_NAME[dayMonthMatch[2].toLowerCase()] + if (monthIndex === undefined || day < 1 || day > 31) { + return null + } + const now = new Date() + const timeZone = extractResetTimeZone(resetDescription) + let timestamp = buildWallClockTimestamp( + { year: now.getFullYear(), monthIndex, day, hour, minute }, + timeZone + ) + if (timestamp !== null && timestamp <= Date.now()) { + timestamp = buildWallClockTimestamp( + { year: now.getFullYear() + 1, monthIndex, day, hour, minute }, + timeZone + ) + } + return timestamp + } + + const candidate = new Date() + candidate.setHours(hour, minute, 0, 0) + if (candidate.getTime() <= Date.now()) { + candidate.setDate(candidate.getDate() + 1) + } + return candidate.getTime() +} + function parseRelativeResetTimestamp(resetDescription: string): number | null { if (!RELATIVE_RESET_RE.test(resetDescription)) { return null diff --git a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts index c5088a2ae776..15c338aad89b 100644 --- a/src/main/rate-limits/codex-fetcher-pty-settle.test.ts +++ b/src/main/rate-limits/codex-fetcher-pty-settle.test.ts @@ -61,12 +61,14 @@ describe('fetchCodexRateLimits PTY settle timers', () => { } onPtyData('>') - expect(vi.getTimerCount()).toBe(1) + // Pending: PTY timeout + the delayed /status Enter keypress. + expect(vi.getTimerCount()).toBe(2) onPtyData('5h limit: 17%\n') onPtyData('Weekly limit: 23%\n') onPtyData('still rendering\n') - expect(vi.getTimerCount()).toBe(2) + // One settle timer armed — not one per data chunk. + expect(vi.getTimerCount()).toBe(3) await vi.advanceTimersByTimeAsync(500) @@ -76,4 +78,223 @@ describe('fetchCodexRateLimits PTY settle timers', () => { status: 'ok' }) }) + + it('keeps the reset text on the weekly window for weekly-only plans', async () => { + const ptyHandlers: { onData?: (data: string) => void } = {} + + childSpawnMock.mockImplementation(() => { + throw new Error('rpc unavailable') + }) + ptySpawnMock.mockReturnValue({ + onData: vi.fn((callback) => { + ptyHandlers.onData = callback + return makeDisposable() + }), + onExit: vi.fn(() => makeDisposable()), + write: vi.fn(), + kill: vi.fn() + }) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) + + const onPtyData = ptyHandlers.onData + if (!onPtyData) { + throw new Error('PTY data handler was not registered') + } + + onPtyData('>') + onPtyData('Weekly limit: 76%\nResets in 5d 23h\n') + + await vi.advanceTimersByTimeAsync(500) + + const fiveDays23h = (5 * 24 + 23) * 60 * 60 * 1000 + await expect(resultPromise).resolves.toMatchObject({ + session: null, + weekly: { + usedPercent: 76, + resetDescription: '5d 23h', + resetsAt: Date.now() + fiveDays23h + }, + status: 'ok' + }) + }) + + it('keeps each window reset text on its own window for dual-window plans', async () => { + const ptyHandlers: { onData?: (data: string) => void } = {} + + childSpawnMock.mockImplementation(() => { + throw new Error('rpc unavailable') + }) + ptySpawnMock.mockReturnValue({ + onData: vi.fn((callback) => { + ptyHandlers.onData = callback + return makeDisposable() + }), + onExit: vi.fn(() => makeDisposable()), + write: vi.fn(), + kill: vi.fn() + }) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) + + const onPtyData = ptyHandlers.onData + if (!onPtyData) { + throw new Error('PTY data handler was not registered') + } + + onPtyData('>') + onPtyData('5h limit: 17% (resets in 2h 30m)\nWeekly limit: 23% (resets in 5d 3h)\n') + + await vi.advanceTimersByTimeAsync(500) + + await expect(resultPromise).resolves.toMatchObject({ + session: { + usedPercent: 17, + resetDescription: '2h 30m', + resetsAt: Date.now() + (2 * 60 + 30) * 60 * 1000 + }, + weekly: { + usedPercent: 23, + resetDescription: '5d 3h', + resetsAt: Date.now() + (5 * 24 + 3) * 60 * 60 * 1000 + }, + status: 'ok' + }) + }) + + it('parses the framed codex 0.145 status panel via the /status nudge', async () => { + const ptyHandlers: { onData?: (data: string) => void } = {} + const write = vi.fn() + + childSpawnMock.mockImplementation(() => { + throw new Error('rpc unavailable') + }) + ptySpawnMock.mockReturnValue({ + onData: vi.fn((callback) => { + ptyHandlers.onData = callback + return makeDisposable() + }), + onExit: vi.fn(() => makeDisposable()), + write, + kill: vi.fn() + }) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) + + const onPtyData = ptyHandlers.onData + if (!onPtyData) { + throw new Error('PTY data handler was not registered') + } + + // codex ≥0.145 shows a '›' composer with placeholder text, never a bare '>' prompt. + onPtyData('›Summarize recent commits') + expect(write).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(2500) + expect(write).toHaveBeenCalledWith('/status') + await vi.advanceTimersByTimeAsync(350) + expect(write).toHaveBeenCalledWith('\r') + + onPtyData( + '│ Weekly limit: \x1b[?2026h\x1b[0 q[█████████░░░░░░░░░░░] 43% left\x1b[?2026l (resets 10:21 on 28 Jul) │\n' + + '│ GPT-5.3-Codex-Spark Weekly limit: [████████████████████] 100% left (resets 17:40 on 29 Jul) │\n' + ) + await vi.advanceTimersByTimeAsync(500) + + const expectedReset = new Date(new Date().getFullYear(), 6, 28, 10, 21) + if (expectedReset.getTime() <= Date.now()) { + expectedReset.setFullYear(expectedReset.getFullYear() + 1) + } + await expect(resultPromise).resolves.toMatchObject({ + session: null, + weekly: { + usedPercent: 57, + resetDescription: '10:21 on 28 Jul', + resetsAt: expectedReset.getTime() + }, + status: 'ok' + }) + }) + + it('never selects a model-scoped weekly row even when it renders first', async () => { + const ptyHandlers: { onData?: (data: string) => void } = {} + + childSpawnMock.mockImplementation(() => { + throw new Error('rpc unavailable') + }) + ptySpawnMock.mockReturnValue({ + onData: vi.fn((callback) => { + ptyHandlers.onData = callback + return makeDisposable() + }), + onExit: vi.fn(() => makeDisposable()), + write: vi.fn(), + kill: vi.fn() + }) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) + + const onPtyData = ptyHandlers.onData + if (!onPtyData) { + throw new Error('PTY data handler was not registered') + } + + onPtyData('>') + onPtyData( + '│ GPT-5.3-Codex-Spark Weekly limit: [████████████████████] 100% left (resets 17:40 on 29 Jul) │\n' + + '│ Weekly limit: [█████████░░░░░░░░░░░] 43% left (resets 10:21 on 28 Jul) │\n' + ) + await vi.advanceTimersByTimeAsync(500) + + await expect(resultPromise).resolves.toMatchObject({ + session: null, + weekly: { usedPercent: 57, resetDescription: '10:21 on 28 Jul' }, + status: 'ok' + }) + }) + + it('re-sends Enter once when the panel does not render after the first submit', async () => { + const ptyHandlers: { onData?: (data: string) => void } = {} + const write = vi.fn() + + childSpawnMock.mockImplementation(() => { + throw new Error('rpc unavailable') + }) + ptySpawnMock.mockReturnValue({ + onData: vi.fn((callback) => { + ptyHandlers.onData = callback + return makeDisposable() + }), + onExit: vi.fn(() => makeDisposable()), + write, + kill: vi.fn() + }) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(0) + + const onPtyData = ptyHandlers.onData + if (!onPtyData) { + throw new Error('PTY data handler was not registered') + } + + onPtyData('>') + await vi.advanceTimersByTimeAsync(350) + expect(write.mock.calls.filter((call) => call[0] === '\r')).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(3000) + expect(write.mock.calls.filter((call) => call[0] === '\r')).toHaveLength(2) + + onPtyData('Weekly limit: 76%\nResets in 5d 23h\n') + await vi.advanceTimersByTimeAsync(500) + + await expect(resultPromise).resolves.toMatchObject({ + session: null, + weekly: { usedPercent: 76 }, + status: 'ok' + }) + }) }) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index e1d58ae659e4..28a97a37da62 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -10,6 +10,7 @@ import { homedir } from 'node:os' import { cancelUnreadResponseBody } from '../lib/unread-response-body' import { join } from 'node:path' import { probeCodexAuthPresence } from './codex-auth-presence' +import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' import { classifyCodexRateLimitWindows, CODEX_SESSION_WINDOW_MINUTES, @@ -39,6 +40,15 @@ import { const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 const PTY_TIMEOUT_MS = 15_000 +// Why: codex ≥0.145 renders a '›' composer with placeholder text after it, so a +// prompt-anchored send can never fire; nudge /status after a short boot grace. +const PTY_STATUS_NUDGE_MS = 2_500 +// Why: '/status\r' in one write coalesces into a paste-like chunk and the TUI +// inserts the newline instead of submitting; Enter must be its own keypress. +const PTY_STATUS_ENTER_DELAY_MS = 350 +// Why: slow hosts (WSL/SSH) can drop the first Enter while the TUI is still +// booting; one spare Enter is a no-op on an empty, ready composer. +const PTY_STATUS_ENTER_RETRY_MS = 3_000 const BACKEND_TIMEOUT_MS = 10_000 // Why: redeeming a reset credit is an explicit user action, not a poll — allow more time for a slow backend. const REDEEM_BACKEND_TIMEOUT_MS = 30_000 @@ -770,10 +780,33 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise FIVE_HOUR_RE.test(line), + isPtyLimitLabel + ) + const weeklyReset = extractClaudePtyResetMetadata( + lines, + (line) => WEEKLY_RE.test(line), + isPtyLimitLabel + ) const session: RateLimitWindow | null = fiveMatch ? { - usedPercent: Math.min(100, Number.parseInt(fiveMatch[1], 10)), + usedPercent: ptyUsedPercent(fiveMatch), windowMinutes: 300, - resetsAt: null, - resetDescription: null + resetsAt: sessionReset.resetsAt, + resetDescription: sessionReset.resetDescription } : null const weekly: RateLimitWindow | null = weeklyMatch ? { - usedPercent: Math.min(100, Number.parseInt(weeklyMatch[1], 10)), + usedPercent: ptyUsedPercent(weeklyMatch), windowMinutes: 10080, - resetsAt: null, - resetDescription: null + resetsAt: weeklyReset.resetsAt, + resetDescription: weeklyReset.resetDescription } : null - // Try to extract reset time from surrounding text - const resetMatch = RESET_TEXT_RE.exec(output) - if (resetMatch && session) { - session.resetDescription = resetMatch[1].trim() - } - return { session, weekly } } @@ -845,6 +885,53 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise void }[] = [registerHiddenRateLimitPty(term)] + let statusEnter: ReturnType | null = null + function sendStatusCommand(): void { + sentStatus = true + if (statusNudge) { + clearTimeout(statusNudge) + statusNudge = null + } + term.write('/status') + statusEnter = setTimeout(() => { + statusEnter = null + term.write('\r') + statusEnter = setTimeout(() => { + statusEnter = null + if (!resolved && !settleTimer) { + term.write('\r') + } + }, PTY_STATUS_ENTER_RETRY_MS) + }, PTY_STATUS_ENTER_DELAY_MS) + } + + let statusNudge: ReturnType | null = null + // Why: count the nudge grace from first TUI output, not spawn, so slow + // WSL/SSH boots get the full window before /status is typed. + function armStatusNudge(): void { + if (statusNudge || sentStatus || resolved) { + return + } + statusNudge = setTimeout(() => { + statusNudge = null + if (!resolved && !sentStatus) { + sendStatusCommand() + } + }, PTY_STATUS_NUDGE_MS) + } + termDisposables.push({ + dispose: () => { + if (statusNudge) { + clearTimeout(statusNudge) + statusNudge = null + } + if (statusEnter) { + clearTimeout(statusEnter) + statusEnter = null + } + } + }) + function settleAborted(): void { if (resolved) { return @@ -899,15 +986,18 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise\s*$/.test(data)) { - sentStatus = true - term.write('/status\r') + if (!sentStatus && /[>›]\s*$/.test(data)) { + sendStatusCommand() return } // Check if we have parseable output - if (sentStatus && !settleTimer && (FIVE_HOUR_RE.test(output) || WEEKLY_RE.test(output))) { + // Why: colored meter bars embed digits inside CSI sequences, so probe cleaned text. + const probe = sentStatus && !settleTimer ? stripPtyControlSequences(output) : null + if (probe !== null && (FIVE_HOUR_RE.test(probe) || WEEKLY_RE.test(probe))) { // Why: the TUI keeps streaming after status is parseable; one settle timer lets the panel finish flushing. settleTimer = setTimeout(() => { settleTimer = null @@ -921,8 +1011,7 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise { expect( firesNativePasteEvent(makeKeyEvent({ key: 'v', ctrlKey: true, shiftKey: true }), false) ).toBe(true) - expect( - firesNativePasteEvent(makeKeyEvent({ key: 'Insert', shiftKey: true }), false) - ).toBe(true) + expect(firesNativePasteEvent(makeKeyEvent({ key: 'Insert', shiftKey: true }), false)).toBe(true) }) it('does not treat a remapped non-clipboard chord as a native paste event', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-clipboard-event-paste.ts b/src/renderer/src/components/terminal-pane/terminal-clipboard-event-paste.ts index 15bf439fdbe1..e6cc6bf78c1d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-clipboard-event-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-clipboard-event-paste.ts @@ -37,10 +37,6 @@ export function firesNativePasteEvent(event: PasteChordEvent, isMac: boolean): b return true } return ( - event.key === 'Insert' && - event.shiftKey && - !event.ctrlKey && - !event.metaKey && - !event.altKey + event.key === 'Insert' && event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey ) } diff --git a/src/renderer/src/hooks/useIssueMetadata.test.tsx b/src/renderer/src/hooks/useIssueMetadata.test.tsx index 94564bc6b1b9..c0b79d220d63 100644 --- a/src/renderer/src/hooks/useIssueMetadata.test.tsx +++ b/src/renderer/src/hooks/useIssueMetadata.test.tsx @@ -185,11 +185,6 @@ describe('useIssueMetadata Linear hooks', () => { await flushEffects() expect(linearMocks.linearTeamStates).toHaveBeenCalledTimes(2) - expect(states.map((s) => s.id).sort()).toEqual([ - 'be-done', - 'be-todo', - 'fe-review', - 'fe-todo' - ]) + expect(states.map((s) => s.id).sort()).toEqual(['be-done', 'be-todo', 'fe-review', 'fe-todo']) }) }) diff --git a/src/renderer/src/hooks/usePrimarySelectionPaste.ts b/src/renderer/src/hooks/usePrimarySelectionPaste.ts index ebe9245cabe1..0c98d0822e21 100644 --- a/src/renderer/src/hooks/usePrimarySelectionPaste.ts +++ b/src/renderer/src/hooks/usePrimarySelectionPaste.ts @@ -110,7 +110,10 @@ export function usePrimarySelectionPaste(enabled: boolean): void { // a pending DOM target, so honor its armed window to swallow the follow-up // native paste event that xterm would otherwise forward to the PTY — but // only for the terminal's own surface, never unrelated document pastes. - if (isTerminalNativePasteTarget(event.target) && shouldSuppressPrimarySelectionNativePaste()) { + if ( + isTerminalNativePasteTarget(event.target) && + shouldSuppressPrimarySelectionNativePaste() + ) { suppressEvent(event) } }