mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/feature-project-level-worktree-shared-directories
This commit is contained in:
@@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user