From 1aaf049a4d9edf18743d999ae2be90800cb78aaa Mon Sep 17 00:00:00 2001 From: moseoh Date: Sat, 25 Jul 2026 08:26:55 +0900 Subject: [PATCH 1/3] fix(rate-limits): keep Codex PTY reset text for weekly-only plans (#8643) * fix(rate-limits): keep Codex PTY reset text for weekly-only plans The PTY /status fallback parses '5h limit' and 'Weekly limit' lines by label, but the extracted reset text was only ever attached to the session window. Codex plans without a 5h session bucket (e.g. current Pro) produce a weekly-only parse, so the reset time the CLI printed was silently dropped. Fall back to the weekly window when no session window exists. * review: parse Codex PTY reset text per window into resetsAt * review: make Codex PTY status fallback work on codex >=0.145 * review: harden PTY status parse against model-scoped rows and styled output * fix(rate-limits): strip private PTY control sequences --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- src/main/git/runner-wsl-gh-fallback.test.ts | 14 +- src/main/persistence.test.ts | 16 +- src/main/persistence.ts | 4 +- .../rate-limits/claude-pty-reset-parser.ts | 65 ++++- .../codex-fetcher-pty-settle.test.ts | 225 +++++++++++++++++- src/main/rate-limits/codex-fetcher.ts | 136 +++++++++-- .../terminal-clipboard-event-paste.test.ts | 4 +- .../terminal-clipboard-event-paste.ts | 6 +- .../src/hooks/useIssueMetadata.test.tsx | 7 +- .../src/hooks/usePrimarySelectionPaste.ts | 5 +- 10 files changed, 422 insertions(+), 60 deletions(-) diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 2bfb60e0b2a..35a422e1b39 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 7cd465e6d54..8d41c81a3a8 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 ef22ddcc276..64b58d28300 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 7cadfe3fba4..43137a8cea5 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 c5088a2ae77..15c338aad89 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 e1d58ae659e..28a97a37da6 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 15bf439fdbe..e6cc6bf78c1 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 94564bc6b1b..c0b79d220d6 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 ebe9245cabe..0c98d0822e2 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) } } From d5340fd1913f21fde52fafb01f2c7d7fd3c520d2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:53:45 -0700 Subject: [PATCH 2/3] ci(release-cut): restore the skill-independent version commit (#10460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #10340 added a ledger-advance step to the release cut, which violates the contract test #9119 added: the cut must not run generate-skill-bundle-manifest or stage resources/skills. Both PRs were green on their own branches and only conflicted once merged, so nothing failed until main had both — main and every open PR have been red since. Revert the two workflow lines. The script's --release implementation stays: it is correct and harmless when unused, and the root fix in #10340 — verify no longer walking git tags — does not depend on the cut step. This leaves #10340 semantically incomplete and that must not be dropped. With the registry seeded from the committed ledger instead of a tag walk, nothing advances the ledger at cut, so each new skill change re-uses the same unreleased tail revision for different bytes; older installs then match no known snapshot and degrade to unrecognized, which reads in the UI as a skill that needs attention and cannot be updated. Follow-up is to reintroduce the advance narrowly — stage only resources/skills/release-mapping.json and narrow the assertion to forbid mutating the content-addressed artifacts while permitting the provenance row. --- .github/workflows/release-cut.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index a26210e001c..b99ca206605 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -521,14 +521,7 @@ jobs: # message and tag name explicitly (avoids npm's `v1.2.3` prefix # assumptions and any lifecycle scripts that would run on bump). npm version "$VERSION" --no-git-tag-version --allow-same-version - # Why: release cut is the single authoritative point where the released - # skill ledger advances — this version's working-tree bytes become an - # immutable released revision so future builds recognize them as an - # official snapshot. It is the only place skill artifacts regenerate; - # ordinary lint verifies working-tree bytes against the committed ledger - # and never walks tags. Uses only Node built-ins, so no install needed. - node config/scripts/generate-skill-bundle-manifest.mjs --release "$VERSION" - git add package.json resources/skills + git add package.json commit_message="release: v$VERSION" if [[ "$EVENT_NAME" == "schedule" ]]; then commit_message="$commit_message [rc-slot:$SLOT]" From 49e32ff2b4a9c2a80b530f301007b555b43b5667 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:08:02 -0700 Subject: [PATCH 3/3] fix: prevent UI freeze from dual-modal race in action sheets (#10432) Add closeBeforePress flag to Rename, Browser, and Refresh actions to defer modal opening until the action sheet closes. Eliminates the race condition that caused the mobile app to freeze when opening these modals. --- .../app/h/[hostId]/session/[worktreeId].tsx | 6 ++- ...bile-terminal-action-sheet-actions.test.ts | 38 +++++++++++++++++++ .../mobile-terminal-action-sheet-actions.ts | 2 +- 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 mobile/src/session/mobile-terminal-action-sheet-actions.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index da7c2502d4c..8b236bfeaff 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -5091,8 +5091,8 @@ export default function SessionScreen() { { label: 'Browser', icon: Globe, + closeBeforePress: true, onPress: () => { - setShowCreateTabDrawer(false) if (browserScreencastSupported !== true) { showToast('Desktop update required for mobile browser streaming', 1600) return @@ -5168,9 +5168,11 @@ export default function SessionScreen() { { label: 'Refresh', icon: RefreshCw, + // Why: dirty refresh opens ConfirmModal; wait for this sheet's native + // Modal to unmount first (same dual-Modal race as tab Rename, #10331). + closeBeforePress: true, onPress: () => { const target = markdownActionTarget - setMarkdownActionTarget(null) if (target) { discardMarkdownLocalContent(target) } diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.test.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.test.ts new file mode 100644 index 00000000000..c6cbc1188ba --- /dev/null +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { getMobileTerminalActionSheetActions } from './mobile-terminal-action-sheet-actions' + +vi.mock('lucide-react-native', () => ({ + Eraser: vi.fn(), + MessageSquare: vi.fn(), + Monitor: vi.fn(), + Smartphone: vi.fn(), + SquareTerminal: vi.fn() +})) + +describe('getMobileTerminalActionSheetActions', () => { + it('defers Rename until after the action sheet closes', () => { + const target = { handle: 'terminal-1' } + const onDismiss = vi.fn() + const onRename = vi.fn() + const actions = getMobileTerminalActionSheetActions({ + target, + tabs: [], + isTabChatView: () => false, + nativeChatTranscriptIsLocalReadable: true, + onDismiss, + onToggleChat: vi.fn(), + isPhoneMode: () => false, + onToggleDisplayMode: vi.fn(), + onRename, + onClear: vi.fn(), + onClose: vi.fn() + }) + + const rename = actions.find((action) => action.label === 'Rename') + expect(rename).toMatchObject({ closeBeforePress: true }) + + rename?.onPress() + expect(onRename).toHaveBeenCalledWith(target) + expect(onDismiss).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.ts index e263e32b80f..f21468ff449 100644 --- a/mobile/src/session/mobile-terminal-action-sheet-actions.ts +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.ts @@ -44,8 +44,8 @@ export function getMobileTerminalActionSheetActions { - args.onDismiss() args.onRename(target) } },