Merge remote-tracking branch 'origin/main' into brennanb2025/feature-project-level-worktree-shared-directories

This commit is contained in:
Brennan Benson
2026-07-24 17:08:16 -07:00
14 changed files with 466 additions and 71 deletions
+1 -8
View File
@@ -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]"
@@ -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)
}
@@ -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()
})
})
@@ -44,8 +44,8 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
},
{
label: 'Rename',
closeBeforePress: true,
onPress: () => {
args.onDismiss()
args.onRename(target)
}
},
+8 -6
View File
@@ -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(
+12 -4
View File
@@ -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 () => {
+3 -1
View File
@@ -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
}
@@ -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
@@ -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'
})
})
})
+112 -24
View File
@@ -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<Provi
// PTY fallback — spawn `codex`, send `/status`, parse rendered output
// ---------------------------------------------------------------------------
// Why: match the Codex CLI /status output ("5h limit"/"Weekly limit" lines with a percent and optional reset text).
const FIVE_HOUR_RE = /5h\s+limit[:\s]*(\d+)%/i
const WEEKLY_RE = /weekly\s+limit[:\s]*(\d+)%/i
const RESET_TEXT_RE = /resets?\s+(?:at\s+|in\s+)?(.+)/i
// Why: match the Codex CLI /status output ("5h limit"/"Weekly limit" lines). Newer
// CLIs render a meter between the label and the percent ("Weekly limit: [███░] 43% left"),
// so skip any non-digit run and capture the used/left word to orient the number.
// The lookbehind rejects model-scoped rows ("GPT-…-Spark Weekly limit") so they are
// never selected as the account window regardless of row order; line-start anchoring
// is unusable here because stripping cursor-move sequences merges visual lines.
const FIVE_HOUR_RE = /(?<![\w-][^\S\r\n]{0,4})5h\s+limit[^\d%\r\n]*(\d+)%(?:\s*(used|left))?/i
const WEEKLY_RE = /(?<![\w-][^\S\r\n]{0,4})weekly\s+limit[^\d%\r\n]*(\d+)%(?:\s*(used|left))?/i
// Why: model-scoped limit rows must still stop a per-window reset-text scan.
const ANY_LIMIT_LABEL_RE = /(?:5h|weekly)\s+limit/i
// eslint-disable-next-line no-control-regex
const PTY_CONTROL_SEQUENCE_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g
function stripPtyControlSequences(output: string): string {
return output.replace(PTY_CONTROL_SEQUENCE_RE, '')
}
function isPtyLimitLabel(line: string): boolean {
return ANY_LIMIT_LABEL_RE.test(line)
}
function ptyUsedPercent(match: RegExpExecArray): number {
const pct = Number.parseInt(match[1], 10)
const oriented = match[2]?.toLowerCase() === 'left' ? 100 - pct : pct
return Math.min(100, Math.max(0, oriented))
}
function parsePtyStatus(output: string): {
session: RateLimitWindow | null
@@ -781,31 +814,38 @@ function parsePtyStatus(output: string): {
} {
const fiveMatch = FIVE_HOUR_RE.exec(output)
const weeklyMatch = WEEKLY_RE.exec(output)
const lines = output.split(/\r\n|\n|\r/)
// Why: each limit line owns the reset text that follows it (weekly-only plans
// have no 5h line), and parsing it into resetsAt is what the UI renders.
const sessionReset = extractClaudePtyResetMetadata(
lines,
(line) => 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<Provi
})
const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)]
let statusEnter: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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<Provi
output = output.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH)
}
armStatusNudge()
// Wait for prompt, then send /status
if (!sentStatus && />\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<Provi
}
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
// eslint-disable-next-line no-control-regex
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
const clean = stripPtyControlSequences(output)
const { session, weekly } = parsePtyStatus(clean)
resolve({
@@ -955,8 +1044,7 @@ async function fetchViaPty(options?: FetchCodexRateLimitsOptions): Promise<Provi
clearTimeout(timeout)
timeout = null
}
// eslint-disable-next-line no-control-regex
const clean = output.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
const clean = stripPtyControlSequences(output)
const { session, weekly } = parsePtyStatus(clean)
resolve({
provider: 'codex',
@@ -66,9 +66,7 @@ describe('firesNativePasteEvent', () => {
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', () => {
@@ -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
)
}
@@ -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'])
})
})
@@ -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)
}
}