diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 664bb4c28b3..e29a7426371 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -2,8 +2,6 @@ # This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green — # split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines"). # Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only) -inline src/cli/handlers/automations.ts -inline src/cli/help.ts inline src/main/agent-hooks/server.ts inline src/main/automations/external-manager.ts inline src/main/automations/hermes-cron-output.ts @@ -12,7 +10,6 @@ inline src/main/browser/browser-cookie-import.ts inline src/main/browser/browser-manager.ts inline src/main/browser/cdp-bridge.ts inline src/main/claude-accounts/runtime-auth-service.ts -inline src/main/cli/wsl-cli-installer.ts inline src/main/codex-accounts/runtime-home-service.ts inline src/main/codex-accounts/service.ts inline src/main/codex/hook-service.ts @@ -23,14 +20,10 @@ inline src/main/index.ts inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts inline src/main/ipc/worktree-remote.ts -inline src/main/keybindings/keybinding-file.ts inline src/main/linear/issues.ts -inline src/main/memory/collector.ts inline src/main/ports/advertised-url-watcher.ts inline src/main/ports/local-workspace-port-scanner.ts -inline src/main/project-groups/nested-repo-discovery.ts inline src/main/providers/local-pty-provider.ts -inline src/main/rate-limits/claude-pty.ts inline src/main/rate-limits/service.ts inline src/main/runtime/orca-runtime-browser.ts inline src/main/runtime/orca-runtime-files.ts @@ -69,7 +62,6 @@ inline src/renderer/src/components/right-sidebar/PortsPanel.tsx inline src/renderer/src/components/settings/AccountsPane.tsx inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx inline src/renderer/src/components/settings/Settings.tsx -inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx inline src/renderer/src/components/status-bar/StatusBar.tsx inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -82,24 +74,16 @@ inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts inline src/renderer/src/runtime/runtime-file-client.ts -inline src/renderer/src/runtime/runtime-linear-client.ts inline src/renderer/src/runtime/sync-runtime-graph.ts inline src/renderer/src/runtime/web-runtime-session.ts inline src/renderer/src/runtime/web-session-tabs-sync.ts inline src/renderer/src/store/slices/agent-status.ts inline src/renderer/src/store/slices/browser.ts -inline src/renderer/src/store/slices/diffComments.ts -inline src/renderer/src/store/slices/hosted-review.ts inline src/renderer/src/store/slices/linear.ts inline src/renderer/src/store/slices/tabs.ts inline src/renderer/src/store/slices/ui.ts -inline src/shared/automation-schedules.ts -inline src/shared/commit-message-agent-spec.ts -inline src/shared/constants.ts inline src/shared/keybindings.ts -inline src/shared/marine-creatures.ts inline src/shared/telemetry-events.ts -inline src/shared/text-search.ts inline tests/e2e/helpers/terminal.ts mobile-config app/h/*/files/*.tsx mobile-config app/h/*/index.tsx @@ -107,15 +91,9 @@ mobile-config app/h/*/session/*.tsx mobile-config app/h/*/source-control/*.tsx mobile-config app/h/*/tasks.tsx mobile-config app/index.tsx -mobile-config app/pair-scan.tsx mobile-config app/troubleshoot.tsx mobile-config scripts/mock-server.ts -mobile-config scripts/repro-terminal-colors.ts -mobile-config scripts/repro-worktree-startup-stream.ts mobile-config src/browser/MobileBrowserPane.tsx -mobile-config src/components/CustomKeyModal.tsx mobile-config src/components/NewWorktreeModal.tsx -mobile-config src/components/mobile-rich-markdown-editor-html.ts -mobile-config src/terminal/terminal-accessory-keys.ts mobile-config src/terminal/terminal-webview-html.ts mobile-config src/transport/rpc-client.ts diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 877a6d7e123..f3338fc3616 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -75,54 +75,18 @@ "max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }] } }, - { - "files": ["src/components/mobile-rich-markdown-editor-html.ts"], - "rules": { - "max-lines": ["error", { "max": 648, "skipBlankLines": true, "skipComments": true }] - } - }, - { - "files": ["src/components/CustomKeyModal.tsx"], - "rules": { - "max-lines": ["error", { "max": 645, "skipBlankLines": true, "skipComments": true }] - } - }, - { - "files": ["app/pair-scan.tsx"], - "rules": { - "max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }] - } - }, { "files": ["scripts/mock-server.ts"], "rules": { "max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }] } }, - { - "files": ["src/terminal/terminal-accessory-keys.ts"], - "rules": { - "max-lines": ["error", { "max": 388, "skipBlankLines": true, "skipComments": true }] - } - }, { "files": ["app/troubleshoot.tsx"], "rules": { "max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }] } }, - { - "files": ["scripts/repro-worktree-startup-stream.ts"], - "rules": { - "max-lines": ["error", { "max": 326, "skipBlankLines": true, "skipComments": true }] - } - }, - { - "files": ["scripts/repro-terminal-colors.ts"], - "rules": { - "max-lines": ["error", { "max": 319, "skipBlankLines": true, "skipComments": true }] - } - }, { "files": ["app/h/*/files/*.tsx"], "rules": { diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index c78d7a907df..61e99f19f2a 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -2,7 +2,6 @@ import { useState, useRef, useCallback } from 'react' import { View, Text, - StyleSheet, Pressable, ActivityIndicator, Linking, @@ -19,13 +18,14 @@ import { } from '../src/transport/pre-profile-pairing-coordinator' import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types' import { useRefreshHostClient } from '../src/transport/client-context' -import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' +import { colors, spacing } from '../src/theme/mobile-theme' import { TextInputModal } from '../src/components/TextInputModal' import { ConnectionLog } from '../src/components/ConnectionLog' import { loadMobileOnboardingSteps, mobileOnboardingDestination } from '../src/onboarding/mobile-onboarding-plan' +import { pairScanStyles as styles } from '../src/pair-scan-styles' // Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken // route surfaces as a real error with the log visible instead of a @@ -356,186 +356,3 @@ export default function PairScanScreen() { ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - padding: spacing.lg - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginBottom: spacing.sm - }, - steps: { - gap: spacing.sm, - marginBottom: spacing.lg, - marginLeft: 7 - }, - step: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - stepBadge: { - width: 22, - height: 22, - borderRadius: 11, - backgroundColor: colors.bgRaised, - alignItems: 'center', - justifyContent: 'center' - }, - stepNumber: { - fontSize: 12, - fontWeight: '700', - color: colors.textSecondary - }, - stepText: { - fontSize: typography.bodySize, - color: colors.textSecondary - }, - cameraWrap: { - flex: 1, - borderRadius: radii.camera, - overflow: 'hidden' - }, - // Why: holds the layout slot while the camera is unmounted during - // paste, so the bottom action button doesn't snap up to fill the - // empty space. - cameraPlaceholder: { - flex: 1, - backgroundColor: colors.bgPanel, - borderRadius: radii.camera - }, - camera: { - ...StyleSheet.absoluteFillObject - }, - reticle: { - ...StyleSheet.absoluteFillObject, - alignItems: 'center', - justifyContent: 'center' - }, - reticleFrame: { - position: 'relative' - }, - corner: { - position: 'absolute', - width: 28, - height: 28, - borderColor: 'rgba(255,255,255,0.7)' - }, - cornerTL: { - top: 0, - left: 0, - borderTopWidth: 2.5, - borderLeftWidth: 2.5, - borderTopLeftRadius: 6 - }, - cornerTR: { - top: 0, - right: 0, - borderTopWidth: 2.5, - borderRightWidth: 2.5, - borderTopRightRadius: 6 - }, - cornerBL: { - bottom: 0, - left: 0, - borderBottomWidth: 2.5, - borderLeftWidth: 2.5, - borderBottomLeftRadius: 6 - }, - cornerBR: { - bottom: 0, - right: 0, - borderBottomWidth: 2.5, - borderRightWidth: 2.5, - borderBottomRightRadius: 6 - }, - centered: { - flex: 1, - alignItems: 'center', - justifyContent: 'center' - }, - title: { - fontSize: typography.titleSize, - fontWeight: '600', - color: colors.textPrimary, - marginBottom: spacing.sm - }, - subtitle: { - maxWidth: 310, - fontSize: typography.bodySize, - color: colors.textSecondary, - textAlign: 'center', - marginBottom: spacing.xl, - lineHeight: 20 - }, - connectingText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - marginTop: spacing.lg - }, - logSlot: { - width: '100%', - marginTop: spacing.lg, - paddingHorizontal: spacing.sm - }, - errorText: { - color: colors.statusRed, - fontSize: typography.bodySize, - textAlign: 'center', - marginBottom: spacing.xl, - lineHeight: 20 - }, - primaryButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs, - backgroundColor: colors.textPrimary, - paddingHorizontal: spacing.xl, - paddingVertical: spacing.sm + 2, - borderRadius: radii.button - }, - primaryButtonText: { - color: colors.bgBase, - fontSize: typography.bodySize, - fontWeight: '600' - }, - pasteButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs, - marginTop: spacing.md, - paddingVertical: spacing.sm, - borderRadius: radii.button - }, - pasteButtonPressed: { - opacity: 0.6 - }, - pasteButtonText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - fontWeight: '500' - }, - errorActions: { - alignItems: 'center', - gap: spacing.sm - }, - secondaryButton: { - paddingHorizontal: spacing.xl, - paddingVertical: spacing.sm, - borderRadius: radii.button - }, - secondaryButtonText: { - color: colors.textSecondary, - fontSize: typography.bodySize, - fontWeight: '500' - } -}) diff --git a/mobile/scripts/repro-terminal-color-output.ts b/mobile/scripts/repro-terminal-color-output.ts new file mode 100644 index 00000000000..50e4e153b54 --- /dev/null +++ b/mobile/scripts/repro-terminal-color-output.ts @@ -0,0 +1,60 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const ESC = String.fromCharCode(27) + +export type TerminalColorSnapshot = { + label: string + handle: string + cols: number | null + rows: number | null + serialized: string + lines: string +} + +function countMatches(value: string, pattern: RegExp): number { + return value.match(pattern)?.length ?? 0 +} + +export function summarizeTerminalColorSnapshot( + snapshot: TerminalColorSnapshot +): Record { + const data = snapshot.serialized + return { + label: snapshot.label, + handle: snapshot.handle, + cols: snapshot.cols, + rows: snapshot.rows, + serializedBytes: Buffer.byteLength(data), + sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')), + sgrColor: countMatches( + data, + new RegExp( + `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`, + 'g' + ) + ), + sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')), + altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no', + containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no', + containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no' + } +} + +export function saveTerminalColorSnapshots(snapshots: TerminalColorSnapshot[]): string { + const dir = join(process.cwd(), 'terminal-color-repro') + mkdirSync(dir, { recursive: true }) + for (const snapshot of snapshots) { + const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-') + writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines) + writeFileSync( + join(dir, `${base}.escaped.txt`), + JSON.stringify(snapshot.serialized || snapshot.lines) + ) + } + writeFileSync( + join(dir, 'summary.json'), + JSON.stringify(snapshots.map(summarizeTerminalColorSnapshot), null, 2) + ) + return dir +} diff --git a/mobile/scripts/repro-terminal-colors.ts b/mobile/scripts/repro-terminal-colors.ts index 3c21df64176..e6c302652a0 100644 --- a/mobile/scripts/repro-terminal-colors.ts +++ b/mobile/scripts/repro-terminal-colors.ts @@ -10,10 +10,13 @@ * ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \ * pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB] */ -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' import nacl from 'tweetnacl' import WebSocket from 'ws' +import { + saveTerminalColorSnapshots, + summarizeTerminalColorSnapshot, + type TerminalColorSnapshot +} from './repro-terminal-color-output' const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' const token = process.argv[2] @@ -21,7 +24,6 @@ const serverPublicKeyB64 = process.argv[3] const worktreeSelector = process.argv[4] const explicitHandleA = process.argv[5] const explicitHandleB = process.argv[6] -const ESC = String.fromCharCode(27) type RpcResponse = { id: string @@ -37,15 +39,6 @@ type PendingRequest = { reject: (error: Error) => void } -type Snapshot = { - label: string - handle: string - cols: number | null - rows: number | null - serialized: string - lines: string -} - if (!token || !serverPublicKeyB64 || !worktreeSelector) { console.error( 'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts [handleA] [handleB]' @@ -158,9 +151,13 @@ async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise { +async function captureSnapshot( + ws: WebSocket, + label: string, + handle: string +): Promise { const id = nextId() - const snapshot = await new Promise((resolve, reject) => { + const snapshot = await new Promise((resolve, reject) => { const timeout = setTimeout(() => { pending.delete(id) streamListeners.delete(id) @@ -207,48 +204,6 @@ async function captureSnapshot(ws: WebSocket, label: string, handle: string): Pr return snapshot } -function countMatches(value: string, pattern: RegExp): number { - return value.match(pattern)?.length ?? 0 -} - -function summarize(snapshot: Snapshot): Record { - const data = snapshot.serialized - return { - label: snapshot.label, - handle: snapshot.handle, - cols: snapshot.cols, - rows: snapshot.rows, - serializedBytes: Buffer.byteLength(data), - sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')), - sgrColor: countMatches( - data, - new RegExp( - `${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`, - 'g' - ) - ), - sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')), - altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no', - containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no', - containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no' - } -} - -function saveSnapshots(snapshots: Snapshot[]): string { - const dir = join(process.cwd(), 'terminal-color-repro') - mkdirSync(dir, { recursive: true }) - for (const snapshot of snapshots) { - const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-') - writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines) - writeFileSync( - join(dir, `${base}.escaped.txt`), - JSON.stringify(snapshot.serialized || snapshot.lines) - ) - } - writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2)) - return dir -} - async function run(ws: WebSocket): Promise { ws.send( JSON.stringify({ @@ -303,11 +258,14 @@ async function run(ws: WebSocket): Promise { const firstB = await captureSnapshot(ws, 'b-during-switch', handleB) const secondA = await captureSnapshot(ws, 'a-after-switch', handleA) const snapshots = [firstA, firstB, secondA] - const dir = saveSnapshots(snapshots) + const dir = saveTerminalColorSnapshots(snapshots) - console.table(snapshots.map(summarize)) + console.table(snapshots.map(summarizeTerminalColorSnapshot)) console.log(`saved: ${dir}`) - if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) { + if ( + summarizeTerminalColorSnapshot(firstA).sgrColor !== + summarizeTerminalColorSnapshot(secondA).sgrColor + ) { console.log( 'color SGR count changed between A snapshots: serialization/state changed on desktop' ) diff --git a/mobile/scripts/repro-worktree-startup-output.ts b/mobile/scripts/repro-worktree-startup-output.ts new file mode 100644 index 00000000000..83e127a3fa6 --- /dev/null +++ b/mobile/scripts/repro-worktree-startup-output.ts @@ -0,0 +1,75 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const ESC = String.fromCharCode(27) + +export type StartupTerminalCapture = { + handle: string + title: string | null + scrollback: Record | null + chunks: string[] +} + +function stripAnsi(value: string): string { + return ( + value + // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries + .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '') + // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries + .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '') + .replace(/\r/g, '\n') + ) +} + +function normalizePreview(value: string): string { + return stripAnsi(value) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 8) + .join('\n') +} + +export function summarizeStartupCapture(capture: StartupTerminalCapture): Record { + const serialized = + typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' + const live = capture.chunks.join('') + const serializedPreview = normalizePreview(serialized) + const livePreview = normalizePreview(live) + return { + handle: capture.handle, + title: capture.title, + cols: capture.scrollback?.cols ?? null, + rows: capture.scrollback?.rows ?? null, + serializedBytes: Buffer.byteLength(serialized), + liveBytes: Buffer.byteLength(live), + liveChunks: capture.chunks.length, + serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, + liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, + livePreviewContainedInSerialized: + livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''), + serializedPreview, + livePreview + } +} + +export function saveStartupCaptures( + captures: StartupTerminalCapture[], + worktreeName: string +): string { + const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName) + mkdirSync(dir, { recursive: true }) + for (const capture of captures) { + const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-') + const serialized = + typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' + writeFileSync(join(dir, `${base}.serialized.ansi`), serialized) + writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join('')) + writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2)) + } + writeFileSync( + join(dir, 'summary.json'), + JSON.stringify(captures.map(summarizeStartupCapture), null, 2) + ) + return dir +} diff --git a/mobile/scripts/repro-worktree-startup-stream.ts b/mobile/scripts/repro-worktree-startup-stream.ts index e4db6182f00..f3bbdc0d5d0 100644 --- a/mobile/scripts/repro-worktree-startup-stream.ts +++ b/mobile/scripts/repro-worktree-startup-stream.ts @@ -4,10 +4,15 @@ * Usage: * pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand] */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import nacl from 'tweetnacl' import WebSocket from 'ws' +import { + saveStartupCaptures, + summarizeStartupCapture, + type StartupTerminalCapture +} from './repro-worktree-startup-output' const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768' const USER_DATA = @@ -15,7 +20,6 @@ const USER_DATA = const repoSelector = process.argv[2] const worktreeName = process.argv[3] const startupCommand = process.argv[4] || 'claude' -const ESC = String.fromCharCode(27) type RpcResponse = { id: string @@ -36,13 +40,6 @@ type TerminalInfo = { title: string | null } -type Capture = { - handle: string - title: string | null - scrollback: Record | null - chunks: string[] -} - if (!repoSelector || !worktreeName) { console.error( 'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts [startupCommand]' @@ -141,26 +138,6 @@ function formatError(response: RpcResponse): string { return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000) } -function stripAnsi(value: string): string { - return ( - value - // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries - .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '') - // eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries - .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '') - .replace(/\r/g, '\n') - ) -} - -function normalizePreview(value: string): string { - return stripAnsi(value) - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .slice(0, 8) - .join('\n') -} - async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise { const deadline = Date.now() + 60_000 while (Date.now() < deadline) { @@ -185,7 +162,7 @@ async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise { +async function subscribe(ws: WebSocket, capture: StartupTerminalCapture): Promise { const id = nextId() streamListeners.set(id, (result) => { if (result.type === 'scrollback') { @@ -204,44 +181,6 @@ async function subscribe(ws: WebSocket, capture: Capture): Promise { }) } -function summarize(capture: Capture): Record { - const serialized = - typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' - const live = capture.chunks.join('') - const serializedPreview = normalizePreview(serialized) - const livePreview = normalizePreview(live) - return { - handle: capture.handle, - title: capture.title, - cols: capture.scrollback?.cols ?? null, - rows: capture.scrollback?.rows ?? null, - serializedBytes: Buffer.byteLength(serialized), - liveBytes: Buffer.byteLength(live), - liveChunks: capture.chunks.length, - serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, - liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length, - livePreviewContainedInSerialized: - livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''), - serializedPreview, - livePreview - } -} - -function save(captures: Capture[], worktreeName: string): string { - const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName) - mkdirSync(dir, { recursive: true }) - for (const capture of captures) { - const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-') - const serialized = - typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : '' - writeFileSync(join(dir, `${base}.serialized.ansi`), serialized) - writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join('')) - writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2)) - } - writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2)) - return dir -} - async function run(ws: WebSocket): Promise { ws.send( JSON.stringify({ @@ -312,8 +251,8 @@ async function run(ws: WebSocket): Promise { for (const capture of captures) { await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null) } - const dir = save(captures, worktreeName) - console.table(captures.map(summarize)) + const dir = saveStartupCaptures(captures, worktreeName) + console.table(captures.map(summarizeStartupCapture)) console.log(`saved: ${dir}`) } diff --git a/mobile/src/components/CustomKeyModal.styles.ts b/mobile/src/components/CustomKeyModal.styles.ts new file mode 100644 index 00000000000..991e557441f --- /dev/null +++ b/mobile/src/components/CustomKeyModal.styles.ts @@ -0,0 +1,276 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, radii, typography } from '../theme/mobile-theme' + +export const customKeyModalStyles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingBottom: spacing.sm + }, + backButton: { + width: 30, + height: 30, + borderRadius: 15, + alignItems: 'center', + justifyContent: 'center' + }, + backButtonPressed: { + backgroundColor: colors.bgRaised + }, + backSpacer: { + width: 30 + }, + title: { + flex: 1, + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary, + textAlign: 'center' + }, + group: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + row: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary, + marginBottom: 1 + }, + rowHint: { + fontSize: 12, + color: colors.textMuted + }, + shortcutForm: { + paddingTop: spacing.sm + }, + preview: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.lg + spacing.xs, + flexWrap: 'wrap' + }, + previewKeycapRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + previewPlus: { + color: colors.textMuted, + fontSize: 16 + }, + keycap: { + minWidth: 48, + height: 48, + paddingHorizontal: spacing.md, + borderRadius: 10, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + alignItems: 'center', + justifyContent: 'center' + }, + keycapModifier: { + minWidth: 0 + }, + keycapWarn: { + borderColor: colors.statusAmber + }, + keycapText: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: 17, + fontWeight: '600' + }, + keycapTextWarn: { + color: colors.statusAmber + }, + keycapModifierText: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: 14, + fontWeight: '600' + }, + section: { + marginTop: spacing.md + }, + sectionLabel: { + fontSize: 11, + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.8, + marginBottom: spacing.sm, + paddingLeft: 2 + }, + mods: { + flexDirection: 'row', + gap: spacing.sm + }, + chip: { + flex: 1, + height: 40, + borderRadius: 8, + backgroundColor: colors.bgPanel, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 4 + }, + chipSelected: { + backgroundColor: colors.textPrimary + }, + chipPressed: { + backgroundColor: colors.bgRaised + }, + chipText: { + color: colors.textSecondary, + fontSize: 14, + fontWeight: '500' + }, + chipTextSelected: { + color: colors.bgBase + }, + chipGlyph: { + color: colors.textMuted, + fontSize: 13, + fontFamily: typography.monoFamily + }, + chipGlyphSelected: { + color: 'rgba(10,10,10,0.5)' + }, + keyInput: { + width: '100%', + height: 56, + borderRadius: 10, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: 22, + fontWeight: '600', + textAlign: 'center' + }, + moreLink: { + paddingVertical: spacing.sm, + alignItems: 'center' + }, + moreLinkPressed: { + opacity: 0.6 + }, + moreLinkText: { + color: colors.textSecondary, + fontSize: 13, + textDecorationLine: 'underline' + }, + specialKeysForm: { + paddingTop: spacing.xs, + paddingBottom: spacing.md, + gap: spacing.md + }, + specialGroup: { + gap: spacing.xs + }, + specialGroupTitle: { + fontSize: 11, + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.8, + paddingLeft: 2, + marginBottom: spacing.xs + }, + keyGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + marginHorizontal: -spacing.xs / 2 + }, + keyCellWrap: { + paddingHorizontal: spacing.xs / 2, + paddingVertical: spacing.xs / 2 + }, + keyCell: { + height: 40, + borderRadius: 8, + backgroundColor: colors.bgPanel, + alignItems: 'center', + justifyContent: 'center' + }, + keyCellPressed: { + backgroundColor: colors.bgRaised + }, + keyCellSelected: { + backgroundColor: colors.textPrimary + }, + keyCellText: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary, + fontFamily: typography.monoFamily + }, + keyCellTextSelected: { + color: colors.bgBase + }, + macroForm: { + padding: spacing.md, + gap: spacing.sm + }, + fieldLabel: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary + }, + fieldInput: { + backgroundColor: colors.bgBase, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: 14, + fontFamily: typography.monoFamily, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + switchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.xs + }, + switchLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + saveButton: { + marginTop: spacing.md, + backgroundColor: colors.textPrimary, + paddingVertical: spacing.md, + borderRadius: 10, + alignItems: 'center' + }, + saveButtonDisabled: { + backgroundColor: colors.bgRaised + }, + saveButtonText: { + color: colors.bgBase, + fontSize: 15, + fontWeight: '600' + }, + saveButtonTextDisabled: { + color: colors.textMuted + } +}) diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index 2777e310dc8..0cc24f4c030 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -1,8 +1,8 @@ import { useCallback, useMemo, useState } from 'react' -import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native' +import { View, Text, Pressable, TextInput, Switch } from 'react-native' import { ChevronLeft } from 'lucide-react-native' import AsyncStorage from '@react-native-async-storage/async-storage' -import { colors, spacing, radii, typography } from '../theme/mobile-theme' +import { colors } from '../theme/mobile-theme' import { BottomDrawer } from './BottomDrawer' import { buildTerminalShortcutKey, @@ -11,6 +11,7 @@ import { type TerminalShortcutModifier, type TerminalShortcutSpecialKey } from '../terminal/terminal-accessory-keys' +import { customKeyModalStyles as styles } from './CustomKeyModal.styles' const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys' @@ -409,277 +410,3 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc ) } - -const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - alignItems: 'center', - paddingBottom: spacing.sm - }, - backButton: { - width: 30, - height: 30, - borderRadius: 15, - alignItems: 'center', - justifyContent: 'center' - }, - backButtonPressed: { - backgroundColor: colors.bgRaised - }, - backSpacer: { - width: 30 - }, - title: { - flex: 1, - fontSize: 15, - fontWeight: '600', - color: colors.textPrimary, - textAlign: 'center' - }, - group: { - backgroundColor: colors.bgPanel, - borderRadius: 12, - overflow: 'hidden' - }, - separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, - row: { - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - rowLabel: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary, - marginBottom: 1 - }, - rowHint: { - fontSize: 12, - color: colors.textMuted - }, - shortcutForm: { - paddingTop: spacing.sm - }, - preview: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.sm, - paddingVertical: spacing.lg + spacing.xs, - flexWrap: 'wrap' - }, - previewKeycapRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - previewPlus: { - color: colors.textMuted, - fontSize: 16 - }, - keycap: { - minWidth: 48, - height: 48, - paddingHorizontal: spacing.md, - borderRadius: 10, - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - alignItems: 'center', - justifyContent: 'center' - }, - keycapModifier: { - minWidth: 0 - }, - keycapWarn: { - borderColor: colors.statusAmber - }, - keycapText: { - color: colors.textPrimary, - fontFamily: typography.monoFamily, - fontSize: 17, - fontWeight: '600' - }, - keycapTextWarn: { - color: colors.statusAmber - }, - keycapModifierText: { - color: colors.textSecondary, - fontFamily: typography.monoFamily, - fontSize: 14, - fontWeight: '600' - }, - section: { - marginTop: spacing.md - }, - sectionLabel: { - fontSize: 11, - color: colors.textMuted, - textTransform: 'uppercase', - letterSpacing: 0.8, - marginBottom: spacing.sm, - paddingLeft: 2 - }, - mods: { - flexDirection: 'row', - gap: spacing.sm - }, - chip: { - flex: 1, - height: 40, - borderRadius: 8, - backgroundColor: colors.bgPanel, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 4 - }, - chipSelected: { - backgroundColor: colors.textPrimary - }, - chipPressed: { - backgroundColor: colors.bgRaised - }, - chipText: { - color: colors.textSecondary, - fontSize: 14, - fontWeight: '500' - }, - chipTextSelected: { - color: colors.bgBase - }, - chipGlyph: { - color: colors.textMuted, - fontSize: 13, - fontFamily: typography.monoFamily - }, - chipGlyphSelected: { - color: 'rgba(10,10,10,0.5)' - }, - keyInput: { - width: '100%', - height: 56, - borderRadius: 10, - backgroundColor: colors.bgPanel, - borderWidth: 1, - borderColor: colors.borderSubtle, - color: colors.textPrimary, - fontFamily: typography.monoFamily, - fontSize: 22, - fontWeight: '600', - textAlign: 'center' - }, - moreLink: { - paddingVertical: spacing.sm, - alignItems: 'center' - }, - moreLinkPressed: { - opacity: 0.6 - }, - moreLinkText: { - color: colors.textSecondary, - fontSize: 13, - textDecorationLine: 'underline' - }, - specialKeysForm: { - paddingTop: spacing.xs, - paddingBottom: spacing.md, - gap: spacing.md - }, - specialGroup: { - gap: spacing.xs - }, - specialGroupTitle: { - fontSize: 11, - color: colors.textMuted, - textTransform: 'uppercase', - letterSpacing: 0.8, - paddingLeft: 2, - marginBottom: spacing.xs - }, - keyGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - marginHorizontal: -spacing.xs / 2 - }, - keyCellWrap: { - paddingHorizontal: spacing.xs / 2, - paddingVertical: spacing.xs / 2 - }, - keyCell: { - height: 40, - borderRadius: 8, - backgroundColor: colors.bgPanel, - alignItems: 'center', - justifyContent: 'center' - }, - keyCellPressed: { - backgroundColor: colors.bgRaised - }, - keyCellSelected: { - backgroundColor: colors.textPrimary - }, - keyCellText: { - fontSize: 13, - fontWeight: '600', - color: colors.textPrimary, - fontFamily: typography.monoFamily - }, - keyCellTextSelected: { - color: colors.bgBase - }, - macroForm: { - padding: spacing.md, - gap: spacing.sm - }, - fieldLabel: { - fontSize: 13, - fontWeight: '500', - color: colors.textSecondary - }, - fieldInput: { - backgroundColor: colors.bgBase, - color: colors.textPrimary, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - fontSize: 14, - fontFamily: typography.monoFamily, - borderWidth: 1, - borderColor: colors.borderSubtle - }, - switchRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: spacing.xs - }, - switchLabel: { - fontSize: typography.bodySize, - color: colors.textPrimary - }, - saveButton: { - marginTop: spacing.md, - backgroundColor: colors.textPrimary, - paddingVertical: spacing.md, - borderRadius: 10, - alignItems: 'center' - }, - saveButtonDisabled: { - backgroundColor: colors.bgRaised - }, - saveButtonText: { - color: colors.bgBase, - fontSize: 15, - fontWeight: '600' - }, - saveButtonTextDisabled: { - color: colors.textMuted - } -}) diff --git a/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts b/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts new file mode 100644 index 00000000000..c4348ad15a2 --- /dev/null +++ b/mobile/src/components/mobile-rich-markdown-editor-body-primary.ts @@ -0,0 +1,279 @@ +export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [ + ';', + ' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;', + ' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;', + ' }', + ' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }', + ' html, body {', + ' width: 100%;', + ' min-height: 100%;', + ' margin: 0;', + ' background: var(--editor-surface);', + ' color: var(--foreground);', + ' font-family: var(--font-sans);', + ' overscroll-behavior: contain;', + ' }', + ' body { overflow: auto; }', + ' #editor {', + ' min-height: 100vh;', + ' padding: 18px 16px 112px;', + ' outline: none;', + ' font-size: 14px;', + ' line-height: 1.7;', + ' word-wrap: break-word;', + ' overflow-wrap: anywhere;', + ' caret-color: var(--foreground);', + ' }', + ' #editor[contenteditable="false"] {', + ' opacity: 0.78;', + ' }', + ' #editor:empty::before,', + ' #editor p.is-empty:first-child::before {', + ' content: attr(data-placeholder);', + ' color: var(--muted-foreground);', + ' pointer-events: none;', + ' }', + ' #editor > :first-child { margin-top: 0; }', + ' h1, h2, h3, h4, h5, h6 {', + ' margin: 1.5em 0 0.5em;', + ' font-weight: 600;', + ' line-height: 1.3;', + ' letter-spacing: 0;', + ' }', + ' h1 { font-size: 1.85em; font-weight: 700; }', + ' h2 { font-size: 1.4em; }', + ' h3 { font-size: 1.15em; }', + ' p, ul, ol, blockquote { margin: 0.75em 0; }', + ' ul, ol { padding-left: 1.5em; }', + ' ul { list-style: disc; }', + ' ol { list-style: decimal; }', + ' li { margin: 0.15em 0; }', + ' li > p { margin: 0; }', + ' ul[data-type="taskList"] {', + ' padding-left: 0;', + ' list-style: none;', + ' }', + ' ul[data-type="taskList"] > li {', + ' display: flex;', + ' align-items: flex-start;', + ' gap: 6px;', + ' }', + ' ul[data-type="taskList"] > li > label {', + ' flex-shrink: 0;', + ' display: flex;', + ' align-items: center;', + ' height: 1.55em;', + ' user-select: none;', + ' }', + ' ul[data-type="taskList"] input[type="checkbox"] {', + ' appearance: none;', + ' width: 16px;', + ' height: 16px;', + ' margin: 0;', + ' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);', + ' border-radius: 4px;', + ' background: transparent;', + ' position: relative;', + ' }', + ' ul[data-type="taskList"] input[type="checkbox"]:checked {', + ' background: var(--primary);', + ' border-color: var(--primary);', + ' }', + ' ul[data-type="taskList"] input[type="checkbox"]:checked::after {', + ' content: "";', + ' position: absolute;', + ' left: 4px;', + ' top: 1px;', + ' width: 5px;', + ' height: 9px;', + ' border: solid var(--primary-foreground);', + ' border-width: 0 2px 2px 0;', + ' transform: rotate(45deg);', + ' }', + ' ul[data-type="taskList"] input[type="checkbox"]:disabled {', + ' opacity: 0.65;', + ' }', + ' ul[data-type="taskList"] > li > div {', + ' flex: 1;', + ' min-width: 0;', + ' }', + ' ul[data-type="taskList"] > li[data-checked="true"] > div {', + ' text-decoration: line-through;', + ' color: var(--muted-foreground);', + ' }', + ' blockquote {', + ' padding: 0.5em 1em;', + ' border-left: 3px solid var(--border);', + ' border-radius: 0 6px 6px 0;', + ' color: var(--muted-foreground);', + ' background: color-mix(in srgb, var(--foreground) 2%, transparent);', + ' }', + ' table {', + ' width: 100%;', + ' margin: 1em 0;', + ' border-collapse: collapse;', + ' font-size: 0.95em;', + ' }', + ' th, td {', + ' padding: 8px 14px;', + ' border: 1px solid var(--border);', + ' text-align: left;', + ' vertical-align: top;', + ' }', + ' th {', + ' font-weight: 600;', + ' background: color-mix(in srgb, var(--foreground) 4%, transparent);', + ' }', + ' tr:nth-child(odd) td {', + ' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);', + ' }', + ' code {', + ' padding: 0.2em 0.4em;', + ' border-radius: 5px;', + ' background: color-mix(in srgb, var(--foreground) 8%, transparent);', + ' font-size: 0.88em;', + ' font-family: var(--font-mono);', + ' }', + ' pre {', + ' margin: 0.75em 0;', + ' padding: 14px 18px;', + ' border-radius: 8px;', + ' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);', + ' overflow-x: auto;', + ' line-height: 1.55;', + ' background: color-mix(in srgb, var(--foreground) 6%, transparent);', + ' font-family: var(--font-mono);', + ' white-space: pre-wrap;', + ' }', + ' pre::before {', + ' content: attr(data-language);', + ' display: block;', + ' min-height: 13px;', + ' margin-bottom: 4px;', + ' color: var(--muted-foreground);', + ' font-size: 11px;', + ' text-transform: uppercase;', + ' }', + ' pre code {', + ' padding: 0;', + ' border-radius: 0;', + ' background: transparent;', + ' font-size: 0.92em;', + ' white-space: pre-wrap;', + ' }', + ' hr {', + ' margin: 1.5em 0;', + ' border: none;', + ' border-top: 1px solid var(--border);', + ' }', + ' a {', + ' color: var(--accent-link);', + ' text-decoration: underline;', + ' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);', + ' text-underline-offset: 2px;', + ' }', + ' img {', + ' display: block;', + ' max-width: 100%;', + ' margin: 0.75em 0;', + ' border-radius: 8px;', + ' }', + ' ', + '', + '', + '
', + ' ', + '', + '' +].join('\n') diff --git a/mobile/src/components/mobile-rich-markdown-editor-html.ts b/mobile/src/components/mobile-rich-markdown-editor-html.ts index b7ff1649075..493fea3dd02 100644 --- a/mobile/src/components/mobile-rich-markdown-editor-html.ts +++ b/mobile/src/components/mobile-rich-markdown-editor-html.ts @@ -2,6 +2,12 @@ import { colors } from '../theme/mobile-theme' import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script' import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script' import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script' +import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY } from './mobile-rich-markdown-editor-body-primary' +import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY } from './mobile-rich-markdown-editor-body-secondary' +import { + MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS, + MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END +} from './mobile-rich-markdown-editor-document-suffix' export function escapeInjectedJavaScriptString(value: string): string { return JSON.stringify(value).replace(/<\/script/gi, '<\\/script') @@ -24,644 +30,7 @@ export function buildMobileRichMarkdownEditorHtml(): string { --border: ${colors.borderSubtle}; --primary: ${colors.textPrimary}; --primary-foreground: ${colors.bgBase}; - --accent-link: ${colors.accentBlue}; - --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; - --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - } - * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } - html, body { - width: 100%; - min-height: 100%; - margin: 0; - background: var(--editor-surface); - color: var(--foreground); - font-family: var(--font-sans); - overscroll-behavior: contain; - } - body { overflow: auto; } - #editor { - min-height: 100vh; - padding: 18px 16px 112px; - outline: none; - font-size: 14px; - line-height: 1.7; - word-wrap: break-word; - overflow-wrap: anywhere; - caret-color: var(--foreground); - } - #editor[contenteditable="false"] { - opacity: 0.78; - } - #editor:empty::before, - #editor p.is-empty:first-child::before { - content: attr(data-placeholder); - color: var(--muted-foreground); - pointer-events: none; - } - #editor > :first-child { margin-top: 0; } - h1, h2, h3, h4, h5, h6 { - margin: 1.5em 0 0.5em; - font-weight: 600; - line-height: 1.3; - letter-spacing: 0; - } - h1 { font-size: 1.85em; font-weight: 700; } - h2 { font-size: 1.4em; } - h3 { font-size: 1.15em; } - p, ul, ol, blockquote { margin: 0.75em 0; } - ul, ol { padding-left: 1.5em; } - ul { list-style: disc; } - ol { list-style: decimal; } - li { margin: 0.15em 0; } - li > p { margin: 0; } - ul[data-type="taskList"] { - padding-left: 0; - list-style: none; - } - ul[data-type="taskList"] > li { - display: flex; - align-items: flex-start; - gap: 6px; - } - ul[data-type="taskList"] > li > label { - flex-shrink: 0; - display: flex; - align-items: center; - height: 1.55em; - user-select: none; - } - ul[data-type="taskList"] input[type="checkbox"] { - appearance: none; - width: 16px; - height: 16px; - margin: 0; - border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent); - border-radius: 4px; - background: transparent; - position: relative; - } - ul[data-type="taskList"] input[type="checkbox"]:checked { - background: var(--primary); - border-color: var(--primary); - } - ul[data-type="taskList"] input[type="checkbox"]:checked::after { - content: ""; - position: absolute; - left: 4px; - top: 1px; - width: 5px; - height: 9px; - border: solid var(--primary-foreground); - border-width: 0 2px 2px 0; - transform: rotate(45deg); - } - ul[data-type="taskList"] input[type="checkbox"]:disabled { - opacity: 0.65; - } - ul[data-type="taskList"] > li > div { - flex: 1; - min-width: 0; - } - ul[data-type="taskList"] > li[data-checked="true"] > div { - text-decoration: line-through; - color: var(--muted-foreground); - } - blockquote { - padding: 0.5em 1em; - border-left: 3px solid var(--border); - border-radius: 0 6px 6px 0; - color: var(--muted-foreground); - background: color-mix(in srgb, var(--foreground) 2%, transparent); - } - table { - width: 100%; - margin: 1em 0; - border-collapse: collapse; - font-size: 0.95em; - } - th, td { - padding: 8px 14px; - border: 1px solid var(--border); - text-align: left; - vertical-align: top; - } - th { - font-weight: 600; - background: color-mix(in srgb, var(--foreground) 4%, transparent); - } - tr:nth-child(odd) td { - background: color-mix(in srgb, var(--foreground) 1.5%, transparent); - } - code { - padding: 0.2em 0.4em; - border-radius: 5px; - background: color-mix(in srgb, var(--foreground) 8%, transparent); - font-size: 0.88em; - font-family: var(--font-mono); - } - pre { - margin: 0.75em 0; - padding: 14px 18px; - border-radius: 8px; - border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent); - overflow-x: auto; - line-height: 1.55; - background: color-mix(in srgb, var(--foreground) 6%, transparent); - font-family: var(--font-mono); - white-space: pre-wrap; - } - pre::before { - content: attr(data-language); - display: block; - min-height: 13px; - margin-bottom: 4px; - color: var(--muted-foreground); - font-size: 11px; - text-transform: uppercase; - } - pre code { - padding: 0; - border-radius: 0; - background: transparent; - font-size: 0.92em; - white-space: pre-wrap; - } - hr { - margin: 1.5em 0; - border: none; - border-top: 1px solid var(--border); - } - a { - color: var(--accent-link); - text-decoration: underline; - text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); - text-underline-offset: 2px; - } - img { - display: block; - max-width: 100%; - margin: 0.75em 0; - border-radius: 8px; - } - - - -
- - -` + --accent-link: ${colors.accentBlue}${MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY} +${MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY}${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT} +${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS}${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END}` } diff --git a/mobile/src/pair-scan-styles.ts b/mobile/src/pair-scan-styles.ts new file mode 100644 index 00000000000..af1b936fb54 --- /dev/null +++ b/mobile/src/pair-scan-styles.ts @@ -0,0 +1,185 @@ +import { StyleSheet } from 'react-native' +import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' + +export const pairScanStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + padding: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.sm + }, + steps: { + gap: spacing.sm, + marginBottom: spacing.lg, + marginLeft: 7 + }, + step: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + stepBadge: { + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: colors.bgRaised, + alignItems: 'center', + justifyContent: 'center' + }, + stepNumber: { + fontSize: 12, + fontWeight: '700', + color: colors.textSecondary + }, + stepText: { + fontSize: typography.bodySize, + color: colors.textSecondary + }, + cameraWrap: { + flex: 1, + borderRadius: radii.camera, + overflow: 'hidden' + }, + // Why: holds the layout slot while the camera is unmounted during + // paste, so the bottom action button doesn't snap up to fill the + // empty space. + cameraPlaceholder: { + flex: 1, + backgroundColor: colors.bgPanel, + borderRadius: radii.camera + }, + camera: { + ...StyleSheet.absoluteFillObject + }, + reticle: { + ...StyleSheet.absoluteFillObject, + alignItems: 'center', + justifyContent: 'center' + }, + reticleFrame: { + position: 'relative' + }, + corner: { + position: 'absolute', + width: 28, + height: 28, + borderColor: 'rgba(255,255,255,0.7)' + }, + cornerTL: { + top: 0, + left: 0, + borderTopWidth: 2.5, + borderLeftWidth: 2.5, + borderTopLeftRadius: 6 + }, + cornerTR: { + top: 0, + right: 0, + borderTopWidth: 2.5, + borderRightWidth: 2.5, + borderTopRightRadius: 6 + }, + cornerBL: { + bottom: 0, + left: 0, + borderBottomWidth: 2.5, + borderLeftWidth: 2.5, + borderBottomLeftRadius: 6 + }, + cornerBR: { + bottom: 0, + right: 0, + borderBottomWidth: 2.5, + borderRightWidth: 2.5, + borderBottomRightRadius: 6 + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + title: { + fontSize: typography.titleSize, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: spacing.sm + }, + subtitle: { + maxWidth: 310, + fontSize: typography.bodySize, + color: colors.textSecondary, + textAlign: 'center', + marginBottom: spacing.xl, + lineHeight: 20 + }, + connectingText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + marginTop: spacing.lg + }, + logSlot: { + width: '100%', + marginTop: spacing.lg, + paddingHorizontal: spacing.sm + }, + errorText: { + color: colors.statusRed, + fontSize: typography.bodySize, + textAlign: 'center', + marginBottom: spacing.xl, + lineHeight: 20 + }, + primaryButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + backgroundColor: colors.textPrimary, + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm + 2, + borderRadius: radii.button + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + pasteButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + marginTop: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + pasteButtonPressed: { + opacity: 0.6 + }, + pasteButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + }, + errorActions: { + alignItems: 'center', + gap: spacing.sm + }, + secondaryButton: { + paddingHorizontal: spacing.xl, + paddingVertical: spacing.sm, + borderRadius: radii.button + }, + secondaryButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + } +}) diff --git a/mobile/src/terminal/terminal-accessory-keys.ts b/mobile/src/terminal/terminal-accessory-keys.ts index 49aaba134d5..c02f265fba4 100644 --- a/mobile/src/terminal/terminal-accessory-keys.ts +++ b/mobile/src/terminal/terminal-accessory-keys.ts @@ -1,3 +1,9 @@ +import { + SPECIAL_KEY_LABELS, + TERMINAL_ACCESSORY_KEY_DEFINITIONS, + TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS +} from './terminal-key-definitions' + export type TerminalAccessoryKey = { id: string label: string @@ -75,66 +81,6 @@ const CTRL_PRINTABLE_BYTES: Record = { '?': '\x7f' } -const SPECIAL_KEY_LABELS: Record = { - escape: 'Esc', - tab: 'Tab', - enter: 'Enter', - backspace: '⌫', - delete: 'Del', - insert: 'Ins', - arrowUp: '↑', - arrowDown: '↓', - arrowLeft: '←', - arrowRight: '→', - home: 'Home', - end: 'End', - pageUp: 'PgUp', - pageDown: 'PgDn', - space: 'Space', - f1: 'F1', - f2: 'F2', - f3: 'F3', - f4: 'F4', - f5: 'F5', - f6: 'F6', - f7: 'F7', - f8: 'F8', - f9: 'F9', - f10: 'F10', - f11: 'F11', - f12: 'F12' -} - -const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record = { - escape: 'Escape', - tab: 'Tab', - enter: 'Enter', - backspace: 'Backspace', - delete: 'Forward delete', - insert: 'Insert', - arrowUp: 'Arrow up', - arrowDown: 'Arrow down', - arrowLeft: 'Arrow left', - arrowRight: 'Arrow right', - home: 'Home', - end: 'End', - pageUp: 'Page up', - pageDown: 'Page down', - space: 'Space', - f1: 'F1', - f2: 'F2', - f3: 'F3', - f4: 'F4', - f5: 'F5', - f6: 'F6', - f7: 'F7', - f8: 'F8', - f9: 'F9', - f10: 'F10', - f11: 'F11', - f12: 'F12' -} - const CSI_FINAL_SPECIAL_KEYS: Record = { arrowUp: 'A', arrowDown: 'B', @@ -165,87 +111,9 @@ const CSI_TILDE_SPECIAL_KEYS: Record = { f12: 24 } -export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [ - 'escape', - 'tab', - 'enter', - 'backspace', - 'delete', - 'insert', - 'arrowUp', - 'arrowDown', - 'arrowLeft', - 'arrowRight', - 'home', - 'end', - 'pageUp', - 'pageDown', - 'space', - 'f1', - 'f2', - 'f3', - 'f4', - 'f5', - 'f6', - 'f7', - 'f8', - 'f9', - 'f10', - 'f11', - 'f12' -].map((id) => ({ - id, - label: SPECIAL_KEY_LABELS[id]!, - accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]! -})) +export const TERMINAL_SHORTCUT_SPECIAL_KEYS = TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS -export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ - { id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' }, - { id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' }, - { id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' }, - // Why: terminal apps recognize ESC [ Z as the reverse-tab sequence. - { id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }, - { id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' }, - { id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true }, - { - id: 'delete', - label: 'Del', - bytes: '\x1b[3~', - accessibilityLabel: 'Forward delete', - repeatable: true - }, - { id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true }, - { - id: 'arrowDown', - label: '↓', - bytes: '\x1b[B', - accessibilityLabel: 'Arrow Down', - repeatable: true - }, - { - id: 'arrowLeft', - label: '←', - bytes: '\x1b[D', - accessibilityLabel: 'Arrow Left', - repeatable: true - }, - { - id: 'arrowRight', - label: '→', - bytes: '\x1b[C', - accessibilityLabel: 'Arrow Right', - repeatable: true - }, - { id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' }, - { id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }, - { id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' }, - { id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' }, - { id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' }, - { id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' }, - { id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' }, - { id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, - { id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } -] +export const TERMINAL_ACCESSORY_KEYS = TERMINAL_ACCESSORY_KEY_DEFINITIONS export function buildTerminalShortcutKey( binding: TerminalShortcutBinding diff --git a/mobile/src/terminal/terminal-key-definitions.ts b/mobile/src/terminal/terminal-key-definitions.ts new file mode 100644 index 00000000000..e6aeb63d45c --- /dev/null +++ b/mobile/src/terminal/terminal-key-definitions.ts @@ -0,0 +1,143 @@ +import type { TerminalAccessoryKey, TerminalShortcutSpecialKey } from './terminal-accessory-keys' + +export const SPECIAL_KEY_LABELS: Record = { + escape: 'Esc', + tab: 'Tab', + enter: 'Enter', + backspace: '⌫', + delete: 'Del', + insert: 'Ins', + arrowUp: '↑', + arrowDown: '↓', + arrowLeft: '←', + arrowRight: '→', + home: 'Home', + end: 'End', + pageUp: 'PgUp', + pageDown: 'PgDn', + space: 'Space', + f1: 'F1', + f2: 'F2', + f3: 'F3', + f4: 'F4', + f5: 'F5', + f6: 'F6', + f7: 'F7', + f8: 'F8', + f9: 'F9', + f10: 'F10', + f11: 'F11', + f12: 'F12' +} + +const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record = { + escape: 'Escape', + tab: 'Tab', + enter: 'Enter', + backspace: 'Backspace', + delete: 'Forward delete', + insert: 'Insert', + arrowUp: 'Arrow up', + arrowDown: 'Arrow down', + arrowLeft: 'Arrow left', + arrowRight: 'Arrow right', + home: 'Home', + end: 'End', + pageUp: 'Page up', + pageDown: 'Page down', + space: 'Space', + f1: 'F1', + f2: 'F2', + f3: 'F3', + f4: 'F4', + f5: 'F5', + f6: 'F6', + f7: 'F7', + f8: 'F8', + f9: 'F9', + f10: 'F10', + f11: 'F11', + f12: 'F12' +} + +export const TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS: TerminalShortcutSpecialKey[] = [ + 'escape', + 'tab', + 'enter', + 'backspace', + 'delete', + 'insert', + 'arrowUp', + 'arrowDown', + 'arrowLeft', + 'arrowRight', + 'home', + 'end', + 'pageUp', + 'pageDown', + 'space', + 'f1', + 'f2', + 'f3', + 'f4', + 'f5', + 'f6', + 'f7', + 'f8', + 'f9', + 'f10', + 'f11', + 'f12' +].map((id) => ({ + id, + label: SPECIAL_KEY_LABELS[id]!, + accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]! +})) + +export const TERMINAL_ACCESSORY_KEY_DEFINITIONS: TerminalAccessoryKey[] = [ + { id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' }, + { id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' }, + { id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' }, + // Why: terminal apps recognize ESC [ Z as the reverse-tab sequence. + { id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' }, + { id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' }, + { id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true }, + { + id: 'delete', + label: 'Del', + bytes: '\x1b[3~', + accessibilityLabel: 'Forward delete', + repeatable: true + }, + { id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true }, + { + id: 'arrowDown', + label: '↓', + bytes: '\x1b[B', + accessibilityLabel: 'Arrow Down', + repeatable: true + }, + { + id: 'arrowLeft', + label: '←', + bytes: '\x1b[D', + accessibilityLabel: 'Arrow Left', + repeatable: true + }, + { + id: 'arrowRight', + label: '→', + bytes: '\x1b[C', + accessibilityLabel: 'Arrow Right', + repeatable: true + }, + { id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' }, + { id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' }, + { id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' }, + { id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' }, + { id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' }, + { id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' }, + { id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' }, + { id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, + { id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } +] diff --git a/src/cli/handlers/automation-handler-flags.ts b/src/cli/handlers/automation-handler-flags.ts new file mode 100644 index 00000000000..c67f592ab7c --- /dev/null +++ b/src/cli/handlers/automation-handler-flags.ts @@ -0,0 +1,308 @@ +import type { AutomationPrecheck, AutomationSchedulePreset } from '../../shared/automations-types' +import { + normalizeTaskSourceContext, + type TaskSourceContext +} from '../../shared/task-source-context' +import type { TuiAgent } from '../../shared/tui-agent' +import { + DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, + MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS +} from '../../shared/automation-precheck' +import { buildAutomationRrule } from '../../shared/automation-schedule-occurrences' +import { isValidAutomationSchedule } from '../../shared/automation-schedule-parsing' +import { isTuiAgent } from '../../shared/tui-agent-config' +import { + getOptionalPositiveIntegerFlag, + getOptionalStringFlag, + getRequiredStringFlag +} from '../flags' +import { RuntimeClientError } from '../runtime-client' + +const PRESET_TRIGGERS = new Set(['hourly', 'daily', 'weekdays', 'weekly']) +const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const + +function getScheduleModifierFlag(flags: Map): string | undefined { + return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag)) +} + +function validateScheduleModifierApplicability( + flags: Map, + raw: string +): void { + const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset) + if (!isPreset) { + if (flags.has('time')) { + throw new RuntimeClientError( + 'invalid_argument', + '--time can only be used with preset automation triggers' + ) + } + if (flags.has('day')) { + throw new RuntimeClientError( + 'invalid_argument', + '--day can only be used with the weekly automation preset' + ) + } + return + } + if (raw === 'hourly' && flags.has('time')) { + throw new RuntimeClientError( + 'invalid_argument', + '--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute' + ) + } + if (raw !== 'weekly' && flags.has('day')) { + throw new RuntimeClientError( + 'invalid_argument', + '--day can only be used with the weekly automation preset' + ) + } +} + +function getOptionalDayFlag(flags: Map): number | undefined { + const raw = flags.get('day') + if (raw === undefined) { + return undefined + } + const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN + if (!Number.isInteger(day) || day < 0 || day > 6) { + throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6') + } + return day +} + +export function getProviderFlag(flags: Map): TuiAgent { + const provider = getRequiredStringFlag(flags, 'provider') + if (!isTuiAgent(provider)) { + throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) + } + return provider +} + +export function getOptionalProviderFlag( + flags: Map +): TuiAgent | undefined { + const provider = getOptionalStringFlag(flags, 'provider') + if (provider === undefined) { + return undefined + } + if (!isTuiAgent(provider)) { + throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) + } + return provider +} + +function getTimeFlag(flags: Map): { hour: number; minute: number } { + const value = flags.get('time') + if (value === undefined) { + return { hour: 9, minute: 0 } + } + if (typeof value !== 'string' || value.length === 0) { + throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format') + } + return parseTimeFlag(value) +} + +function parseTimeFlag(value: string): { hour: number; minute: number } { + const match = /^(\d{1,2}):(\d{2})$/.exec(value) + if (!match) { + throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format') + } + const hour = Number(match[1]) + const minute = Number(match[2]) + if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time') + } + return { hour, minute } +} + +export function getScheduleFlag( + flags: Map, + required: boolean +): { rrule: string; dtstart: number } | undefined { + const trigger = getOptionalStringFlag(flags, 'trigger') + const schedule = getOptionalStringFlag(flags, 'schedule') + if (trigger && schedule) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --trigger or --schedule, not both.' + ) + } + const raw = trigger ?? schedule + if (!raw) { + const modifier = getScheduleModifierFlag(flags) + if (modifier) { + throw new RuntimeClientError( + 'invalid_argument', + `--${modifier} requires --trigger or --schedule` + ) + } + if (required) { + throw new RuntimeClientError('invalid_argument', 'Missing required --trigger') + } + return undefined + } + if (raw === 'manual') { + throw new RuntimeClientError( + 'invalid_argument', + 'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run ` when needed.' + ) + } + validateScheduleModifierApplicability(flags, raw) + const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags) + const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1 + const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset) + ? buildAutomationRrule({ + preset: raw as Exclude, + hour, + minute, + dayOfWeek + }) + : raw + if (!isValidAutomationSchedule(rrule)) { + throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`) + } + return { rrule, dtstart: Date.now() } +} + +export function getEnabledFlag(flags: Map): boolean | undefined { + const enabledFlag = flags.get('enabled') + const disabledFlag = flags.get('disabled') + if (typeof enabledFlag === 'string') { + throw new RuntimeClientError('invalid_argument', '--enabled does not take a value') + } + if (typeof disabledFlag === 'string') { + throw new RuntimeClientError('invalid_argument', '--disabled does not take a value') + } + const enabled = enabledFlag === true + const disabled = disabledFlag === true + if (enabled && disabled) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --enabled or --disabled, not both.' + ) + } + if (enabled) { + return true + } + if (disabled) { + return false + } + return undefined +} + +export function getReuseSessionFlag(flags: Map): boolean | undefined { + const reuseFlag = flags.get('reuse-session') + const freshFlag = flags.get('fresh-session') + if (typeof reuseFlag === 'string') { + throw new RuntimeClientError('invalid_argument', '--reuse-session does not take a value') + } + if (typeof freshFlag === 'string') { + throw new RuntimeClientError('invalid_argument', '--fresh-session does not take a value') + } + const reuse = reuseFlag === true + const fresh = freshFlag === true + if (reuse && fresh) { + throw new RuntimeClientError( + 'invalid_argument', + 'Use either --reuse-session or --fresh-session, not both.' + ) + } + if (reuse) { + return true + } + if (fresh) { + return false + } + return undefined +} + +export function getPrecheckFlag( + flags: Map +): AutomationPrecheck | null | undefined { + const hasPrecheck = flags.has('precheck') + const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout') + if (!hasPrecheck) { + if (timeoutSeconds !== undefined) { + throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck') + } + return undefined + } + const value = flags.get('precheck') + if (typeof value !== 'string') { + throw new RuntimeClientError('invalid_argument', '--precheck requires a command') + } + const command = value.trim() + if (!command) { + return null + } + if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) { + throw new RuntimeClientError( + 'invalid_argument', + `--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds` + ) + } + return { + command, + timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS + } +} + +export function getSourceContextFlag( + flags: Map +): TaskSourceContext | null | undefined { + if (!flags.has('source-context')) { + return undefined + } + const value = flags.get('source-context') + if (typeof value !== 'string') { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context requires a JSON TaskSourceContext or null' + ) + } + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON') + } + if (parsed === null) { + return null + } + if (!parsed || typeof parsed !== 'object') { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context must be a JSON TaskSourceContext or null' + ) + } + const sourceContext = normalizeTaskSourceContext( + parsed as Parameters[0] + ) + if (!sourceContext) { + throw new RuntimeClientError( + 'invalid_argument', + '--source-context is not a valid TaskSourceContext' + ) + } + return sourceContext +} + +export function getWorkspaceModeFlag( + flags: Map +): 'existing' | 'new_per_run' | undefined { + const value = getOptionalStringFlag(flags, 'workspace-mode') + if (value === undefined) { + return undefined + } + if (value === 'existing') { + return 'existing' + } + if (value === 'new-per-run' || value === 'new_per_run') { + return 'new_per_run' + } + throw new RuntimeClientError( + 'invalid_argument', + '--workspace-mode must be existing or new-per-run' + ) +} diff --git a/src/cli/handlers/automations.ts b/src/cli/handlers/automations.ts index d413b8a2a79..a9a14cc7d78 100644 --- a/src/cli/handlers/automations.ts +++ b/src/cli/handlers/automations.ts @@ -1,16 +1,11 @@ -/* eslint-disable max-lines -- Why: automation handlers share schedule parsing, target resolution, and RPC payload shaping for one command family. */ import type { Automation, AutomationCreateInput, - AutomationPrecheck, AutomationRun, - AutomationSchedulePreset, AutomationUpdateInput } from '../../shared/automations-types' import { buildWorkspaceRunContext, - normalizeTaskSourceContext, - type TaskSourceContext, type WorkspaceRunContext } from '../../shared/task-source-context' import type { @@ -18,13 +13,6 @@ import type { AutomationOwnerPrecondition } from '../../shared/automation-owner-precondition' import type { ProjectHostSetup } from '../../shared/project-types' -import type { TuiAgent } from '../../shared/tui-agent' -import { - DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, - MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS -} from '../../shared/automation-precheck' -import { buildAutomationRrule, isValidAutomationSchedule } from '../../shared/automation-schedules' -import { isTuiAgent } from '../../shared/tui-agent-config' import type { CommandHandler } from '../dispatch' import { formatAutomationList, @@ -48,6 +36,16 @@ import { hasWorkspaceProjectTarget, resolveProjectCreateTarget } from '../worktree-project-target' +import { + getEnabledFlag, + getOptionalProviderFlag, + getPrecheckFlag, + getProviderFlag, + getReuseSessionFlag, + getScheduleFlag, + getSourceContextFlag, + getWorkspaceModeFlag +} from './automation-handler-flags' type AutomationCreateParams = Omit & { destination?: AutomationDestination @@ -61,293 +59,6 @@ type AutomationUpdateParams = AutomationUpdateInput & { workspace?: string } -const PRESET_TRIGGERS = new Set(['hourly', 'daily', 'weekdays', 'weekly']) -const SCHEDULE_MODIFIER_FLAGS = ['day', 'time'] as const - -function getScheduleModifierFlag(flags: Map): string | undefined { - return SCHEDULE_MODIFIER_FLAGS.find((flag) => flags.has(flag)) -} - -function validateScheduleModifierApplicability( - flags: Map, - raw: string -): void { - const isPreset = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset) - if (!isPreset) { - if (flags.has('time')) { - throw new RuntimeClientError( - 'invalid_argument', - '--time can only be used with preset automation triggers' - ) - } - if (flags.has('day')) { - throw new RuntimeClientError( - 'invalid_argument', - '--day can only be used with the weekly automation preset' - ) - } - return - } - if (raw === 'hourly' && flags.has('time')) { - throw new RuntimeClientError( - 'invalid_argument', - '--time cannot be used with the hourly automation preset; use a cron trigger such as "30 * * * *" to choose the minute' - ) - } - if (raw !== 'weekly' && flags.has('day')) { - throw new RuntimeClientError( - 'invalid_argument', - '--day can only be used with the weekly automation preset' - ) - } -} - -function getOptionalDayFlag(flags: Map): number | undefined { - const raw = flags.get('day') - if (raw === undefined) { - return undefined - } - const day = typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : Number.NaN - if (!Number.isInteger(day) || day < 0 || day > 6) { - throw new RuntimeClientError('invalid_argument', '--day must be an integer from 0 to 6') - } - return day -} - -function getProviderFlag(flags: Map): TuiAgent { - const provider = getRequiredStringFlag(flags, 'provider') - if (!isTuiAgent(provider)) { - throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) - } - return provider -} - -function getOptionalProviderFlag(flags: Map): TuiAgent | undefined { - const provider = getOptionalStringFlag(flags, 'provider') - if (provider === undefined) { - return undefined - } - if (!isTuiAgent(provider)) { - throw new RuntimeClientError('invalid_argument', `Unknown provider: ${provider}`) - } - return provider -} - -function getTimeFlag(flags: Map): { hour: number; minute: number } { - const value = flags.get('time') - if (value === undefined) { - return { hour: 9, minute: 0 } - } - if (typeof value !== 'string' || value.length === 0) { - throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format') - } - return parseTimeFlag(value) -} - -function parseTimeFlag(value: string): { hour: number; minute: number } { - const match = /^(\d{1,2}):(\d{2})$/.exec(value) - if (!match) { - throw new RuntimeClientError('invalid_argument', '--time must use HH:MM format') - } - const hour = Number(match[1]) - const minute = Number(match[2]) - if (!Number.isInteger(hour) || hour < 0 || hour > 23 || minute < 0 || minute > 59) { - throw new RuntimeClientError('invalid_argument', '--time must be a valid 24-hour time') - } - return { hour, minute } -} - -function getScheduleFlag( - flags: Map, - required: boolean -): { rrule: string; dtstart: number } | undefined { - const trigger = getOptionalStringFlag(flags, 'trigger') - const schedule = getOptionalStringFlag(flags, 'schedule') - if (trigger && schedule) { - throw new RuntimeClientError( - 'invalid_argument', - 'Use either --trigger or --schedule, not both.' - ) - } - const raw = trigger ?? schedule - if (!raw) { - const modifier = getScheduleModifierFlag(flags) - if (modifier) { - throw new RuntimeClientError( - 'invalid_argument', - `--${modifier} requires --trigger or --schedule` - ) - } - if (required) { - throw new RuntimeClientError('invalid_argument', 'Missing required --trigger') - } - return undefined - } - if (raw === 'manual') { - throw new RuntimeClientError( - 'invalid_argument', - 'Manual-only automations are not supported yet. Create a scheduled automation with --disabled and run it with `orca automations run ` when needed.' - ) - } - validateScheduleModifierApplicability(flags, raw) - const { hour, minute } = raw === 'hourly' ? { hour: 0, minute: 0 } : getTimeFlag(flags) - const dayOfWeek = raw === 'weekly' ? (getOptionalDayFlag(flags) ?? 1) : 1 - const rrule = PRESET_TRIGGERS.has(raw as AutomationSchedulePreset) - ? buildAutomationRrule({ - preset: raw as Exclude, - hour, - minute, - dayOfWeek - }) - : raw - if (!isValidAutomationSchedule(rrule)) { - throw new RuntimeClientError('invalid_argument', `Invalid automation trigger: ${raw}`) - } - return { rrule, dtstart: Date.now() } -} - -function getEnabledFlag(flags: Map): boolean | undefined { - const enabledFlag = flags.get('enabled') - const disabledFlag = flags.get('disabled') - if (typeof enabledFlag === 'string') { - throw new RuntimeClientError('invalid_argument', '--enabled does not take a value') - } - if (typeof disabledFlag === 'string') { - throw new RuntimeClientError('invalid_argument', '--disabled does not take a value') - } - const enabled = enabledFlag === true - const disabled = disabledFlag === true - if (enabled && disabled) { - throw new RuntimeClientError( - 'invalid_argument', - 'Use either --enabled or --disabled, not both.' - ) - } - if (enabled) { - return true - } - if (disabled) { - return false - } - return undefined -} - -function getReuseSessionFlag(flags: Map): boolean | undefined { - const reuseFlag = flags.get('reuse-session') - const freshFlag = flags.get('fresh-session') - if (typeof reuseFlag === 'string') { - throw new RuntimeClientError('invalid_argument', '--reuse-session does not take a value') - } - if (typeof freshFlag === 'string') { - throw new RuntimeClientError('invalid_argument', '--fresh-session does not take a value') - } - const reuse = reuseFlag === true - const fresh = freshFlag === true - if (reuse && fresh) { - throw new RuntimeClientError( - 'invalid_argument', - 'Use either --reuse-session or --fresh-session, not both.' - ) - } - if (reuse) { - return true - } - if (fresh) { - return false - } - return undefined -} - -function getPrecheckFlag( - flags: Map -): AutomationPrecheck | null | undefined { - const hasPrecheck = flags.has('precheck') - const timeoutSeconds = getOptionalPositiveIntegerFlag(flags, 'precheck-timeout') - if (!hasPrecheck) { - if (timeoutSeconds !== undefined) { - throw new RuntimeClientError('invalid_argument', '--precheck-timeout requires --precheck') - } - return undefined - } - const value = flags.get('precheck') - if (typeof value !== 'string') { - throw new RuntimeClientError('invalid_argument', '--precheck requires a command') - } - const command = value.trim() - if (!command) { - return null - } - if (timeoutSeconds !== undefined && timeoutSeconds > MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS) { - throw new RuntimeClientError( - 'invalid_argument', - `--precheck-timeout must be at most ${MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS} seconds` - ) - } - return { - command, - timeoutSeconds: timeoutSeconds ?? DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS - } -} - -function getSourceContextFlag( - flags: Map -): TaskSourceContext | null | undefined { - if (!flags.has('source-context')) { - return undefined - } - const value = flags.get('source-context') - if (typeof value !== 'string') { - throw new RuntimeClientError( - 'invalid_argument', - '--source-context requires a JSON TaskSourceContext or null' - ) - } - let parsed: unknown - try { - parsed = JSON.parse(value) - } catch { - throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON') - } - if (parsed === null) { - return null - } - if (!parsed || typeof parsed !== 'object') { - throw new RuntimeClientError( - 'invalid_argument', - '--source-context must be a JSON TaskSourceContext or null' - ) - } - const sourceContext = normalizeTaskSourceContext( - parsed as Parameters[0] - ) - if (!sourceContext) { - throw new RuntimeClientError( - 'invalid_argument', - '--source-context is not a valid TaskSourceContext' - ) - } - return sourceContext -} - -function getWorkspaceModeFlag( - flags: Map -): 'existing' | 'new_per_run' | undefined { - const value = getOptionalStringFlag(flags, 'workspace-mode') - if (value === undefined) { - return undefined - } - if (value === 'existing') { - return 'existing' - } - if (value === 'new-per-run' || value === 'new_per_run') { - return 'new_per_run' - } - throw new RuntimeClientError( - 'invalid_argument', - '--workspace-mode must be existing or new-per-run' - ) -} - async function resolveDefaultTarget( flags: Map, cwd: string, diff --git a/src/cli/help.ts b/src/cli/help.ts index 22272a2c28d..7a6b3686123 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,371 +1,10 @@ -/* eslint-disable max-lines -- Why: root and generated command help text live together so CLI discovery stays greppable. */ import type { CommandSpec } from './args' import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args' import { unknownCommandData } from './command-suggestion' +import { ROOT_HELP_TEXT_PRIMARY } from './root-help-text-primary' +import { ROOT_HELP_TEXT_SECONDARY } from './root-help-text-secondary' -const ROOT_HELP_TEXT = `orca - -Usage: orca [options] - -Startup: - open Launch Orca and wait for the runtime to be reachable - serve Start a headless Orca runtime server - status Show app/runtime/graph readiness - -Diagnostics: - diagnostics memory Collect a memory snapshot for Orca and managed terminals - -Agent Discovery: - agent-context Print the machine-readable command schema for agents - -Accounts: - account add Add a managed Claude or Codex account on this Orca host - account list List managed Claude and Codex accounts on this Orca host - -Skills: - skills installed List installed skill selectors - skills share Publish selected skills behind one unlisted link - skills list List version-matched skill guides bundled with this Orca CLI - skills get Print a version-matched skill guide as Markdown - skills install Install bundled Orca skills globally via the community skills CLI - skills update Update already-installed Orca skills via the community skills CLI - -Hosts: - host list List targetable machines and how to name each one - -Environments: - environment add Save a remote Orca runtime from a pairing code - environment list List saved remote Orca runtimes - environment show Show one saved remote Orca runtime - environment rm Remove a saved remote Orca runtime - -Environment Recipes: - vm recipe doctor Validate a per-workspace environment recipe - -Automations: - automations list List scheduled Orca automations - automations show Show one Orca automation - automations create Create a scheduled Orca automation - automations edit Edit an Orca automation - automations remove Remove an Orca automation and its run history - automations run Run an Orca automation now - automations runs List automation run history - -Projects: - project list List durable projects known to Orca - project setups List project host setups - project setup-existing-folder Make a project available on a host by importing an existing folder - project setup-clone Make a project available on a host by cloning a repository - project setup-create Create independent project host setup metadata - project setup-update Update project host setup metadata - project setup-delete Remove a project host setup - -Repos: - repo list List repos registered in Orca - repo add Add a project to Orca by filesystem path - repo show Show one registered repo - repo set-base-ref Set the repo's default base ref for future worktrees - repo search-refs Search branch/tag refs within a repo - -Worktrees: - worktree list List Orca-managed worktrees - worktree show Show one worktree - worktree current Show the Orca-managed worktree for the current directory - worktree create Create a new Orca-managed worktree - worktree set Update Orca metadata for a worktree - worktree rm Remove a worktree from Orca and git - worktree ps Show a compact orchestration summary across worktrees - -Files: - file open Open a workspace file in the Orca editor - file diff Open a workspace file diff in the Orca editor - file open-changed Open all git-changed files for a workspace - -Terminals: - terminal list List live Orca-managed terminals - terminal show Show terminal metadata and preview - terminal read Read bounded terminal output - terminal send Send input to a live terminal - terminal wait Wait for a terminal condition (exit, tui-idle) - terminal stop Stop terminals for a worktree - terminal create Create a terminal session in a worktree - terminal rename Set or clear the title of a terminal tab - terminal split Split an existing terminal pane - terminal switch Bring a terminal tab to the foreground - terminal focus Alias for terminal switch - terminal close Close a terminal pane/session, or its whole tab with --tab - -Orchestration: - orchestration run-create Create and bind a lightweight orchestration Run - orchestration run-use Bind this coordinator terminal to an existing Run - orchestration run-current Show this terminal's bound Run - orchestration run-list List lightweight orchestration Runs - orchestration run-show Show one lightweight orchestration Run - orchestration send Send an inter-agent message - orchestration check Check the bound Run mailbox - orchestration ask Ask the coordinator a blocking question - orchestration reply Reply to a message - orchestration inbox Show all messages across recipients - orchestration task-create Create an orchestration task - orchestration task-list List orchestration tasks - orchestration task-update Update a task status - orchestration dispatch Dispatch a task to a terminal - orchestration dispatch-show Show dispatch context for a task - orchestration worker-start Start a supervised worker locally or on a connected Orca server - orchestration worker-show Inspect one supervised worker - orchestration worker-read Read bounded output from one supervised worker - orchestration worker-stop Fence one Dispatch; stop only its supervised worker - orchestration worker-abandon Fence an uncertain worker without claiming it stopped - orchestration worker-release Release a settled worker's terminal after archiving its output - orchestration worker-retain Keep a worker terminal live for debugging - orchestration worker-list Report worker terminal resource accounting - orchestration coordinator-start Start the legacy automatic coordinator loop - orchestration coordinator-stop Stop the legacy automatic coordinator loop - orchestration gate-create Create a decision gate blocking a task - orchestration gate-resolve Resolve a pending decision gate - orchestration gate-list List decision gates - orchestration reset Reset orchestration state - -Computer Use: - computer capabilities Show computer-use provider capabilities - computer permissions Show or open computer-use permission setup - computer list-apps List running apps available to computer-use - computer list-windows List visible windows for a target app - computer get-app-state Capture a compact accessibility snapshot of an app - computer click Click an app element or window coordinate - computer perform-secondary-action Run an advertised accessibility action - computer scroll Scroll an app element - computer drag Drag between app elements or window coordinates - computer type-text Type literal text at the current app focus - computer press-key Press a single key such as Return or Escape - computer hotkey Press a shortcut combination such as CmdOrCtrl+A - computer paste-text Paste text through the native clipboard path - computer set-value Set the value of a settable app element - -Linear: - linear Read Linear ticket context for agents - -Mobile Emulator (iOS Simulator): - emulator list List available/running emulators (Orca-managed + raw serve-sim) - emulator attach Attach/start helper and make active for the worktree - emulator tap Tap at normalized 0..1 coords (preferred for single taps) - emulator type Type text (US ASCII only) - emulator gesture Send begin/move/end touch points - emulator button Hardware button (home, side_button, etc.) - emulator rotate Rotate device (portrait|landscape_left|...) - emulator exec --command Raw serve-sim subcommand passthrough (no "serve-sim" prefix) - emulator kill Stop helper for device - -Browser Automation: - tab create Create a new browser tab (navigates to --url) - tab list List open browser tabs - tab show Show one browser tab by page id - tab current Show the current browser tab - tab profile list List browser session profiles - tab profile create Create a browser session profile - tab profile delete Delete a browser session profile - tab profile set Switch a browser tab to a different profile - tab profile show Show the profile bound to a browser tab - tab profile use-default Switch a browser tab back to the default profile - tab profile clone Clone a browser tab into another profile - tab switch Switch the active browser tab by --index or --page - tab close Close a browser tab by --index/--page or the current tab - snapshot Accessibility snapshot with element refs (e.g. @e1, @e2) - goto Navigate the active tab to --url - click Click element by --element ref - fill Clear and fill input by --element ref with --value - type Type --input text at the current focus (no element needed) - select Select dropdown option by --element ref and --value - hover Hover element by --element ref - keypress Press a key (e.g. --key Enter, --key Tab) - scroll Scroll --direction (up/down) by --amount pixels - back Navigate back in browser history - reload Reload the active browser tab - screenshot Capture viewport screenshot (--format png|jpeg) - eval Evaluate --expression JavaScript in the page context - wait Wait for page idle or --timeout ms - check Check a checkbox by --element ref - uncheck Uncheck a checkbox by --element ref - focus Focus an element by --element ref - clear Clear an input by --element ref - drag Drag --from ref to --to ref - upload Upload --files to a file input by --element ref - dblclick Double-click element by --element ref - forward Navigate forward in browser history - scrollintoview Scroll --element into view - get Get element property (--what: text, html, value, url, title) - is Check element state (--what: visible, enabled, checked) - inserttext Insert text without key events - mouse move Move mouse to --x --y coordinates - mouse down Press mouse button - mouse up Release mouse button - mouse wheel Scroll wheel --dy [--dx] - find Find element by locator (--locator role|text|label --value ) - set device Emulate device (--name "iPhone 12") - set offline Toggle offline mode (--state on|off) - set headers Set HTTP headers (--headers '{"key":"val"}') - set credentials Set HTTP auth (--user --pass

) - set media Set color scheme (--color-scheme dark|light) - clipboard read Read clipboard contents - clipboard write Write --text to clipboard - dialog accept Accept browser dialog (--text for prompt response) - dialog dismiss Dismiss browser dialog - storage local get Get localStorage value by --key - storage local set Set localStorage --key --value - storage local clear Clear localStorage - storage session get Get sessionStorage value by --key - storage session set Set sessionStorage --key --value - storage session clear Clear sessionStorage - download Download file via --selector to --path - highlight Highlight --selector on page - exec Run any agent-browser command (--command "...") - -Common Commands: - orca open [--json] - orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--project-root ] [--recipe-json] [--json] - orca status [--json] - orca diagnostics memory [--json] - orca agent-context [--json] - orca account add [--agent claude|codex] [--json] - orca account list [--json] - orca host list [--json] - orca environment add --name --pairing-code [--json] - orca environment list [--json] - orca environment show --environment [--json] - orca environment rm --environment [--json] - orca worktree list [--repo ] [--limit ] [--json] - orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json] - orca worktree show --worktree [--json] - orca worktree current [--json] - orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json] - orca worktree rm --worktree [--force] [--run-hooks] [--json] - orca worktree ps [--limit ] [--json] - orca file open [--worktree ] [--json] - orca file diff [--staged] [--worktree ] [--json] - orca file open-changed [--mode edit|diff|both] [--worktree ] [--json] - orca terminal list [--worktree ] [--limit ] [--include-visual-layouts] [--json] - orca terminal show [--terminal ] [--json] - orca terminal read [--terminal ] [--cursor ] [--limit ] [--json] - orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json] - orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json] - orca terminal stop --worktree [--json] - orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json] - orca terminal split [--terminal ] [--direction horizontal|vertical] [--json] - orca terminal switch [--terminal ] [--json] - orca terminal close [--terminal ] [--tab] [--json] - orca project list [--json] - orca project setups [--project ] [--host ] [--json] - orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json] - orca project setup-clone --project --host --url --destination [--display-name ] [--json] - orca project setup-create --project --host [--setup-id ] [--path ] [--kind git|folder] [--display-name ] [--worktree-base-path ] [--git-username ] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json] - orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json] - orca project setup-delete --setup [--json] - orca repo list [--json] - orca repo add --path [--json] - orca repo show --repo [--json] - orca repo set-base-ref --repo --ref [--json] - orca repo search-refs --repo --query [--limit ] [--json] - -Selectors: - --repo Registered repo selector such as id:, name:, or path: - --worktree Worktree selector such as identity:, id:::, name:, branch:, issue:, path:, or active/current - --terminal Runtime-issued terminal handle returned by \`orca terminal list --json\` - --parent-worktree Parent worktree selector such as identity:, id:::, branch:, issue:, path:, or active/current - --no-parent Force no parent lineage for unrelated worktree creation/update - -Terminal Send Options: - --text Text to send to the terminal - --enter Append Enter after sending text - --interrupt Send as an interrupt-style input when supported - -Terminal List Options: - --include-visual-layouts Include tab and pane topology in JSON output - -Wait Options: - --for exit Wait until the target terminal exits - --timeout-ms Maximum wait time before timing out - -Output Options: - --json Emit machine-readable JSON instead of human text - --pairing-code Connect to a remote Orca runtime using an orca://pair?... code - --environment Connect using a saved environment id or name - --help Show this help message - -Behavior: - Most commands require a running Orca runtime. If Orca is not open yet, run \`orca open\` first. - Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT. - Use selectors for discovery and handles for repeated live terminal operations. - -Agent Sessions And Worktrees: - \`worktree create --agent\` creates a new checkout with an agent. - To start a fresh agent in the current worktree, use: - orca terminal create --worktree active --command "codex" - -Browser Workflow: - 1. Create or navigate: orca tab create --url https://example.com - orca goto --url https://example.com - 2. Inspect the page: orca snapshot - (Returns an accessibility tree with element refs like e1, e2, e3) - For concurrent workflows, prefer: orca tab list --json - then reuse tabs[].browserPageId with --page on later commands. - 3. Interact: orca click --element e2 - orca fill --element e5 --value "search query" - orca keypress --key Enter - 4. Re-inspect: orca snapshot - (Element refs change after navigation — always re-snapshot before interacting) - -Browser Options: - --element Element ref from snapshot (e.g. @e3) - --url URL to navigate to - --value Value to fill or select - --input Text to type at current focus (no element needed) - --expression JavaScript expression to evaluate - --key Key to press (Enter, Tab, Escape, Control+a, etc.) - --direction

Scroll direction: up or down - --amount Scroll distance in pixels (default: viewport height) - --index Tab index (from \`tab list\`) - --page Stable browser page id (preferred for concurrent workflows) - --profile Browser profile id - --show-profile Include the tab's browser profile in text output - --no-ua-spoof Keep Electron's native user agent for a new profile - --format Screenshot image format - --from Drag source element ref - --to Drag target element ref - --files Comma-separated file paths for upload - --timeout Wait timeout in milliseconds - --worktree Scope commands to a specific worktree's browser tabs - -Examples: - $ orca open - $ orca status --json - $ orca diagnostics memory --json - $ orca repo list - $ orca worktree create --name agent-task --agent codex --prompt "hi" - $ orca worktree create --repo name:orca --name cli-test-1 --issue 273 - $ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue - $ orca worktree create --name linear-task --linear-issue STA-335 - $ orca worktree show --worktree branch:Jinwoo-H/cli - $ orca worktree current - $ orca worktree set --worktree active --comment "waiting on review" - $ orca worktree set --worktree active --linear-issue null - $ orca worktree ps --limit 10 - $ orca file open-changed --mode diff - $ orca file open src/App.tsx - $ orca terminal create --worktree active --command "codex" - $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json - $ orca terminal send --terminal term_123 --text "hi" --enter - $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json - $ orca tab current --json - $ orca tab show --page page_123 --json - $ orca tab create --url https://example.com --profile work - $ orca tab profile clone --page page_123 --profile work --json - $ orca snapshot - $ orca click --element e3 - $ orca fill --element e5 --value "hello" - $ orca goto --url https://example.com/login - $ orca keypress --key Enter - $ orca eval --expression "document.title" - $ orca tab list --json` +const ROOT_HELP_TEXT = [ROOT_HELP_TEXT_PRIMARY, ROOT_HELP_TEXT_SECONDARY].join('\n') export function printHelp(specs: CommandSpec[], commandPath: string[] = []): void { const exactSpec = findCommandSpec(specs, commandPath) diff --git a/src/cli/root-help-text-primary.ts b/src/cli/root-help-text-primary.ts new file mode 100644 index 00000000000..6f282c884fd --- /dev/null +++ b/src/cli/root-help-text-primary.ts @@ -0,0 +1,184 @@ +export const ROOT_HELP_TEXT_PRIMARY = [ + 'orca', + '', + 'Usage: orca [options]', + '', + 'Startup:', + ' open Launch Orca and wait for the runtime to be reachable', + ' serve Start a headless Orca runtime server', + ' status Show app/runtime/graph readiness', + '', + 'Diagnostics:', + ' diagnostics memory Collect a memory snapshot for Orca and managed terminals', + '', + 'Agent Discovery:', + ' agent-context Print the machine-readable command schema for agents', + '', + 'Accounts:', + ' account add Add a managed Claude or Codex account on this Orca host', + ' account list List managed Claude and Codex accounts on this Orca host', + '', + 'Skills:', + ' skills installed List installed skill selectors', + ' skills share Publish selected skills behind one unlisted link', + ' skills list List version-matched skill guides bundled with this Orca CLI', + ' skills get Print a version-matched skill guide as Markdown', + ' skills install Install bundled Orca skills globally via the community skills CLI', + ' skills update Update already-installed Orca skills via the community skills CLI', + '', + 'Hosts:', + ' host list List targetable machines and how to name each one', + '', + 'Environments:', + ' environment add Save a remote Orca runtime from a pairing code', + ' environment list List saved remote Orca runtimes', + ' environment show Show one saved remote Orca runtime', + ' environment rm Remove a saved remote Orca runtime', + '', + 'Environment Recipes:', + ' vm recipe doctor Validate a per-workspace environment recipe', + '', + 'Automations:', + ' automations list List scheduled Orca automations', + ' automations show Show one Orca automation', + ' automations create Create a scheduled Orca automation', + ' automations edit Edit an Orca automation', + ' automations remove Remove an Orca automation and its run history', + ' automations run Run an Orca automation now', + ' automations runs List automation run history', + '', + 'Projects:', + ' project list List durable projects known to Orca', + ' project setups List project host setups', + ' project setup-existing-folder Make a project available on a host by importing an existing folder', + ' project setup-clone Make a project available on a host by cloning a repository', + ' project setup-create Create independent project host setup metadata', + ' project setup-update Update project host setup metadata', + ' project setup-delete Remove a project host setup', + '', + 'Repos:', + ' repo list List repos registered in Orca', + ' repo add Add a project to Orca by filesystem path', + ' repo show Show one registered repo', + " repo set-base-ref Set the repo's default base ref for future worktrees", + ' repo search-refs Search branch/tag refs within a repo', + '', + 'Worktrees:', + ' worktree list List Orca-managed worktrees', + ' worktree show Show one worktree', + ' worktree current Show the Orca-managed worktree for the current directory', + ' worktree create Create a new Orca-managed worktree', + ' worktree set Update Orca metadata for a worktree', + ' worktree rm Remove a worktree from Orca and git', + ' worktree ps Show a compact orchestration summary across worktrees', + '', + 'Files:', + ' file open Open a workspace file in the Orca editor', + ' file diff Open a workspace file diff in the Orca editor', + ' file open-changed Open all git-changed files for a workspace', + '', + 'Terminals:', + ' terminal list List live Orca-managed terminals', + ' terminal show Show terminal metadata and preview', + ' terminal read Read bounded terminal output', + ' terminal send Send input to a live terminal', + ' terminal wait Wait for a terminal condition (exit, tui-idle)', + ' terminal stop Stop terminals for a worktree', + ' terminal create Create a terminal session in a worktree', + ' terminal rename Set or clear the title of a terminal tab', + ' terminal split Split an existing terminal pane', + ' terminal switch Bring a terminal tab to the foreground', + ' terminal focus Alias for terminal switch', + ' terminal close Close a terminal pane/session, or its whole tab with --tab', + '', + 'Orchestration:', + ' orchestration run-create Create and bind a lightweight orchestration Run', + ' orchestration run-use Bind this coordinator terminal to an existing Run', + " orchestration run-current Show this terminal's bound Run", + ' orchestration run-list List lightweight orchestration Runs', + ' orchestration run-show Show one lightweight orchestration Run', + ' orchestration send Send an inter-agent message', + ' orchestration check Check the bound Run mailbox', + ' orchestration ask Ask the coordinator a blocking question', + ' orchestration reply Reply to a message', + ' orchestration inbox Show all messages across recipients', + ' orchestration task-create Create an orchestration task', + ' orchestration task-list List orchestration tasks', + ' orchestration task-update Update a task status', + ' orchestration dispatch Dispatch a task to a terminal', + ' orchestration dispatch-show Show dispatch context for a task', + ' orchestration worker-start Start a supervised worker locally or on a connected Orca server', + ' orchestration worker-show Inspect one supervised worker', + ' orchestration worker-read Read bounded output from one supervised worker', + ' orchestration worker-stop Fence one Dispatch; stop only its supervised worker', + ' orchestration worker-abandon Fence an uncertain worker without claiming it stopped', + " orchestration worker-release Release a settled worker's terminal after archiving its output", + ' orchestration worker-retain Keep a worker terminal live for debugging', + ' orchestration worker-list Report worker terminal resource accounting', + ' orchestration coordinator-start Start the legacy automatic coordinator loop', + ' orchestration coordinator-stop Stop the legacy automatic coordinator loop', + ' orchestration gate-create Create a decision gate blocking a task', + ' orchestration gate-resolve Resolve a pending decision gate', + ' orchestration gate-list List decision gates', + ' orchestration reset Reset orchestration state', + '', + 'Computer Use:', + ' computer capabilities Show computer-use provider capabilities', + ' computer permissions Show or open computer-use permission setup', + ' computer list-apps List running apps available to computer-use', + ' computer list-windows List visible windows for a target app', + ' computer get-app-state Capture a compact accessibility snapshot of an app', + ' computer click Click an app element or window coordinate', + ' computer perform-secondary-action Run an advertised accessibility action', + ' computer scroll Scroll an app element', + ' computer drag Drag between app elements or window coordinates', + ' computer type-text Type literal text at the current app focus', + ' computer press-key Press a single key such as Return or Escape', + ' computer hotkey Press a shortcut combination such as CmdOrCtrl+A', + ' computer paste-text Paste text through the native clipboard path', + ' computer set-value Set the value of a settable app element', + '', + 'Linear:', + ' linear Read Linear ticket context for agents', + '', + 'Mobile Emulator (iOS Simulator):', + ' emulator list List available/running emulators (Orca-managed + raw serve-sim)', + ' emulator attach Attach/start helper and make active for the worktree', + ' emulator tap Tap at normalized 0..1 coords (preferred for single taps)', + ' emulator type Type text (US ASCII only)', + ' emulator gesture Send begin/move/end touch points', + ' emulator button Hardware button (home, side_button, etc.)', + ' emulator rotate Rotate device (portrait|landscape_left|...)', + ' emulator exec --command Raw serve-sim subcommand passthrough (no "serve-sim" prefix)', + ' emulator kill Stop helper for device', + '', + 'Browser Automation:', + ' tab create Create a new browser tab (navigates to --url)', + ' tab list List open browser tabs', + ' tab show Show one browser tab by page id', + ' tab current Show the current browser tab', + ' tab profile list List browser session profiles', + ' tab profile create Create a browser session profile', + ' tab profile delete Delete a browser session profile', + ' tab profile set Switch a browser tab to a different profile', + ' tab profile show Show the profile bound to a browser tab', + ' tab profile use-default Switch a browser tab back to the default profile', + ' tab profile clone Clone a browser tab into another profile', + ' tab switch Switch the active browser tab by --index or --page', + ' tab close Close a browser tab by --index/--page or the current tab', + ' snapshot Accessibility snapshot with element refs (e.g. @e1, @e2)', + ' goto Navigate the active tab to --url', + ' click Click element by --element ref', + ' fill Clear and fill input by --element ref with --value', + ' type Type --input text at the current focus (no element needed)', + ' select Select dropdown option by --element ref and --value', + ' hover Hover element by --element ref', + ' keypress Press a key (e.g. --key Enter, --key Tab)', + ' scroll Scroll --direction (up/down) by --amount pixels', + ' back Navigate back in browser history', + ' reload Reload the active browser tab', + ' screenshot Capture viewport screenshot (--format png|jpeg)', + ' eval Evaluate --expression JavaScript in the page context', + ' wait Wait for page idle or --timeout ms', + ' check Check a checkbox by --element ref' +].join('\n') diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts new file mode 100644 index 00000000000..79c2f7d21f1 --- /dev/null +++ b/src/cli/root-help-text-secondary.ts @@ -0,0 +1,183 @@ +export const ROOT_HELP_TEXT_SECONDARY = [ + ' uncheck Uncheck a checkbox by --element ref', + ' focus Focus an element by --element ref', + ' clear Clear an input by --element ref', + ' drag Drag --from ref to --to ref', + ' upload Upload --files to a file input by --element ref', + ' dblclick Double-click element by --element ref', + ' forward Navigate forward in browser history', + ' scrollintoview Scroll --element into view', + ' get Get element property (--what: text, html, value, url, title)', + ' is Check element state (--what: visible, enabled, checked)', + ' inserttext Insert text without key events', + ' mouse move Move mouse to --x --y coordinates', + ' mouse down Press mouse button', + ' mouse up Release mouse button', + ' mouse wheel Scroll wheel --dy [--dx]', + ' find Find element by locator (--locator role|text|label --value )', + ' set device Emulate device (--name "iPhone 12")', + ' set offline Toggle offline mode (--state on|off)', + ' set headers Set HTTP headers (--headers \'{"key":"val"}\')', + ' set credentials Set HTTP auth (--user --pass

)', + ' set media Set color scheme (--color-scheme dark|light)', + ' clipboard read Read clipboard contents', + ' clipboard write Write --text to clipboard', + ' dialog accept Accept browser dialog (--text for prompt response)', + ' dialog dismiss Dismiss browser dialog', + ' storage local get Get localStorage value by --key', + ' storage local set Set localStorage --key --value', + ' storage local clear Clear localStorage', + ' storage session get Get sessionStorage value by --key', + ' storage session set Set sessionStorage --key --value', + ' storage session clear Clear sessionStorage', + ' download Download file via --selector to --path', + ' highlight Highlight --selector on page', + ' exec Run any agent-browser command (--command "...")', + '', + 'Common Commands:', + ' orca open [--json]', + ' orca serve [--port ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--project-root ] [--recipe-json] [--json]', + ' orca status [--json]', + ' orca diagnostics memory [--json]', + ' orca agent-context [--json]', + ' orca account add [--agent claude|codex] [--json]', + ' orca account list [--json]', + ' orca host list [--json]', + ' orca environment add --name --pairing-code [--json]', + ' orca environment list [--json]', + ' orca environment show --environment [--json]', + ' orca environment rm --environment [--json]', + ' orca worktree list [--repo ] [--limit ] [--json]', + ' orca worktree create --name [--repo |--project [--host ]|--project-host-setup ] [--agent ] [--prompt ] [--setup run|skip|inherit] [--base-branch ] [--issue ] [--linear-issue ] [--comment ] [--parent-worktree ] [--no-parent] [--run-hooks] [--activate] [--json]', + ' orca worktree show --worktree [--json]', + ' orca worktree current [--json]', + ' orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json]', + ' orca worktree rm --worktree [--force] [--run-hooks] [--json]', + ' orca worktree ps [--limit ] [--json]', + ' orca file open [--worktree ] [--json]', + ' orca file diff [--staged] [--worktree ] [--json]', + ' orca file open-changed [--mode edit|diff|both] [--worktree ] [--json]', + ' orca terminal list [--worktree ] [--limit ] [--include-visual-layouts] [--json]', + ' orca terminal show [--terminal ] [--json]', + ' orca terminal read [--terminal ] [--cursor ] [--limit ] [--json]', + ' orca terminal send [--terminal ] [--text ] [--enter] [--interrupt] [--json]', + ' orca terminal wait [--terminal ] --for exit|tui-idle [--timeout-ms ] [--json]', + ' orca terminal stop --worktree [--json]', + ' orca terminal create [--worktree ] [--title ] [--command ] [--focus] [--json]', + ' orca terminal split [--terminal ] [--direction horizontal|vertical] [--json]', + ' orca terminal switch [--terminal ] [--json]', + ' orca terminal close [--terminal ] [--tab] [--json]', + ' orca project list [--json]', + ' orca project setups [--project ] [--host ] [--json]', + ' orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json]', + ' orca project setup-clone --project --host --url --destination [--display-name ] [--json]', + ' orca project setup-create --project --host [--setup-id ] [--path ] [--kind git|folder] [--display-name ] [--worktree-base-path ] [--git-username ] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]', + ' orca project setup-update --setup [--display-name ] [--path ] [--worktree-base-path ] [--git-username ] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]', + ' orca project setup-delete --setup [--json]', + ' orca repo list [--json]', + ' orca repo add --path [--json]', + ' orca repo show --repo [--json]', + ' orca repo set-base-ref --repo --ref [--json]', + ' orca repo search-refs --repo --query [--limit ] [--json]', + '', + 'Selectors:', + ' --repo Registered repo selector such as id:, name:, or path:', + ' --worktree Worktree selector such as identity:, id:::, name:, branch:, issue:, path:, or active/current', + ' --terminal Runtime-issued terminal handle returned by `orca terminal list --json`', + ' --parent-worktree Parent worktree selector such as identity:, id:::, branch:, issue:, path:, or active/current', + ' --no-parent Force no parent lineage for unrelated worktree creation/update', + '', + 'Terminal Send Options:', + ' --text Text to send to the terminal', + ' --enter Append Enter after sending text', + ' --interrupt Send as an interrupt-style input when supported', + '', + 'Terminal List Options:', + ' --include-visual-layouts Include tab and pane topology in JSON output', + '', + 'Wait Options:', + ' --for exit Wait until the target terminal exits', + ' --timeout-ms Maximum wait time before timing out', + '', + 'Output Options:', + ' --json Emit machine-readable JSON instead of human text', + ' --pairing-code Connect to a remote Orca runtime using an orca://pair?... code', + ' --environment Connect using a saved environment id or name', + ' --help Show this help message', + '', + 'Behavior:', + ' Most commands require a running Orca runtime. If Orca is not open yet, run `orca open` first.', + ' Remote runtime access can also be supplied with ORCA_PAIRING_CODE or ORCA_ENVIRONMENT.', + ' Use selectors for discovery and handles for repeated live terminal operations.', + '', + 'Agent Sessions And Worktrees:', + ' `worktree create --agent` creates a new checkout with an agent.', + ' To start a fresh agent in the current worktree, use:', + ' orca terminal create --worktree active --command "codex"', + '', + 'Browser Workflow:', + ' 1. Create or navigate: orca tab create --url https://example.com', + ' orca goto --url https://example.com', + ' 2. Inspect the page: orca snapshot', + ' (Returns an accessibility tree with element refs like e1, e2, e3)', + ' For concurrent workflows, prefer: orca tab list --json', + ' then reuse tabs[].browserPageId with --page on later commands.', + ' 3. Interact: orca click --element e2', + ' orca fill --element e5 --value "search query"', + ' orca keypress --key Enter', + ' 4. Re-inspect: orca snapshot', + ' (Element refs change after navigation — always re-snapshot before interacting)', + '', + 'Browser Options:', + ' --element Element ref from snapshot (e.g. @e3)', + ' --url URL to navigate to', + ' --value Value to fill or select', + ' --input Text to type at current focus (no element needed)', + ' --expression JavaScript expression to evaluate', + ' --key Key to press (Enter, Tab, Escape, Control+a, etc.)', + ' --direction

Scroll direction: up or down', + ' --amount Scroll distance in pixels (default: viewport height)', + ' --index Tab index (from `tab list`)', + ' --page Stable browser page id (preferred for concurrent workflows)', + ' --profile Browser profile id', + " --show-profile Include the tab's browser profile in text output", + " --no-ua-spoof Keep Electron's native user agent for a new profile", + ' --format Screenshot image format', + ' --from Drag source element ref', + ' --to Drag target element ref', + ' --files Comma-separated file paths for upload', + ' --timeout Wait timeout in milliseconds', + " --worktree Scope commands to a specific worktree's browser tabs", + '', + 'Examples:', + ' $ orca open', + ' $ orca status --json', + ' $ orca diagnostics memory --json', + ' $ orca repo list', + ' $ orca worktree create --name agent-task --agent codex --prompt "hi"', + ' $ orca worktree create --repo name:orca --name cli-test-1 --issue 273', + ' $ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue', + ' $ orca worktree create --name linear-task --linear-issue STA-335', + ' $ orca worktree show --worktree branch:Jinwoo-H/cli', + ' $ orca worktree current', + ' $ orca worktree set --worktree active --comment "waiting on review"', + ' $ orca worktree set --worktree active --linear-issue null', + ' $ orca worktree ps --limit 10', + ' $ orca file open-changed --mode diff', + ' $ orca file open src/App.tsx', + ' $ orca terminal create --worktree active --command "codex"', + ' $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json', + ' $ orca terminal send --terminal term_123 --text "hi" --enter', + ' $ orca terminal wait --terminal term_123 --for exit --timeout-ms 60000 --json', + ' $ orca tab current --json', + ' $ orca tab show --page page_123 --json', + ' $ orca tab create --url https://example.com --profile work', + ' $ orca tab profile clone --page page_123 --profile work --json', + ' $ orca snapshot', + ' $ orca click --element e3', + ' $ orca fill --element e5 --value "hello"', + ' $ orca goto --url https://example.com/login', + ' $ orca keypress --key Enter', + ' $ orca eval --expression "document.title"', + ' $ orca tab list --json' +].join('\n') diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index 7c95be6431c..a3c102e1dfc 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -1,14 +1,10 @@ -/* eslint-disable max-lines -- Why: WSL CLI status/install/remove share one state machine; - splitting the installer would separate conflict checks from the operations they guard. */ import type { CliInstallStatus } from '../../shared/cli-install-types' import { getDefaultWslDistro } from '../wsl' import { runWslProcess } from '../wsl/wsl-runner' import { CliInstaller } from './cli-installer' import { buildManagedLegacyRemoveCommand, - buildRegistrationLockPrelude, buildSafeRemoveCommand, - buildSafeReplaceGuard, buildWslBridgeScript, buildWslLauncher, getBridgePathFromCommandPath, @@ -18,10 +14,11 @@ import { parseManagedLauncherTarget, quoteShell } from './wsl-cli-scripts' +import { buildWslCliInstallCommand } from './wsl-cli-registration-command' +import { buildWslCliStatus, readWslCliCommandFile, resolveReadyWslCliState } from './wsl-cli-status' const MANAGED_MARKER = getWslLauncherMarker() const BRIDGE_MANAGED_MARKER = getWslBridgeMarker() -const WSL_COMMAND_NAME = 'orca-ide' const LEGACY_WSL_COMMAND_NAME = 'orca' const WSL_COMMAND_TIMEOUT_MS = 10_000 @@ -60,7 +57,12 @@ export class WslCliInstaller { } async getStatus(): Promise { - const ready = await this.resolveReadyState() + const ready = await resolveReadyWslCliState({ + platform: this.platform, + distro: this.distro, + getHostStatus: () => this.hostInstaller.getStatus(), + run: (distro, command) => this.run(distro, command) + }) if ('status' in ready) { return ready.status } @@ -208,67 +210,13 @@ export class WslCliInstaller { throw new Error(`Refusing to replace non-Orca command at ${status.commandPath}.`) } - // Why: the launcher and PowerShell bridge are one registration; the - // command replacement stays a single atomic rename (never missing for a - // concurrent shell) while a bridge copy enables rollback of the pair. await this.run( this.distro as string, - [ - // Why -eu not -euo pipefail: transported via runWslProcess's `sh -s`, - // and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option. - 'set -eu', - `mkdir -p ${quoteShell(status.pathDirectory as string)}`, - `mkdir -p ${quoteShell(getPosixDirname(getBridgePathFromCommandPath(status.commandPath)))}`, - buildRegistrationLockPrelude(status.commandPath), - `command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`, - `bridge_path=${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`, - `legacy_command_path=${quoteShell( - `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}` - )}`, - 'bridge_tmp="${bridge_path}.tmp.$$"', - 'bridge_backup="${bridge_tmp}.backup"', - 'bridge_had_original=0', - 'bridge_touched=0', - 'committed=0', - 'rollback() {', - ' result=$?', - ' set +e', - ' if [ "$committed" -ne 1 ]; then', - ` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}; fi`, - ' fi', - ' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"', - ' exit "$result"', - '}', - 'trap rollback EXIT', - buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER), - buildSafeReplaceGuard( - getBridgePathFromCommandPath(status.commandPath), - BRIDGE_MANAGED_MARKER - ), - `cat > "$command_tmp" <<'ORCA_WSL_CLI'`, - buildWslLauncher(status.launcherPath, getBridgePathFromCommandPath(status.commandPath)), - 'ORCA_WSL_CLI', - `cat > "$bridge_tmp" <<'ORCA_WSL_BRIDGE'`, - buildWslBridgeScript(), - 'ORCA_WSL_BRIDGE', - 'chmod 755 "$command_tmp"', - 'chmod 644 "$bridge_tmp"', - buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER), - buildSafeReplaceGuard( - getBridgePathFromCommandPath(status.commandPath), - BRIDGE_MANAGED_MARKER - ), - `if [ -f ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} ]; then cp -p ${quoteShell(getBridgePathFromCommandPath(status.commandPath))} "$bridge_backup"; bridge_had_original=1; fi`, - `mv -f "$bridge_tmp" ${quoteShell(getBridgePathFromCommandPath(status.commandPath))}`, - 'bridge_touched=1', - `mv -f "$command_tmp" ${quoteShell(status.commandPath)}`, - 'committed=1', - 'rm -f "$bridge_backup"', - // Why: the command was renamed to avoid GNOME Orca; remove only the - // old Orca-managed WSL wrapper after the replacement has committed. - buildManagedLegacyRemoveCommand('"$legacy_command_path"'), - 'trap - EXIT' - ].join('\n') + buildWslCliInstallCommand({ + ...status, + commandPath: status.commandPath, + launcherPath: status.launcherPath + }) ) return this.getStatus() } @@ -299,108 +247,15 @@ export class WslCliInstaller { return this.getStatus() } - private async resolveReadyState(): Promise< - | { status: CliInstallStatus } - | { - distro: string - commandPath: string - bridgePath: string - launcherPath: string - pathConfigured: boolean - } - > { - if (this.platform !== 'win32') { - return { - status: this.unsupported( - 'platform_not_supported', - 'WSL CLI registration is only available on Windows.' - ) - } - } - if (!this.distro) { - return { - status: this.unsupported('platform_not_supported', 'No WSL distribution is available.') - } - } - - const hostStatus = await this.hostInstaller.getStatus() - if (!hostStatus.launcherPath) { - return { - status: this.unsupported( - hostStatus.unsupportedReason ?? 'launcher_missing', - hostStatus.detail ?? 'The Windows Orca CLI launcher is missing.' - ) - } - } - - const home = (await this.run(this.distro, 'printf %s "$HOME"')).trim() - if (!home.startsWith('/')) { - return { - status: this.unsupported('launcher_missing', 'Unable to resolve the WSL home directory.') - } - } - - const interopReady = - ( - await this.run( - this.distro, - '{ command -v powershell.exe >/dev/null 2>&1 || [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; } && command -v wslpath >/dev/null 2>&1 && printf yes || printf no' - ) - ).trim() === 'yes' - if (!interopReady) { - return { - status: this.unsupported( - 'launcher_missing', - 'WSL Windows interop is unavailable; Orca cannot launch the Windows CLI from WSL.' - ) - } - } - - const pathDirectory = `${home}/.local/bin` - // Why: matches the Linux CLI rename to `orca-ide` (avoids GNOME Orca conflict). - const commandPath = `${pathDirectory}/${WSL_COMMAND_NAME}` - const pathConfigured = - ( - await this.run( - this.distro, - `case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac` - ) - ).trim() === 'yes' - - return { - distro: this.distro, - commandPath, - bridgePath: getBridgePathFromCommandPath(commandPath), - launcherPath: hostStatus.launcherPath, - pathConfigured - } - } - private async readCommandFile( distro: string, commandPath: string ): Promise<(string & {}) | 'not_file' | null> { - const output = await this.run( + return readWslCliCommandFile( + (targetDistro, command) => this.run(targetDistro, command), distro, - [ - `if [ -L ${quoteShell(commandPath)} ]; then`, - ' printf __ORCA_NOT_FILE__', - `elif [ ! -e ${quoteShell(commandPath)} ]; then`, - ' printf __ORCA_MISSING__', - `elif [ ! -f ${quoteShell(commandPath)} ]; then`, - ' printf __ORCA_NOT_FILE__', - 'else', - ` cat ${quoteShell(commandPath)}`, - 'fi' - ].join('\n') + commandPath ) - if (output === '__ORCA_MISSING__') { - return null - } - if (output === '__ORCA_NOT_FILE__') { - return 'not_file' - } - return output } private buildStatus(args: { @@ -412,43 +267,7 @@ export class WslCliInstaller { pathConfigured: boolean detail: string }): CliInstallStatus { - return { - platform: 'linux', - commandName: WSL_COMMAND_NAME, - commandPath: args.commandPath, - pathDirectory: getPosixDirname(args.commandPath), - pathConfigured: args.pathConfigured, - launcherPath: args.launcherPath, - installMethod: 'wrapper', - supported: true, - state: args.state, - currentTarget: args.currentTarget, - unsupportedReason: null, - detail: - args.state === 'installed' && !args.pathConfigured - ? `${args.commandPath} is registered, but ${getPosixDirname(args.commandPath)} is not on PATH in ${args.distro}.` - : args.detail - } - } - - private unsupported( - unsupportedReason: NonNullable, - detail: string - ): CliInstallStatus { - return { - platform: 'linux', - commandName: WSL_COMMAND_NAME, - commandPath: null, - pathDirectory: null, - pathConfigured: false, - launcherPath: null, - installMethod: null, - supported: false, - state: 'unsupported', - currentTarget: null, - unsupportedReason, - detail - } + return buildWslCliStatus(args) } private async run(distro: string, command: string): Promise { diff --git a/src/main/cli/wsl-cli-registration-command.ts b/src/main/cli/wsl-cli-registration-command.ts new file mode 100644 index 00000000000..53d39eaec86 --- /dev/null +++ b/src/main/cli/wsl-cli-registration-command.ts @@ -0,0 +1,70 @@ +import type { CliInstallStatus } from '../../shared/cli-install-types' +import { + buildManagedLegacyRemoveCommand, + buildRegistrationLockPrelude, + buildSafeReplaceGuard, + buildWslBridgeScript, + buildWslLauncher, + getBridgePathFromCommandPath, + getPosixDirname, + getWslBridgeMarker, + getWslLauncherMarker, + quoteShell +} from './wsl-cli-scripts' + +const MANAGED_MARKER = getWslLauncherMarker() +const BRIDGE_MANAGED_MARKER = getWslBridgeMarker() +const LEGACY_WSL_COMMAND_NAME = 'orca' + +export function buildWslCliInstallCommand( + status: CliInstallStatus & { commandPath: string; launcherPath: string } +): string { + const bridgePath = getBridgePathFromCommandPath(status.commandPath) + const legacyCommandPath = `${getPosixDirname(status.commandPath)}/${LEGACY_WSL_COMMAND_NAME}` + return [ + // Why -eu not -euo pipefail: transported via runWslProcess's `sh -s`, + // and no pipe here needs pipefail -- dash on Ubuntu 20.04 lacks the option. + 'set -eu', + `mkdir -p ${quoteShell(status.pathDirectory as string)}`, + `mkdir -p ${quoteShell(getPosixDirname(bridgePath))}`, + buildRegistrationLockPrelude(status.commandPath), + `command_tmp=${quoteShell(`${status.commandPath}.tmp`)}.$$`, + `bridge_path=${quoteShell(bridgePath)}`, + `legacy_command_path=${quoteShell(legacyCommandPath)}`, + 'bridge_tmp="${bridge_path}.tmp.$$"', + 'bridge_backup="${bridge_tmp}.backup"', + 'bridge_had_original=0', + 'bridge_touched=0', + 'committed=0', + 'rollback() {', + ' result=$?', + ' set +e', + ' if [ "$committed" -ne 1 ]; then', + ` if [ "$bridge_had_original" -eq 1 ]; then mv -f "$bridge_backup" ${quoteShell(bridgePath)}; elif [ "$bridge_touched" -eq 1 ]; then rm -f ${quoteShell(bridgePath)}; fi`, + ' fi', + ' rm -f "$command_tmp" "$bridge_tmp" "$bridge_backup"', + ' exit "$result"', + '}', + 'trap rollback EXIT', + buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER), + buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER), + `cat > "$command_tmp" <<'ORCA_WSL_CLI'`, + buildWslLauncher(status.launcherPath, bridgePath), + 'ORCA_WSL_CLI', + `cat > "$bridge_tmp" <<'ORCA_WSL_BRIDGE'`, + buildWslBridgeScript(), + 'ORCA_WSL_BRIDGE', + 'chmod 755 "$command_tmp"', + 'chmod 644 "$bridge_tmp"', + buildSafeReplaceGuard(status.commandPath, MANAGED_MARKER), + buildSafeReplaceGuard(bridgePath, BRIDGE_MANAGED_MARKER), + `if [ -f ${quoteShell(bridgePath)} ]; then cp -p ${quoteShell(bridgePath)} "$bridge_backup"; bridge_had_original=1; fi`, + `mv -f "$bridge_tmp" ${quoteShell(bridgePath)}`, + 'bridge_touched=1', + `mv -f "$command_tmp" ${quoteShell(status.commandPath)}`, + 'committed=1', + 'rm -f "$bridge_backup"', + buildManagedLegacyRemoveCommand('"$legacy_command_path"'), + 'trap - EXIT' + ].join('\n') +} diff --git a/src/main/cli/wsl-cli-status.ts b/src/main/cli/wsl-cli-status.ts new file mode 100644 index 00000000000..7661f402a45 --- /dev/null +++ b/src/main/cli/wsl-cli-status.ts @@ -0,0 +1,163 @@ +import type { CliInstallStatus } from '../../shared/cli-install-types' +import { getBridgePathFromCommandPath, getPosixDirname, quoteShell } from './wsl-cli-scripts' + +const WSL_COMMAND_NAME = 'orca-ide' + +export type ReadyWslCliState = { + distro: string + commandPath: string + bridgePath: string + launcherPath: string + pathConfigured: boolean +} + +export async function readWslCliCommandFile( + run: (distro: string, command: string) => Promise, + distro: string, + commandPath: string +): Promise<(string & {}) | 'not_file' | null> { + const output = await run( + distro, + [ + `if [ -L ${quoteShell(commandPath)} ]; then`, + ' printf __ORCA_NOT_FILE__', + `elif [ ! -e ${quoteShell(commandPath)} ]; then`, + ' printf __ORCA_MISSING__', + `elif [ ! -f ${quoteShell(commandPath)} ]; then`, + ' printf __ORCA_NOT_FILE__', + 'else', + ` cat ${quoteShell(commandPath)}`, + 'fi' + ].join('\n') + ) + if (output === '__ORCA_MISSING__') { + return null + } + if (output === '__ORCA_NOT_FILE__') { + return 'not_file' + } + return output +} + +export function buildWslCliStatus(args: { + distro: string + commandPath: string + launcherPath: string + state: CliInstallStatus['state'] + currentTarget: string | null + pathConfigured: boolean + detail: string +}): CliInstallStatus { + return { + platform: 'linux', + commandName: WSL_COMMAND_NAME, + commandPath: args.commandPath, + pathDirectory: getPosixDirname(args.commandPath), + pathConfigured: args.pathConfigured, + launcherPath: args.launcherPath, + installMethod: 'wrapper', + supported: true, + state: args.state, + currentTarget: args.currentTarget, + unsupportedReason: null, + detail: + args.state === 'installed' && !args.pathConfigured + ? `${args.commandPath} is registered, but ${getPosixDirname(args.commandPath)} is not on PATH in ${args.distro}.` + : args.detail + } +} + +export function unsupportedWslCliStatus( + unsupportedReason: NonNullable, + detail: string +): CliInstallStatus { + return { + platform: 'linux', + commandName: WSL_COMMAND_NAME, + commandPath: null, + pathDirectory: null, + pathConfigured: false, + launcherPath: null, + installMethod: null, + supported: false, + state: 'unsupported', + currentTarget: null, + unsupportedReason, + detail + } +} + +export async function resolveReadyWslCliState(args: { + platform: NodeJS.Platform + distro: string | null + getHostStatus: () => Promise + run: (distro: string, command: string) => Promise +}): Promise<{ status: CliInstallStatus } | ReadyWslCliState> { + if (args.platform !== 'win32') { + return { + status: unsupportedWslCliStatus( + 'platform_not_supported', + 'WSL CLI registration is only available on Windows.' + ) + } + } + if (!args.distro) { + return { + status: unsupportedWslCliStatus('platform_not_supported', 'No WSL distribution is available.') + } + } + + const hostStatus = await args.getHostStatus() + if (!hostStatus.launcherPath) { + return { + status: unsupportedWslCliStatus( + hostStatus.unsupportedReason ?? 'launcher_missing', + hostStatus.detail ?? 'The Windows Orca CLI launcher is missing.' + ) + } + } + + const home = (await args.run(args.distro, 'printf %s "$HOME"')).trim() + if (!home.startsWith('/')) { + return { + status: unsupportedWslCliStatus( + 'launcher_missing', + 'Unable to resolve the WSL home directory.' + ) + } + } + + const interopReady = + ( + await args.run( + args.distro, + '{ command -v powershell.exe >/dev/null 2>&1 || [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; } && command -v wslpath >/dev/null 2>&1 && printf yes || printf no' + ) + ).trim() === 'yes' + if (!interopReady) { + return { + status: unsupportedWslCliStatus( + 'launcher_missing', + 'WSL Windows interop is unavailable; Orca cannot launch the Windows CLI from WSL.' + ) + } + } + + const pathDirectory = `${home}/.local/bin` + const commandPath = `${pathDirectory}/${WSL_COMMAND_NAME}` + const pathConfigured = + ( + await args.run( + args.distro, + `case ":$PATH:" in *:${quoteShell(pathDirectory)}:*) printf yes ;; *) printf no ;; esac` + ) + ).trim() === 'yes' + + return { + distro: args.distro, + commandPath, + bridgePath: getBridgePathFromCommandPath(commandPath), + launcherPath: hostStatus.launcherPath, + pathConfigured + } +} diff --git a/src/main/keybindings/keybinding-file-parser.ts b/src/main/keybindings/keybinding-file-parser.ts new file mode 100644 index 00000000000..9343a43d1af --- /dev/null +++ b/src/main/keybindings/keybinding-file-parser.ts @@ -0,0 +1,241 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { + findKeybindingConflicts, + getKeybindingDefinition, + isKeybindingActionId, + normalizeKeybindingArrayForAction, + normalizeKeybindingListForAction, + type KeybindingActionId, + type KeybindingFileDiagnostic, + type KeybindingOverrides, + type KeybindingPlatform +} from '../../shared/keybindings' + +export type JsonObject = Record + +export const FILE_VERSION = 1 +const PLATFORM_KEYS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] +const ROOT_KEYS = new Set(['$schema', 'version', 'keybindings', 'platforms']) + +export function isJsonObject(value: unknown): value is JsonObject { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +export function createEmptyDocument(): JsonObject { + return { + version: FILE_VERSION, + keybindings: {}, + platforms: { + darwin: {}, + linux: {}, + win32: {} + } + } +} + +export function readJsonDocument(path: string): { + exists: boolean + document: JsonObject | null + error?: string +} { + if (!existsSync(path)) { + return { exists: false, document: createEmptyDocument() } + } + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown + if (!isJsonObject(parsed)) { + return { exists: true, document: null, error: 'Keybindings file must contain a JSON object.' } + } + return { exists: true, document: parsed } + } catch (error) { + return { + exists: true, + document: null, + error: error instanceof Error ? error.message : String(error) + } + } +} + +export function writeJsonDocument(path: string, document: JsonObject): void { + mkdirSync(dirname(path), { recursive: true }) + const tempPath = `${path}.tmp` + try { + writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8') + renameSync(tempPath, path) + } catch (error) { + try { + if (existsSync(tempPath)) { + unlinkSync(tempPath) + } + } catch { + // Ignore cleanup failure; the original write error is more actionable. + } + throw error + } +} + +function normalizeBindingValue( + actionId: KeybindingActionId, + value: unknown +): { ok: true; value: string[] } | { ok: false; error: string } { + if (value === null || value === false) { + return { ok: true, value: [] } + } + if (typeof value === 'string') { + const normalized = normalizeKeybindingListForAction(actionId, value) + return Array.isArray(normalized) + ? { ok: true, value: normalized } + : normalized.ok + ? { ok: true, value: [normalized.value] } + : normalized + } + if (Array.isArray(value)) { + if (!value.every((item) => typeof item === 'string')) { + return { ok: false, error: 'Use a string, string array, null, or false.' } + } + const normalized = normalizeKeybindingArrayForAction(actionId, value) + return Array.isArray(normalized) + ? { ok: true, value: normalized } + : normalized.ok + ? { ok: true, value: [normalized.value] } + : normalized + } + return { ok: false, error: 'Use a string, string array, null, or false.' } +} + +export function normalizeWriteBindingValue( + actionId: KeybindingActionId, + value: unknown +): string[] | null { + if (value === null) { + return null + } + if (!Array.isArray(value) || !value.every((binding) => typeof binding === 'string')) { + throw new Error('Use a string array or null.') + } + const normalized = normalizeKeybindingArrayForAction(actionId, value) + if (!Array.isArray(normalized)) { + throw new Error(normalized.ok ? 'Unable to parse shortcut.' : normalized.error) + } + return normalized +} + +export function parseBindingSection( + value: unknown, + section: string, + diagnostics: KeybindingFileDiagnostic[], + options: { skipRootKeys?: boolean } = {} +): KeybindingOverrides { + if (value === undefined) { + return {} + } + if (!isJsonObject(value)) { + diagnostics.push({ + severity: 'error', + section, + message: `${section} must be an object.` + }) + return {} + } + + const overrides: KeybindingOverrides = {} + for (const [actionId, rawBinding] of Object.entries(value)) { + if (options.skipRootKeys && ROOT_KEYS.has(actionId)) { + continue + } + if (!isKeybindingActionId(actionId)) { + diagnostics.push({ + severity: 'warning', + section, + actionId, + message: `Unknown keybinding action "${actionId}" was ignored.` + }) + continue + } + const normalized = normalizeBindingValue(actionId, rawBinding) + if (!normalized.ok) { + diagnostics.push({ + severity: 'error', + section, + actionId, + message: `Shortcut for "${actionId}" was ignored: ${normalized.error}` + }) + continue + } + overrides[actionId] = normalized.value + } + return overrides +} + +export function parsePlatformOverrides( + document: JsonObject, + diagnostics: KeybindingFileDiagnostic[] +): Partial> { + const rawPlatforms = document.platforms + if (rawPlatforms === undefined) { + return {} + } + if (!isJsonObject(rawPlatforms)) { + diagnostics.push({ + severity: 'error', + section: 'platforms', + message: 'platforms must be an object with darwin, linux, or win32 sections.' + }) + return {} + } + + const result: Partial> = {} + for (const [platform, value] of Object.entries(rawPlatforms)) { + if (!PLATFORM_KEYS.includes(platform as KeybindingPlatform)) { + diagnostics.push({ + severity: 'warning', + section: `platforms.${platform}`, + message: `Unknown platform "${platform}" was ignored.` + }) + continue + } + result[platform as KeybindingPlatform] = parseBindingSection( + value, + `platforms.${platform}`, + diagnostics + ) + } + return result +} + +export function removeConflictingOverrides( + platform: KeybindingPlatform, + overrides: KeybindingOverrides, + diagnostics: KeybindingFileDiagnostic[] +): KeybindingOverrides { + let next = { ...overrides } + for (let attempt = 0; attempt < 20; attempt++) { + const conflicts = findKeybindingConflicts(platform, next) + const conflictingOverrides = new Set() + + for (const conflict of conflicts) { + for (const actionId of conflict.actionIds) { + if (Object.hasOwn(next, actionId)) { + conflictingOverrides.add(actionId) + } + } + } + + if (conflictingOverrides.size === 0) { + return next + } + + for (const actionId of conflictingOverrides) { + delete next[actionId] + } + + diagnostics.push({ + severity: 'error', + message: `Conflicting custom shortcuts were ignored: ${Array.from(conflictingOverrides) + .map((actionId) => getKeybindingDefinition(actionId)?.title ?? actionId) + .join(', ')}.` + }) + } + return next +} diff --git a/src/main/keybindings/keybinding-file.ts b/src/main/keybindings/keybinding-file.ts index 7a357010406..4ee6bfeb2ce 100644 --- a/src/main/keybindings/keybinding-file.ts +++ b/src/main/keybindings/keybinding-file.ts @@ -1,250 +1,33 @@ -/* eslint-disable max-lines -- Why: parsing, sanitizing, migrating, and writing the keybindings file must stay together so file-format edge cases share one validation path. */ -import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { existsSync } from 'node:fs' +import { join } from 'node:path' import { findKeybindingConflicts, formatKeybindingList, - getKeybindingDefinition, getKeybindingPlatform, isKeybindingActionId, normalizeKeybindingArrayForAction, - normalizeKeybindingListForAction, type KeybindingActionId, type KeybindingFileDiagnostic, type KeybindingFileSnapshot, - type KeybindingOverrides, - type KeybindingPlatform + type KeybindingOverrides } from '../../shared/keybindings' - -type JsonObject = Record - -const FILE_VERSION = 1 -const PLATFORM_KEYS: readonly KeybindingPlatform[] = ['darwin', 'linux', 'win32'] -const ROOT_KEYS = new Set(['$schema', 'version', 'keybindings', 'platforms']) +import { + createEmptyDocument, + FILE_VERSION, + isJsonObject, + normalizeWriteBindingValue, + parseBindingSection, + parsePlatformOverrides, + readJsonDocument, + removeConflictingOverrides, + writeJsonDocument, + type JsonObject +} from './keybinding-file-parser' export function getUserKeybindingsPath(homePath: string): string { return join(homePath, '.orca', 'keybindings.json') } -function isJsonObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -function createEmptyDocument(): JsonObject { - return { - version: FILE_VERSION, - keybindings: {}, - platforms: { - darwin: {}, - linux: {}, - win32: {} - } - } -} - -function readJsonDocument(path: string): { - exists: boolean - document: JsonObject | null - error?: string -} { - if (!existsSync(path)) { - return { exists: false, document: createEmptyDocument() } - } - try { - const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown - if (!isJsonObject(parsed)) { - return { exists: true, document: null, error: 'Keybindings file must contain a JSON object.' } - } - return { exists: true, document: parsed } - } catch (error) { - return { - exists: true, - document: null, - error: error instanceof Error ? error.message : String(error) - } - } -} - -function writeJsonDocument(path: string, document: JsonObject): void { - mkdirSync(dirname(path), { recursive: true }) - const tempPath = `${path}.tmp` - try { - writeFileSync(tempPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8') - renameSync(tempPath, path) - } catch (error) { - try { - if (existsSync(tempPath)) { - unlinkSync(tempPath) - } - } catch { - // Ignore cleanup failure; the original write error is more actionable. - } - throw error - } -} - -function normalizeBindingValue( - actionId: KeybindingActionId, - value: unknown -): { ok: true; value: string[] } | { ok: false; error: string } { - if (value === null || value === false) { - return { ok: true, value: [] } - } - if (typeof value === 'string') { - const normalized = normalizeKeybindingListForAction(actionId, value) - return Array.isArray(normalized) - ? { ok: true, value: normalized } - : normalized.ok - ? { ok: true, value: [normalized.value] } - : normalized - } - if (Array.isArray(value)) { - if (!value.every((item) => typeof item === 'string')) { - return { ok: false, error: 'Use a string, string array, null, or false.' } - } - const normalized = normalizeKeybindingArrayForAction(actionId, value) - return Array.isArray(normalized) - ? { ok: true, value: normalized } - : normalized.ok - ? { ok: true, value: [normalized.value] } - : normalized - } - return { ok: false, error: 'Use a string, string array, null, or false.' } -} - -function normalizeWriteBindingValue(actionId: KeybindingActionId, value: unknown): string[] | null { - if (value === null) { - return null - } - if (!Array.isArray(value) || !value.every((binding) => typeof binding === 'string')) { - throw new Error('Use a string array or null.') - } - const normalized = normalizeKeybindingArrayForAction(actionId, value) - if (!Array.isArray(normalized)) { - throw new Error(normalized.ok ? 'Unable to parse shortcut.' : normalized.error) - } - return normalized -} - -function parseBindingSection( - value: unknown, - section: string, - diagnostics: KeybindingFileDiagnostic[], - options: { skipRootKeys?: boolean } = {} -): KeybindingOverrides { - if (value === undefined) { - return {} - } - if (!isJsonObject(value)) { - diagnostics.push({ - severity: 'error', - section, - message: `${section} must be an object.` - }) - return {} - } - - const overrides: KeybindingOverrides = {} - for (const [actionId, rawBinding] of Object.entries(value)) { - if (options.skipRootKeys && ROOT_KEYS.has(actionId)) { - continue - } - if (!isKeybindingActionId(actionId)) { - diagnostics.push({ - severity: 'warning', - section, - actionId, - message: `Unknown keybinding action "${actionId}" was ignored.` - }) - continue - } - const normalized = normalizeBindingValue(actionId, rawBinding) - if (!normalized.ok) { - diagnostics.push({ - severity: 'error', - section, - actionId, - message: `Shortcut for "${actionId}" was ignored: ${normalized.error}` - }) - continue - } - overrides[actionId] = normalized.value - } - return overrides -} - -function parsePlatformOverrides( - document: JsonObject, - diagnostics: KeybindingFileDiagnostic[] -): Partial> { - const rawPlatforms = document.platforms - if (rawPlatforms === undefined) { - return {} - } - if (!isJsonObject(rawPlatforms)) { - diagnostics.push({ - severity: 'error', - section: 'platforms', - message: 'platforms must be an object with darwin, linux, or win32 sections.' - }) - return {} - } - - const result: Partial> = {} - for (const [platform, value] of Object.entries(rawPlatforms)) { - if (!PLATFORM_KEYS.includes(platform as KeybindingPlatform)) { - diagnostics.push({ - severity: 'warning', - section: `platforms.${platform}`, - message: `Unknown platform "${platform}" was ignored.` - }) - continue - } - result[platform as KeybindingPlatform] = parseBindingSection( - value, - `platforms.${platform}`, - diagnostics - ) - } - return result -} - -function removeConflictingOverrides( - platform: KeybindingPlatform, - overrides: KeybindingOverrides, - diagnostics: KeybindingFileDiagnostic[] -): KeybindingOverrides { - let next = { ...overrides } - for (let attempt = 0; attempt < 20; attempt++) { - const conflicts = findKeybindingConflicts(platform, next) - const conflictingOverrides = new Set() - - for (const conflict of conflicts) { - for (const actionId of conflict.actionIds) { - if (Object.hasOwn(next, actionId)) { - conflictingOverrides.add(actionId) - } - } - } - - if (conflictingOverrides.size === 0) { - return next - } - - for (const actionId of conflictingOverrides) { - delete next[actionId] - } - - diagnostics.push({ - severity: 'error', - message: `Conflicting custom shortcuts were ignored: ${Array.from(conflictingOverrides) - .map((actionId) => getKeybindingDefinition(actionId)?.title ?? actionId) - .join(', ')}.` - }) - } - return next -} - export function readKeybindingFile( path: string, platform: NodeJS.Platform = process.platform diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index c0e77513004..ff2089e7eb7 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: the collector's host-process index, - history ring buffer, Electron bucketing, and worktree attribution live - together to keep one snapshot's orchestration in one place. */ /** * Memory dashboard collector. * @@ -17,11 +14,9 @@ * renderer polls never produces overlapping child processes. */ -import { basename } from 'node:path' import { exec } from 'node:child_process' import { promisify } from 'node:util' import os from 'node:os' -import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { getProcessOutputFields, iterateProcessOutputLines @@ -30,7 +25,6 @@ import { getAppEnvironment, type AppEnvironment } from '../../shared/app-environ import type { AppMemory, MemorySnapshot, - ProcessCommitMetric, SessionMemory, UsageValues, WorktreeMemory @@ -39,8 +33,24 @@ import type { Store } from '../persistence' import { ORPHAN_WORKTREE_ID } from '../../shared/constants' import { listRegisteredPtys } from './pty-registry' import { enumerateWindowsProcessResources } from './windows-process-resource-collector' -import { collectHostMemory, fallbackHostMemory } from './host-memory' +import { collectHostMemory } from './host-memory' import { getProcessMemoryMetric } from './process-memory-metric' +import { + createEmptyWorktreeMemoryBucket, + pushAppMemoryHistory, + pushMemoryHistorySample, + readAppMemoryHistory, + readMemoryHistory, + resolveWorktreeMemoryNames, + sweepStaleMemoryHistory, + type WorktreeMemoryBucket +} from './memory-snapshot-buckets' +import { + clampMemoryMetric, + emptyMemorySnapshot, + optionalCommitField, + snapshotCommitFields +} from './memory-snapshot-values' export type MemorySnapshotStore = Pick @@ -61,7 +71,7 @@ export async function collectMemorySnapshot(store: MemorySnapshotStore): Promise inflight = runSnapshot(store) .catch((err) => { console.warn('[memory] snapshot failed; returning empty', err) - return emptySnapshot() + return emptyMemorySnapshot() }) .finally(() => { inflight = null @@ -99,88 +109,6 @@ type ProcIndex = { hasPrivateMemory: boolean } -const PROCESS_COMMIT_METRIC: ProcessCommitMetric = 'private-bytes' - -/** - * The one rule for every committed-bytes key: present only when the sweep could - * measure it, because a 0 would read as "these processes commit nothing". - */ -function commitField(hasPrivateMemory: boolean, privateMemory: number): { privateMemory?: number } { - return hasPrivateMemory ? { privateMemory: clampNumber(privateMemory) } : {} -} - -/** The snapshot-level pair, which names the unit alongside the total. */ -function snapshotCommitFields( - hasPrivateMemory: boolean, - totalPrivateMemory: number -): Pick { - return hasPrivateMemory - ? { - processCommitMetric: PROCESS_COMMIT_METRIC, - totalPrivateMemory: clampNumber(totalPrivateMemory) - } - : {} -} - -function clampNumber(value: unknown): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return 0 - } - return Math.max(0, value) -} - -function emptySnapshot(): MemorySnapshot { - const zero = { cpu: 0, memory: 0 } - return { - app: { ...zero, main: zero, renderer: zero, other: zero, history: [] }, - worktrees: [], - host: fallbackHostMemory(), - processMemoryMetric: getProcessMemoryMetric(), - totalCpu: 0, - totalMemory: 0, - collectedAt: Date.now() - } -} - -// ─── History ring buffers ─────────────────────────────────────────── - -const APP_HISTORY_KEY = '__app__' -const HISTORY_CAPACITY = 60 -const HISTORY_STALE_MS = 10 * 60 * 1000 - -type HistoryRing = { - samples: number[] - touchedAt: number -} - -const historyByKey = new Map() - -function pushHistorySample(key: string, memoryBytes: number, now: number): void { - let ring = historyByKey.get(key) - if (!ring) { - ring = { samples: [], touchedAt: now } - historyByKey.set(key, ring) - } - ring.samples.push(memoryBytes) - if (ring.samples.length > HISTORY_CAPACITY) { - ring.samples.shift() - } - ring.touchedAt = now -} - -function readHistory(key: string): number[] { - const ring = historyByKey.get(key) - return ring ? [...ring.samples] : [] -} - -function sweepStaleHistory(now: number): void { - for (const [key, ring] of historyByKey) { - if (now - ring.touchedAt > HISTORY_STALE_MS) { - historyByKey.delete(key) - } - } -} - // ─── Host process enumeration ─────────────────────────────────────── async function enumerateProcesses(): Promise { @@ -292,7 +220,7 @@ function electronMetricMemoryBytes( // Why: on macOS, getAppEnvironment().getAppMetrics().workingSetSize can include large shared // Chromium/Electron mappings. Prefer the host RSS sweep used elsewhere, but // keep workingSetSize as a fallback when the process disappears mid-snapshot. - return clampNumber(proc.memory?.workingSetSize) * 1024 + return clampMemoryMetric(proc.memory?.workingSetSize) * 1024 } function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { @@ -301,11 +229,11 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { const other = { cpu: 0, memory: 0, privateMemory: 0 } for (const proc of getAppEnvironment().getAppMetrics()) { - const cpu = clampNumber(proc.cpu?.percentCPUUsage) + const cpu = clampMemoryMetric(proc.cpu?.percentCPUUsage) const memoryBytes = electronMetricMemoryBytes(proc, processIndex) // Why the host row rather than Electron's own metric: getAppMetrics has no // commit figure for helper processes, and the sweep already indexed them. - const privateBytes = clampNumber(processIndex.byPid.get(proc.pid)?.privateMemory) + const privateBytes = clampMemoryMetric(processIndex.byPid.get(proc.pid)?.privateMemory) // Why: lowercase once so future Electron versions emitting different // casing ('browser' vs 'Browser') still bucket correctly. @@ -325,7 +253,7 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { const usage = (bucket: typeof main): UsageValues => ({ cpu: bucket.cpu, memory: bucket.memory, - ...commitField(processIndex.hasPrivateMemory, bucket.privateMemory) + ...optionalCommitField(processIndex.hasPrivateMemory, bucket.privateMemory) }) return { @@ -340,61 +268,6 @@ function bucketElectronMetrics(processIndex: ProcIndex): AppBucketsRaw { } } -// ─── Worktree attribution ─────────────────────────────────────────── - -type WorktreeBucket = { - worktreeId: string - worktreeName: string - repoId: string - repoName: string - cpu: number - memory: number - privateMemory: number - sessions: SessionMemory[] -} - -function resolveWorktreeNames( - worktreeId: string, - store: MemorySnapshotStore -): { - worktreeName: string - repoId: string - repoName: string -} { - // Orca worktree ids look like `${repoId}::${absolutePath}`. - const parsed = splitWorktreeIdForFilesystem(worktreeId) - const repoId = parsed?.repoId ?? worktreeId - const worktreePath = parsed?.worktreePath ?? '' - const fallbackName = worktreePath ? basename(worktreePath) : worktreeId - - const meta = store.getWorktreeMeta(worktreeId) - const repo = store.getRepo(repoId) - - return { - worktreeName: meta?.displayName?.trim() || fallbackName, - repoId, - repoName: repo?.displayName?.trim() || repoId || 'Unknown Repo' - } -} - -function makeEmptyBucket( - worktreeId: string, - worktreeName: string, - repoId: string, - repoName: string -): WorktreeBucket { - return { - worktreeId, - worktreeName, - repoId, - repoName, - cpu: 0, - memory: 0, - privateMemory: 0, - sessions: [] - } -} - // ─── Main collection path ─────────────────────────────────────────── async function runSnapshot(store: MemorySnapshotStore): Promise { @@ -408,13 +281,13 @@ async function runSnapshot(store: MemorySnapshotStore): Promise // attribute to the first PTY (registration order) to see each pid. const claimed = new Set() - const orphan = makeEmptyBucket( + const orphan = createEmptyWorktreeMemoryBucket( ORPHAN_WORKTREE_ID, 'Unattributed terminals', ORPHAN_WORKTREE_ID, 'Other' ) - const worktreeBuckets = new Map() + const worktreeBuckets = new Map() for (const pty of ptys) { let sessionCpu = 0 @@ -435,7 +308,7 @@ async function runSnapshot(store: MemorySnapshotStore): Promise sessionMemory += row.memory // Why the whole subtree: an agent's committed bytes live in the // children it spawned (codex.exe, MCP servers), not in the shell. - sessionPrivateMemory += clampNumber(row.privateMemory) + sessionPrivateMemory += clampMemoryMetric(row.privateMemory) } } @@ -443,19 +316,24 @@ async function runSnapshot(store: MemorySnapshotStore): Promise sessionId: pty.sessionId ?? pty.ptyId, paneKey: pty.paneKey, pid: pty.pid ?? 0, - cpu: clampNumber(sessionCpu), - memory: clampNumber(sessionMemory), - ...commitField(processIndex.hasPrivateMemory, sessionPrivateMemory) + cpu: clampMemoryMetric(sessionCpu), + memory: clampMemoryMetric(sessionMemory), + ...optionalCommitField(processIndex.hasPrivateMemory, sessionPrivateMemory) } - let bucket: WorktreeBucket + let bucket: WorktreeMemoryBucket if (pty.worktreeId) { const existing = worktreeBuckets.get(pty.worktreeId) if (existing) { bucket = existing } else { - const names = resolveWorktreeNames(pty.worktreeId, store) - bucket = makeEmptyBucket(pty.worktreeId, names.worktreeName, names.repoId, names.repoName) + const names = resolveWorktreeMemoryNames(pty.worktreeId, store) + bucket = createEmptyWorktreeMemoryBucket( + pty.worktreeId, + names.worktreeName, + names.repoId, + names.repoName + ) worktreeBuckets.set(pty.worktreeId, bucket) } } else { @@ -464,11 +342,11 @@ async function runSnapshot(store: MemorySnapshotStore): Promise bucket.cpu += session.cpu bucket.memory += session.memory - bucket.privateMemory += clampNumber(session.privateMemory) + bucket.privateMemory += clampMemoryMetric(session.privateMemory) bucket.sessions.push(session) } - const bucketList: WorktreeBucket[] = [...worktreeBuckets.values()] + const bucketList: WorktreeMemoryBucket[] = [...worktreeBuckets.values()] if (orphan.sessions.length > 0) { bucketList.push(orphan) } @@ -477,16 +355,16 @@ async function runSnapshot(store: MemorySnapshotStore): Promise // returned arrays end with the freshly-collected value. Each write also // acts as a keep-alive so active worktrees survive the staleness sweep. const now = Date.now() - pushHistorySample(APP_HISTORY_KEY, appBuckets.memory, now) + pushAppMemoryHistory(appBuckets.memory, now) for (const bucket of bucketList) { - pushHistorySample(bucket.worktreeId, bucket.memory, now) + pushMemoryHistorySample(bucket.worktreeId, bucket.memory, now) } - sweepStaleHistory(now) + sweepStaleMemoryHistory(now) const worktrees: WorktreeMemory[] = bucketList.map(({ privateMemory, ...b }) => ({ ...b, - ...commitField(processIndex.hasPrivateMemory, privateMemory), - history: readHistory(b.worktreeId) + ...optionalCommitField(processIndex.hasPrivateMemory, privateMemory), + history: readMemoryHistory(b.worktreeId) })) let sessionCpuTotal = 0 @@ -495,17 +373,17 @@ async function runSnapshot(store: MemorySnapshotStore): Promise for (const wt of worktrees) { sessionCpuTotal += wt.cpu sessionMemoryTotal += wt.memory - sessionPrivateTotal += clampNumber(wt.privateMemory) + sessionPrivateTotal += clampMemoryMetric(wt.privateMemory) } return { - app: { ...appBuckets, history: readHistory(APP_HISTORY_KEY) }, + app: { ...appBuckets, history: readAppMemoryHistory() }, worktrees, host, processMemoryMetric: getProcessMemoryMetric(), ...snapshotCommitFields( processIndex.hasPrivateMemory, - clampNumber(appBuckets.privateMemory) + sessionPrivateTotal + clampMemoryMetric(appBuckets.privateMemory) + sessionPrivateTotal ), totalCpu: appBuckets.cpu + sessionCpuTotal, totalMemory: appBuckets.memory + sessionMemoryTotal, diff --git a/src/main/memory/memory-snapshot-buckets.ts b/src/main/memory/memory-snapshot-buckets.ts new file mode 100644 index 00000000000..f2a45e22190 --- /dev/null +++ b/src/main/memory/memory-snapshot-buckets.ts @@ -0,0 +1,101 @@ +import { basename } from 'node:path' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import type { SessionMemory } from '../../shared/process-stats-types' +import type { MemorySnapshotStore } from './collector' + +const APP_HISTORY_KEY = '__app__' +const HISTORY_CAPACITY = 60 +const HISTORY_STALE_MS = 10 * 60 * 1000 + +type HistoryRing = { + samples: number[] + touchedAt: number +} + +const historyByKey = new Map() + +export function pushMemoryHistorySample(key: string, memoryBytes: number, now: number): void { + let ring = historyByKey.get(key) + if (!ring) { + ring = { samples: [], touchedAt: now } + historyByKey.set(key, ring) + } + ring.samples.push(memoryBytes) + if (ring.samples.length > HISTORY_CAPACITY) { + ring.samples.shift() + } + ring.touchedAt = now +} + +export function readMemoryHistory(key: string): number[] { + const ring = historyByKey.get(key) + return ring ? [...ring.samples] : [] +} + +export function sweepStaleMemoryHistory(now: number): void { + for (const [key, ring] of historyByKey) { + if (now - ring.touchedAt > HISTORY_STALE_MS) { + historyByKey.delete(key) + } + } +} + +export function pushAppMemoryHistory(memoryBytes: number, now: number): void { + pushMemoryHistorySample(APP_HISTORY_KEY, memoryBytes, now) +} + +export function readAppMemoryHistory(): number[] { + return readMemoryHistory(APP_HISTORY_KEY) +} + +export type WorktreeMemoryBucket = { + worktreeId: string + worktreeName: string + repoId: string + repoName: string + cpu: number + memory: number + privateMemory: number + sessions: SessionMemory[] +} + +export function resolveWorktreeMemoryNames( + worktreeId: string, + store: MemorySnapshotStore +): { + worktreeName: string + repoId: string + repoName: string +} { + // Orca worktree ids look like `${repoId}::${absolutePath}`. + const parsed = splitWorktreeIdForFilesystem(worktreeId) + const repoId = parsed?.repoId ?? worktreeId + const worktreePath = parsed?.worktreePath ?? '' + const fallbackName = worktreePath ? basename(worktreePath) : worktreeId + const meta = store.getWorktreeMeta(worktreeId) + const repo = store.getRepo(repoId) + + return { + worktreeName: meta?.displayName?.trim() || fallbackName, + repoId, + repoName: repo?.displayName?.trim() || repoId || 'Unknown Repo' + } +} + +export function createEmptyWorktreeMemoryBucket( + worktreeId: string, + worktreeName: string, + repoId: string, + repoName: string +): WorktreeMemoryBucket { + return { + worktreeId, + worktreeName, + repoId, + repoName, + cpu: 0, + memory: 0, + privateMemory: 0, + sessions: [] + } +} diff --git a/src/main/memory/memory-snapshot-values.ts b/src/main/memory/memory-snapshot-values.ts new file mode 100644 index 00000000000..4fea737c6f6 --- /dev/null +++ b/src/main/memory/memory-snapshot-values.ts @@ -0,0 +1,49 @@ +import type { MemorySnapshot, ProcessCommitMetric } from '../../shared/process-stats-types' +import { fallbackHostMemory } from './host-memory' +import { getProcessMemoryMetric } from './process-memory-metric' + +const PROCESS_COMMIT_METRIC: ProcessCommitMetric = 'private-bytes' + +export function clampMemoryMetric(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0 + } + return Math.max(0, value) +} + +/** + * The one rule for every committed-bytes key: present only when the sweep could + * measure it, because a 0 would read as "these processes commit nothing". + */ +export function optionalCommitField( + hasPrivateMemory: boolean, + privateMemory: number +): { privateMemory?: number } { + return hasPrivateMemory ? { privateMemory: clampMemoryMetric(privateMemory) } : {} +} + +/** The snapshot-level pair, which names the unit alongside the total. */ +export function snapshotCommitFields( + hasPrivateMemory: boolean, + totalPrivateMemory: number +): Pick { + return hasPrivateMemory + ? { + processCommitMetric: PROCESS_COMMIT_METRIC, + totalPrivateMemory: clampMemoryMetric(totalPrivateMemory) + } + : {} +} + +export function emptyMemorySnapshot(): MemorySnapshot { + const zero = { cpu: 0, memory: 0 } + return { + app: { ...zero, main: zero, renderer: zero, other: zero, history: [] }, + worktrees: [], + host: fallbackHostMemory(), + processMemoryMetric: getProcessMemoryMetric(), + totalCpu: 0, + totalMemory: 0, + collectedAt: Date.now() + } +} diff --git a/src/main/persistence/scheduling-automations/automation-definition-operations.ts b/src/main/persistence/scheduling-automations/automation-definition-operations.ts index 3b06ad7dce9..3107ae37a5a 100644 --- a/src/main/persistence/scheduling-automations/automation-definition-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-definition-operations.ts @@ -6,7 +6,7 @@ import type { } from '../../../shared/automations-types' import type { PersistedState } from '../../../shared/persisted-state-types' import { normalizeAutomationPrecheck } from '../../../shared/automation-precheck' -import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedules' +import { nextAutomationOccurrenceAfter } from '../../../shared/automation-schedule-occurrences' import { applyAutomationExecutionTarget, deriveAutomationExecutionTargetForCreate, diff --git a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts index d1142eb771e..2097efb4a81 100644 --- a/src/main/persistence/scheduling-automations/automation-schedule-operations.ts +++ b/src/main/persistence/scheduling-automations/automation-schedule-operations.ts @@ -3,7 +3,7 @@ import type { PersistedState } from '../../../shared/persisted-state-types' import { latestAutomationOccurrenceAtOrBefore, nextAutomationOccurrenceAfter -} from '../../../shared/automation-schedules' +} from '../../../shared/automation-schedule-occurrences' export function advanceAutomationNextRun( state: PersistedState, diff --git a/src/main/project-groups/nested-repo-discovery.ts b/src/main/project-groups/nested-repo-discovery.ts index d2df48e7970..84fddfd1167 100644 --- a/src/main/project-groups/nested-repo-discovery.ts +++ b/src/main/project-groups/nested-repo-discovery.ts @@ -1,182 +1,15 @@ -/* eslint-disable max-lines -- Why: scanner traversal, ignore matching, and filesystem -abstraction stay together so local, SSH, and runtime scans cannot drift. */ import { readFile, readdir, stat } from 'node:fs/promises' import { basename, join } from 'node:path' -import type { - NestedRepoCandidate, - NestedRepoScanOptions, - NestedRepoScanResult -} from '../../shared/project-group-types' +import type { NestedRepoCandidate, NestedRepoScanResult } from '../../shared/project-group-types' import { isGitRepo } from '../git/repo' - -type NestedRepoDirectoryEntry = { - name: string - isDirectory: boolean - isSymlink?: boolean -} - -type NestedRepoScanFilesystem = { - readDirectory: (dirPath: string) => Promise - readTextFile?: (filePath: string) => Promise - joinPath: (parentPath: string, childName: string) => string - basename: (path: string) => string - hasGitMarker: (path: string) => Promise | boolean - isSelectedPathGitRepo: (path: string) => Promise | boolean -} - -type IgnoreRule = { - pattern: string - negate: boolean - basenameOnly: boolean - baseSegments: string[] -} - -type TraversalFolder = { - path: string - depth: number - segments: string[] - ignoreRules: IgnoreRule[] -} - -type NormalizedNestedRepoScanOptions = { - maxDepth: number - maxRepos: number - timeoutMs: number | null -} - -const DEFAULT_MAX_DEPTH = 3 -const DEFAULT_MAX_REPOS = 100 - -const SKIPPED_DIRS = new Set([ - 'node_modules', - '.next', - 'dist', - 'build', - '.cache', - 'vendor', - '__pycache__', - '.turbo', - '.parcel-cache' -]) - -const VCS_METADATA_DIRS = new Set(['.git', '.svn', '.hg', '.jj', '.sl', '.repo', 'CVS']) - -function normalizeScanOptions(options: unknown): NormalizedNestedRepoScanOptions { - const raw = options && typeof options === 'object' ? (options as NestedRepoScanOptions) : {} - return { - maxDepth: - typeof raw.maxDepth === 'number' && Number.isFinite(raw.maxDepth) - ? Math.max(1, Math.min(8, Math.floor(raw.maxDepth))) - : DEFAULT_MAX_DEPTH, - maxRepos: - typeof raw.maxRepos === 'number' && Number.isFinite(raw.maxRepos) - ? Math.max(1, Math.min(500, Math.floor(raw.maxRepos))) - : DEFAULT_MAX_REPOS, - timeoutMs: - raw.timeoutMs === null - ? null - : typeof raw.timeoutMs === 'number' && Number.isFinite(raw.timeoutMs) - ? Math.max(500, Math.min(30_000, Math.floor(raw.timeoutMs))) - : null - } -} - -function shouldSkipDirectory(name: string, depth: number): boolean { - if (VCS_METADATA_DIRS.has(name)) { - return true - } - if (SKIPPED_DIRS.has(name)) { - return true - } - return depth > 0 && name.startsWith('.') -} - -function globSegmentMatches(pattern: string, value: string): boolean { - if (!pattern.includes('*') && !pattern.includes('?')) { - return pattern === value - } - const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') - const regex = new RegExp(`^${escaped.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`) - return regex.test(value) -} - -function pathSegmentsMatch(patternSegments: string[], candidateSegments: string[]): boolean { - const matchFrom = (patternIndex: number, candidateIndex: number): boolean => { - if (patternIndex >= patternSegments.length) { - return candidateIndex >= candidateSegments.length - } - const pattern = patternSegments[patternIndex] - if (pattern === '**') { - return ( - matchFrom(patternIndex + 1, candidateIndex) || - (candidateIndex < candidateSegments.length && matchFrom(patternIndex, candidateIndex + 1)) - ) - } - return ( - candidateIndex < candidateSegments.length && - globSegmentMatches(pattern, candidateSegments[candidateIndex] ?? '') && - matchFrom(patternIndex + 1, candidateIndex + 1) - ) - } - return matchFrom(0, 0) -} - -function parseGitignoreRules(content: string, baseSegments: string[]): IgnoreRule[] { - return content - .split(/\r?\n/) - .map((rawLine) => rawLine.trim()) - .filter((line) => line.length > 0 && !line.startsWith('#')) - .map((line) => { - const negate = line.startsWith('!') - const unprefixed = negate ? line.slice(1) : line - const anchored = unprefixed.startsWith('/') - const pattern = unprefixed.replace(/^\/+/, '').replace(/\/+$/, '') - return { - pattern, - negate, - basenameOnly: !anchored && !pattern.includes('/'), - baseSegments - } - }) - .filter((rule) => rule.pattern.length > 0) -} - -function isIgnoredByRules(name: string, segments: string[], rules: IgnoreRule[]): boolean { - let ignored = false - for (const rule of rules) { - if (segments.length <= rule.baseSegments.length) { - continue - } - const relativeSegments = segments.slice(rule.baseSegments.length) - const patternSegments = rule.pattern.split('/') - const matches = rule.basenameOnly - ? relativeSegments.some((segment) => globSegmentMatches(rule.pattern, segment)) - : pathSegmentsMatch(patternSegments, relativeSegments) - if (matches) { - ignored = !rule.negate - } - } - return ignored || shouldSkipDirectory(name, segments.length - 1) -} - -async function readGitignoreRules(args: { - folderPath: string - entries: NestedRepoDirectoryEntry[] - filesystem: NestedRepoScanFilesystem - baseSegments: string[] -}): Promise { - if (!args.filesystem.readTextFile || !args.entries.some((entry) => entry.name === '.gitignore')) { - return [] - } - try { - const content = await args.filesystem.readTextFile( - args.filesystem.joinPath(args.folderPath, '.gitignore') - ) - return parseGitignoreRules(content, args.baseSegments) - } catch { - return [] - } -} +import { + isIgnoredNestedRepoDirectory, + normalizeNestedRepoScanOptions, + readNestedRepoGitignoreRules, + type NestedRepoDirectoryEntry, + type NestedRepoScanFilesystem, + type TraversalFolder +} from './nested-repo-scan-rules' async function hasGitMarker(dirPath: string): Promise { try { @@ -214,7 +47,7 @@ export async function scanNestedRepos(args: { onProgress?: (scan: NestedRepoScanResult) => void }): Promise { const startedAt = Date.now() - const options = normalizeScanOptions(args.options) + const options = normalizeNestedRepoScanOptions(args.options) const repos: NestedRepoCandidate[] = [] let truncated = false let timedOut = false @@ -290,7 +123,7 @@ export async function scanNestedRepos(args: { } const currentIgnoreRules = [ ...currentFolder.ignoreRules, - ...(await readGitignoreRules({ + ...(await readNestedRepoGitignoreRules({ folderPath: currentFolder.path, entries, filesystem, @@ -315,7 +148,7 @@ export async function scanNestedRepos(args: { break } const childSegments = [...currentFolder.segments, name] - if (isIgnoredByRules(name, childSegments, currentIgnoreRules)) { + if (isIgnoredNestedRepoDirectory(name, childSegments, currentIgnoreRules)) { continue } const childPath = filesystem.joinPath(currentFolder.path, name) diff --git a/src/main/project-groups/nested-repo-scan-rules.ts b/src/main/project-groups/nested-repo-scan-rules.ts new file mode 100644 index 00000000000..32d6881e15b --- /dev/null +++ b/src/main/project-groups/nested-repo-scan-rules.ts @@ -0,0 +1,174 @@ +import type { NestedRepoScanOptions } from '../../shared/project-group-types' + +export type NestedRepoDirectoryEntry = { + name: string + isDirectory: boolean + isSymlink?: boolean +} + +export type NestedRepoScanFilesystem = { + readDirectory: (dirPath: string) => Promise + readTextFile?: (filePath: string) => Promise + joinPath: (parentPath: string, childName: string) => string + basename: (path: string) => string + hasGitMarker: (path: string) => Promise | boolean + isSelectedPathGitRepo: (path: string) => Promise | boolean +} + +type IgnoreRule = { + pattern: string + negate: boolean + basenameOnly: boolean + baseSegments: string[] +} + +export type TraversalFolder = { + path: string + depth: number + segments: string[] + ignoreRules: IgnoreRule[] +} + +export type NormalizedNestedRepoScanOptions = { + maxDepth: number + maxRepos: number + timeoutMs: number | null +} + +const DEFAULT_MAX_DEPTH = 3 +const DEFAULT_MAX_REPOS = 100 + +const SKIPPED_DIRS = new Set([ + 'node_modules', + '.next', + 'dist', + 'build', + '.cache', + 'vendor', + '__pycache__', + '.turbo', + '.parcel-cache' +]) + +const VCS_METADATA_DIRS = new Set(['.git', '.svn', '.hg', '.jj', '.sl', '.repo', 'CVS']) + +export function normalizeNestedRepoScanOptions(options: unknown): NormalizedNestedRepoScanOptions { + const raw = options && typeof options === 'object' ? (options as NestedRepoScanOptions) : {} + return { + maxDepth: + typeof raw.maxDepth === 'number' && Number.isFinite(raw.maxDepth) + ? Math.max(1, Math.min(8, Math.floor(raw.maxDepth))) + : DEFAULT_MAX_DEPTH, + maxRepos: + typeof raw.maxRepos === 'number' && Number.isFinite(raw.maxRepos) + ? Math.max(1, Math.min(500, Math.floor(raw.maxRepos))) + : DEFAULT_MAX_REPOS, + timeoutMs: + raw.timeoutMs === null + ? null + : typeof raw.timeoutMs === 'number' && Number.isFinite(raw.timeoutMs) + ? Math.max(500, Math.min(30_000, Math.floor(raw.timeoutMs))) + : null + } +} + +function shouldSkipDirectory(name: string, depth: number): boolean { + if (VCS_METADATA_DIRS.has(name)) { + return true + } + if (SKIPPED_DIRS.has(name)) { + return true + } + return depth > 0 && name.startsWith('.') +} + +function globSegmentMatches(pattern: string, value: string): boolean { + if (!pattern.includes('*') && !pattern.includes('?')) { + return pattern === value + } + const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&') + const regex = new RegExp(`^${escaped.replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]')}$`) + return regex.test(value) +} + +function pathSegmentsMatch(patternSegments: string[], candidateSegments: string[]): boolean { + const matchFrom = (patternIndex: number, candidateIndex: number): boolean => { + if (patternIndex >= patternSegments.length) { + return candidateIndex >= candidateSegments.length + } + const pattern = patternSegments[patternIndex] + if (pattern === '**') { + return ( + matchFrom(patternIndex + 1, candidateIndex) || + (candidateIndex < candidateSegments.length && matchFrom(patternIndex, candidateIndex + 1)) + ) + } + return ( + candidateIndex < candidateSegments.length && + globSegmentMatches(pattern, candidateSegments[candidateIndex] ?? '') && + matchFrom(patternIndex + 1, candidateIndex + 1) + ) + } + return matchFrom(0, 0) +} + +function parseGitignoreRules(content: string, baseSegments: string[]): IgnoreRule[] { + return content + .split(/\r?\n/) + .map((rawLine) => rawLine.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => { + const negate = line.startsWith('!') + const unprefixed = negate ? line.slice(1) : line + const anchored = unprefixed.startsWith('/') + const pattern = unprefixed.replace(/^\/+/, '').replace(/\/+$/, '') + return { + pattern, + negate, + basenameOnly: !anchored && !pattern.includes('/'), + baseSegments + } + }) + .filter((rule) => rule.pattern.length > 0) +} + +export function isIgnoredNestedRepoDirectory( + name: string, + segments: string[], + rules: IgnoreRule[] +): boolean { + let ignored = false + for (const rule of rules) { + if (segments.length <= rule.baseSegments.length) { + continue + } + const relativeSegments = segments.slice(rule.baseSegments.length) + const patternSegments = rule.pattern.split('/') + const matches = rule.basenameOnly + ? relativeSegments.some((segment) => globSegmentMatches(rule.pattern, segment)) + : pathSegmentsMatch(patternSegments, relativeSegments) + if (matches) { + ignored = !rule.negate + } + } + return ignored || shouldSkipDirectory(name, segments.length - 1) +} + +export async function readNestedRepoGitignoreRules(args: { + folderPath: string + entries: NestedRepoDirectoryEntry[] + filesystem: NestedRepoScanFilesystem + baseSegments: string[] +}): Promise { + if (!args.filesystem.readTextFile || !args.entries.some((entry) => entry.name === '.gitignore')) { + return [] + } + try { + const content = await args.filesystem.readTextFile( + args.filesystem.joinPath(args.folderPath, '.gitignore') + ) + return parseGitignoreRules(content, args.baseSegments) + } catch { + return [] + } +} diff --git a/src/main/rate-limits/claude-pty-stop-markers.ts b/src/main/rate-limits/claude-pty-stop-markers.ts new file mode 100644 index 00000000000..2bba56c6911 --- /dev/null +++ b/src/main/rate-limits/claude-pty-stop-markers.ts @@ -0,0 +1,14 @@ +// Why: these substrings indicate the /usage TUI panel has finished rendering. +export const CLAUDE_USAGE_STOP_SUBSTRINGS = [ + 'Current week (all models)', + 'Current week (Opus)', + 'Current week (Sonnet only)', + 'Current week (Sonnet)', + 'Weekly limits', + 'Weekly limit', + 'Weekly usage', + '7-day', + 'Current session', + 'Failed to load usage data', + 'failed to load usage data' +] diff --git a/src/main/rate-limits/claude-pty-usage-parser.ts b/src/main/rate-limits/claude-pty-usage-parser.ts new file mode 100644 index 00000000000..3328d947160 --- /dev/null +++ b/src/main/rate-limits/claude-pty-usage-parser.ts @@ -0,0 +1,166 @@ +import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' + +// Why: these patterns match the Claude CLI's /usage TUI panel output. +// "Current session" shows a percent like "62% used" or "62% left". +// Weekly labels have varied between "Current week" and "Weekly limits". +const SESSION_RE = /current\s*session/i +const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i +const FABLE_WORD_RE = /\bfable\b/i +const FABLE_LABEL_RE = /^\s*fable\s*$/i +// Why: derive from WEEKLY_RE so a future weekly-wording change stays in one place +// instead of silently reopening the Fable-weekly parsing gap this fix closed. +const FABLE_WEEKLY_LABEL_RE = new RegExp( + `${WEEKLY_RE.source}\\s*(?:\\([^)]*\\bfable\\b[^)]*\\)|[-:]?\\s*\\bfable\\b)`, + 'i' +) +const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i +const ESC = String.fromCharCode(27) +const BEL = String.fromCharCode(7) +const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g') +const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g') + +export function stripTerminalControlSequences(output: string): string { + return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '') +} + +/** + * Extract percent-left from lines following a label match. + * Scans up to 12 lines after the label to find the associated percent. + */ +function matchesWeeklyLabel(line: string): boolean { + return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line) +} + +function matchesFableBoundary(line: string): boolean { + return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) +} + +function matchesFableUsageLabel(line: string): boolean { + // Why: broad Fable-weekly copy should stop nearby scans, but only a real + // Fable usage heading should produce the distinct Fable meter. + return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) +} + +function isSectionLabel(line: string): boolean { + return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) +} + +function extractPercentAfterLabel( + lines: string[], + matchesLabel: (line: string) => boolean +): number | null { + for (let i = 0; i < lines.length; i++) { + if (!matchesLabel(lines[i])) { + continue + } + // Scan next 12 lines for a percent + for (let j = i; j < Math.min(i + 12, lines.length); j++) { + if (j > i && isSectionLabel(lines[j])) { + break + } + const m = PERCENT_RE.exec(lines[j]) + if (m) { + const pct = Number.parseFloat(m[1]) + const word = m[2].toLowerCase() + const isUsed = word === 'used' || word === 'consumed' + return isUsed ? pct : 100 - pct + } + } + } + return null +} + +export function parseClaudePtyUsage(output: string): { + session: RateLimitWindow | null + weekly: RateLimitWindow | null + fableWeekly: RateLimitWindow | null +} { + const lines = output.split(/\r\n|\n|\r/) + + const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) + const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) + const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) + const sessionReset = extractClaudePtyResetMetadata( + lines, + (line) => SESSION_RE.test(line), + isSectionLabel + ) + const weeklyReset = extractClaudePtyResetMetadata(lines, matchesWeeklyLabel, isSectionLabel) + const fableWeeklyReset = extractClaudePtyResetMetadata( + lines, + matchesFableUsageLabel, + isSectionLabel + ) + + const session: RateLimitWindow | null = + sessionPct !== null + ? { + usedPercent: Math.min(100, Math.max(0, sessionPct)), + windowMinutes: 300, + resetsAt: sessionReset.resetsAt, + resetDescription: sessionReset.resetDescription + } + : null + + const weekly: RateLimitWindow | null = + weeklyPct !== null + ? { + usedPercent: Math.min(100, Math.max(0, weeklyPct)), + windowMinutes: 10080, + resetsAt: weeklyReset.resetsAt, + resetDescription: weeklyReset.resetDescription + } + : null + + const fableWeekly: RateLimitWindow | null = + fableWeeklyPct !== null + ? { + usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), + windowMinutes: 10080, + resetsAt: fableWeeklyReset.resetsAt, + resetDescription: fableWeeklyReset.resetDescription + } + : null + + return { session, weekly, fableWeekly } +} + +const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i +const LOAD_FAILED_RE = /failed to load usage data/i +const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i +const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i + +export function isClaude21UsagePanel(output: string): boolean { + return CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output) +} + +export function describeClaudeUsageFailure(output: string): string { + if (RATE_LIMITED_RE.test(output)) { + return 'Claude usage is rate limited right now.' + } + + if (LOAD_FAILED_RE.test(output)) { + return 'Claude usage is unavailable right now.' + } + + if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) { + return 'Claude plan usage is unavailable for this Claude CLI session.' + } + + // Why: parser failures are an implementation detail of Orca's PTY fallback. + // The UI should explain the user-visible outcome, not leak internal parsing + // mechanics that the user cannot act on. + return 'Claude usage is unavailable right now.' +} + +export function abortedClaudeUsageResult(): ProviderRateLimits { + return { + provider: 'claude', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Rate-limit fetch aborted', + status: 'error' + } +} diff --git a/src/main/rate-limits/claude-pty.ts b/src/main/rate-limits/claude-pty.ts index 210f4778ac9..cbaedbd17f9 100644 --- a/src/main/rate-limits/claude-pty.ts +++ b/src/main/rate-limits/claude-pty.ts @@ -1,7 +1,4 @@ -/* eslint-disable max-lines -- Why: Claude PTY usage scraping keeps prompt -driving, parser, timers, and teardown in one state machine; splitting it would -make the lifecycle harder to audit. */ -import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' import { resolveClaudeCommand } from '../codex-cli/command' // Why: import from the shared module, not the codex-cli re-export, so a test that @@ -11,11 +8,19 @@ import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-au import { applyClaudeEnvPatch } from '../claude-accounts/environment' import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic' import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup' -import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser' import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' +import { + abortedClaudeUsageResult, + describeClaudeUsageFailure, + isClaude21UsagePanel, + parseClaudePtyUsage, + stripTerminalControlSequences +} from './claude-pty-usage-parser' +import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell' +import { CLAUDE_USAGE_STOP_SUBSTRINGS } from './claude-pty-stop-markers' const PTY_TIMEOUT_MS = 25_000 const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit @@ -24,148 +29,6 @@ const MAX_OUTPUT_LENGTH = 100_000 // 100KB buffer limit // PTY fallback — spawn interactive `claude`, send `/usage`, parse the TUI // --------------------------------------------------------------------------- -// Why: these patterns match the Claude CLI's /usage TUI panel output. -// "Current session" shows a percent like "62% used" or "62% left". -// Weekly labels have varied between "Current week" and "Weekly limits". -const SESSION_RE = /current\s*session/i -const WEEKLY_RE = /(?:current\s*week|weekly\s*(?:limits?|usage|rate\s*limits?)|7\s*[- ]?\s*day)/i -const FABLE_WORD_RE = /\bfable\b/i -const FABLE_LABEL_RE = /^\s*fable\s*$/i -// Why: derive from WEEKLY_RE so a future weekly-wording change stays in one place -// instead of silently reopening the Fable-weekly parsing gap this fix closed. -const FABLE_WEEKLY_LABEL_RE = new RegExp( - `${WEEKLY_RE.source}\\s*(?:\\([^)]*\\bfable\\b[^)]*\\)|[-:]?\\s*\\bfable\\b)`, - 'i' -) -const PERCENT_RE = /(\d{1,3})(?:\.\d+)?\s*%\s*(used|consumed|left|remaining|available)/i -const ESC = String.fromCharCode(27) -const BEL = String.fromCharCode(7) -const OSC_SEQUENCE_RE = new RegExp(`${ESC}\\][^${BEL}]*(?:${BEL}|${ESC}\\\\)`, 'g') -const CSI_SEQUENCE_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g') - -function stripTerminalControlSequences(output: string): string { - return output.replace(OSC_SEQUENCE_RE, '').replace(CSI_SEQUENCE_RE, '') -} - -/** - * Extract percent-left from lines following a label match. - * Scans up to 12 lines after the label to find the associated percent. - */ -function matchesWeeklyLabel(line: string): boolean { - return WEEKLY_RE.test(line) && !FABLE_WORD_RE.test(line) -} - -function matchesFableBoundary(line: string): boolean { - return FABLE_LABEL_RE.test(line) || (FABLE_WORD_RE.test(line) && WEEKLY_RE.test(line)) -} - -function matchesFableUsageLabel(line: string): boolean { - // Why: broad Fable-weekly copy should stop nearby scans, but only a real - // Fable usage heading should produce the distinct Fable meter. - return FABLE_LABEL_RE.test(line) || FABLE_WEEKLY_LABEL_RE.test(line) -} - -function isSectionLabel(line: string): boolean { - return SESSION_RE.test(line) || matchesWeeklyLabel(line) || matchesFableBoundary(line) -} - -function extractPercentAfterLabel( - lines: string[], - matchesLabel: (line: string) => boolean -): number | null { - for (let i = 0; i < lines.length; i++) { - if (!matchesLabel(lines[i])) { - continue - } - // Scan next 12 lines for a percent - for (let j = i; j < Math.min(i + 12, lines.length); j++) { - if (j > i && isSectionLabel(lines[j])) { - break - } - const m = PERCENT_RE.exec(lines[j]) - if (m) { - const pct = Number.parseFloat(m[1]) - const word = m[2].toLowerCase() - const isUsed = word === 'used' || word === 'consumed' - return isUsed ? pct : 100 - pct - } - } - } - return null -} - -function parsePtyUsage(output: string): { - session: RateLimitWindow | null - weekly: RateLimitWindow | null - fableWeekly: RateLimitWindow | null -} { - const lines = output.split(/\r\n|\n|\r/) - - const sessionPct = extractPercentAfterLabel(lines, (line) => SESSION_RE.test(line)) - const weeklyPct = extractPercentAfterLabel(lines, matchesWeeklyLabel) - const fableWeeklyPct = extractPercentAfterLabel(lines, matchesFableUsageLabel) - const sessionReset = extractClaudePtyResetMetadata( - lines, - (line) => SESSION_RE.test(line), - isSectionLabel - ) - const weeklyReset = extractClaudePtyResetMetadata(lines, matchesWeeklyLabel, isSectionLabel) - const fableWeeklyReset = extractClaudePtyResetMetadata( - lines, - matchesFableUsageLabel, - isSectionLabel - ) - - const session: RateLimitWindow | null = - sessionPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, sessionPct)), - windowMinutes: 300, - resetsAt: sessionReset.resetsAt, - resetDescription: sessionReset.resetDescription - } - : null - - const weekly: RateLimitWindow | null = - weeklyPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, weeklyPct)), - windowMinutes: 10080, - resetsAt: weeklyReset.resetsAt, - resetDescription: weeklyReset.resetDescription - } - : null - - const fableWeekly: RateLimitWindow | null = - fableWeeklyPct !== null - ? { - usedPercent: Math.min(100, Math.max(0, fableWeeklyPct)), - windowMinutes: 10080, - resetsAt: fableWeeklyReset.resetsAt, - resetDescription: fableWeeklyReset.resetDescription - } - : null - - return { session, weekly, fableWeekly } -} - -// Why: these substrings indicate the /usage TUI panel has finished -// rendering. We stop collecting output once one appears, then allow -// a settle period for the rest of the content to flush. -const STOP_SUBSTRINGS = [ - 'Current week (all models)', - 'Current week (Opus)', - 'Current week (Sonnet only)', - 'Current week (Sonnet)', - 'Weekly limits', - 'Weekly limit', - 'Weekly usage', - '7-day', - 'Current session', - 'Failed to load usage data', - 'failed to load usage data' -] - // Why: prompt detection is unreliable because the Claude CLI v2.x renders // a status bar and TUI elements that push the `❯` prompt out of any // reasonable detection window. Instead we wait a fixed 2s after spawning @@ -173,48 +36,10 @@ const STOP_SUBSTRINGS = [ // palette prompts ("Show plan usage limits") are auto-confirmed with Enter. const COMMAND_PALETTE_RE = /show plan|usage limits/i const TRUST_PROMPT_RE = /do you trust|trust the files|safety check/i -const RATE_LIMITED_RE = /rate limited\.?\s+please try again later/i -const LOAD_FAILED_RE = /failed to load usage data/i -const CLAUDE_21_USAGE_TABS_RE = /settings?\s+status?\s+config\s+usage\s+stats/i -const CLAUDE_21_SESSION_STATS_RE = /total\s*cost|total\s*duration|usage:\s*\d+\s*input/i const STARTUP_DELAY_MS = 2_000 const SETTLE_AFTER_STOP_MS = 2_000 const SETTLE_AFTER_CLAUDE_21_USAGE_MS = 8_000 -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - -function describeClaudeUsageFailure(output: string): string { - if (RATE_LIMITED_RE.test(output)) { - return 'Claude usage is rate limited right now.' - } - - if (LOAD_FAILED_RE.test(output)) { - return 'Claude usage is unavailable right now.' - } - - if (CLAUDE_21_USAGE_TABS_RE.test(output) || CLAUDE_21_SESSION_STATS_RE.test(output)) { - return 'Claude plan usage is unavailable for this Claude CLI session.' - } - - // Why: parser failures are an implementation detail of Orca's PTY fallback. - // The UI should explain the user-visible outcome, not leak internal parsing - // mechanics that the user cannot act on. - return 'Claude usage is unavailable right now.' -} - -function abortedClaudeUsageResult(): ProviderRateLimits { - return { - provider: 'claude', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Rate-limit fetch aborted', - status: 'error' - } -} - export async function fetchViaPty(options?: { authPreparation?: ClaudeRuntimeAuthPreparation networkProxySettings?: NetworkProxySettings @@ -279,8 +104,10 @@ export async function fetchViaPty(options?: { // Why: hidden usage probes must not inherit a root-like WSL cwd; // keep Claude discovery bounded to a tiny temp directory. ...getHiddenRateLimitWslCwdSetupCommands(), - `export CLAUDE_CONFIG_DIR=${shellQuote(wslConfig.linuxConfigDir)}`, - ...Object.entries(proxyEnv).map(([key, value]) => `export ${key}=${shellQuote(value)}`), + `export CLAUDE_CONFIG_DIR=${quoteHiddenRateLimitShellValue(wslConfig.linuxConfigDir)}`, + ...Object.entries(proxyEnv).map( + ([key, value]) => `export ${key}=${quoteHiddenRateLimitShellValue(value)}` + ), 'exec claude' ].join(' && ') ] @@ -352,7 +179,7 @@ export async function fetchViaPty(options?: { cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) // Even on timeout, try to parse whatever we collected const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parsePtyUsage(clean) + const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) if (session || weekly || fableWeekly) { resolve({ provider: 'claude', @@ -370,7 +197,7 @@ export async function fetchViaPty(options?: { weekly: null, updatedAt: Date.now(), error: withMacTailscaleDnsHint( - CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean) + isClaude21UsagePanel(clean) ? describeClaudeUsageFailure(clean) : 'PTY timeout — /usage panel did not render', clean @@ -407,7 +234,7 @@ export async function fetchViaPty(options?: { cleanupHiddenRateLimitPty(term, termDisposables, { kill: true }) const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parsePtyUsage(clean) + const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) if (!session && !weekly && !fableWeekly) { resolve({ @@ -469,10 +296,7 @@ export async function fetchViaPty(options?: { // Check if we've hit a stop substring indicating the panel rendered if (sentUsage && !stopDetected) { const clean = stripTerminalControlSequences(output) - if ( - !claude21UsageDetected && - (CLAUDE_21_USAGE_TABS_RE.test(clean) || CLAUDE_21_SESSION_STATS_RE.test(clean)) - ) { + if (!claude21UsageDetected && isClaude21UsagePanel(clean)) { claude21UsageDetected = true if (enterInterval) { clearInterval(enterInterval) @@ -483,7 +307,7 @@ export async function fetchViaPty(options?: { // with a user-facing unavailable state instead of a false PTY timeout. claude21UsageSettleTimer = setTimeout(finalize, SETTLE_AFTER_CLAUDE_21_USAGE_MS) } - for (const sub of STOP_SUBSTRINGS) { + for (const sub of CLAUDE_USAGE_STOP_SUBSTRINGS) { if (clean.includes(sub)) { stopDetected = true // Why: 2.0s settle time after detecting the stop substring @@ -508,7 +332,7 @@ export async function fetchViaPty(options?: { timeout = null } const clean = stripTerminalControlSequences(output) - const { session, weekly, fableWeekly } = parsePtyUsage(clean) + const { session, weekly, fableWeekly } = parseClaudePtyUsage(clean) resolve({ provider: 'claude', session, diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index f3f3b078224..da2904d524e 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -37,6 +37,7 @@ import { getHiddenRateLimitWslCwdSetupCommands, resolveHiddenRateLimitPtyCwd } from './hidden-rate-limit-pty-cwd' +import { quoteHiddenRateLimitShellValue } from './hidden-rate-limit-shell' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 @@ -45,10 +46,6 @@ const WSL_RPC_INIT_TIMEOUT_MS = 40_000 export type FetchCodexRateLimitsOptions = CodexRateLimitFetchOptions -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` -} - function buildWslCodexCommand( codexHomePath: string, args: string[], @@ -60,9 +57,9 @@ function buildWslCodexCommand( } const setupCommands = [ ...getHiddenRateLimitWslCwdSetupCommands(), - `export CODEX_HOME=${shellQuote(wslInfo.linuxPath)}` + `export CODEX_HOME=${quoteHiddenRateLimitShellValue(wslInfo.linuxPath)}` ].join(' && ') - const execSuffix = `${args.map(shellQuote).join(' ')}${ + const execSuffix = `${args.map(quoteHiddenRateLimitShellValue).join(' ')}${ isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : '' }` const loginShellCommand = buildWslLoginShellCommand( diff --git a/src/main/rate-limits/hidden-rate-limit-shell.ts b/src/main/rate-limits/hidden-rate-limit-shell.ts new file mode 100644 index 00000000000..261f4519926 --- /dev/null +++ b/src/main/rate-limits/hidden-rate-limit-shell.ts @@ -0,0 +1,3 @@ +export function quoteHiddenRateLimitShellValue(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} diff --git a/src/main/runtime/rpc/methods/automation-schemas.ts b/src/main/runtime/rpc/methods/automation-schemas.ts index 919a251d26d..4d4f43a2350 100644 --- a/src/main/runtime/rpc/methods/automation-schemas.ts +++ b/src/main/runtime/rpc/methods/automation-schemas.ts @@ -1,6 +1,6 @@ // Why: the automation method table stays readable only if its field-level validation lives beside it rather than inside it. import { z } from 'zod' -import { isValidAutomationSchedule } from '../../../../shared/automation-schedules' +import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing' import { MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS, normalizeAutomationPrecheckTimeoutSeconds diff --git a/src/renderer/src/components/LinearIssueTextEditor.tsx b/src/renderer/src/components/LinearIssueTextEditor.tsx index 42db6a5c4c8..4301174ceb5 100644 --- a/src/renderer/src/components/LinearIssueTextEditor.tsx +++ b/src/renderer/src/components/LinearIssueTextEditor.tsx @@ -7,7 +7,7 @@ import { useMountedRef } from '@/hooks/useMountedRef' import { cn } from '@/lib/utils' import { useAppStore } from '@/store' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { linearUpdateIssue } from '@/runtime/runtime-linear-client' +import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 04b4c613c4f..d544b58eebe 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -62,7 +62,7 @@ import { linearGetIssue, linearIssueComments, linearUpdateIssue -} from '@/runtime/runtime-linear-client' +} from '@/runtime/runtime-linear-issue-mutations' import { translate } from '@/i18n/i18n' import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' diff --git a/src/renderer/src/components/automations/AutomationCustomCronPanel.tsx b/src/renderer/src/components/automations/AutomationCustomCronPanel.tsx index ead3ae34f4a..a2a7c8cca1b 100644 --- a/src/renderer/src/components/automations/AutomationCustomCronPanel.tsx +++ b/src/renderer/src/components/automations/AutomationCustomCronPanel.tsx @@ -2,10 +2,8 @@ import React from 'react' import { CheckCircle2, CircleAlert } from 'lucide-react' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' -import { - describeAutomationSchedule, - getAutomationCronExpressionFields -} from '../../../../shared/automation-schedules' +import { describeAutomationSchedule } from '../../../../shared/automation-schedules' +import { getAutomationCronExpressionFields } from '../../../../shared/automation-schedule-parsing' import { formatUiAutomationScheduleDescriptor } from './automation-schedule-label' import type { AutomationDraft } from './AutomationEditorDialog' import { Field } from './automation-page-parts' diff --git a/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx index 45bbfd0f226..7d1f48cea29 100644 --- a/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx +++ b/src/renderer/src/components/automations/AutomationEditorSettingsSidebar.tsx @@ -4,7 +4,7 @@ import { translate } from '@/i18n/i18n' import { isValidAutomationCronSchedule, isValidAutomationSchedule -} from '../../../../shared/automation-schedules' +} from '../../../../shared/automation-schedule-parsing' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import type { ProjectHostSetup } from '../../../../shared/project-types' diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx b/src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx index e65261b8820..f4ba0b46e5b 100644 --- a/src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx +++ b/src/renderer/src/components/automations/AutomationSchedulePicker.render.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AutomationDraft } from './AutomationEditorDialog' import { AutomationSchedulePicker } from './AutomationSchedulePicker' -import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedules' +import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedule-parsing' import { useTranslation } from 'react-i18next' import { i18n } from '@/i18n/i18n' diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts b/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts index 5ee1f22a4f8..9202dd58774 100644 --- a/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts +++ b/src/renderer/src/components/automations/AutomationSchedulePicker.test.ts @@ -14,7 +14,7 @@ import { getSchedulePresetDraft } from './AutomationSchedulePicker' import { formatUiAutomationSchedule } from './automation-schedule-label' -import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedules' +import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedule-parsing' import { SelectItem } from '@/components/ui/select' import { i18n } from '@/i18n/i18n' diff --git a/src/renderer/src/components/automations/AutomationSchedulePicker.tsx b/src/renderer/src/components/automations/AutomationSchedulePicker.tsx index 94399a0aab1..3dfe438cee6 100644 --- a/src/renderer/src/components/automations/AutomationSchedulePicker.tsx +++ b/src/renderer/src/components/automations/AutomationSchedulePicker.tsx @@ -7,10 +7,8 @@ import { SelectValue } from '@/components/ui/select' import type { AutomationSchedulePreset } from '../../../../shared/automations-types' -import { - buildAutomationCronSchedule, - isValidAutomationSchedule -} from '../../../../shared/automation-schedules' +import { buildAutomationCronSchedule } from '../../../../shared/automation-schedule-occurrences' +import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing' import type { AutomationDraft } from './AutomationEditorDialog' import { AutomationCustomCronPanel } from './AutomationCustomCronPanel' import { AutomationTimeField, parseAutomationTime } from './AutomationTimeField' diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 7c908d41af1..7eef4ac6f57 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -33,11 +33,11 @@ import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import type { Repo } from '../../../../shared/repo-types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree/id' import type { Worktree } from '../../../../shared/worktree/types' +import { buildAutomationRrule } from '../../../../shared/automation-schedule-occurrences' import { - buildAutomationRrule, isValidAutomationCronSchedule, isValidAutomationSchedule -} from '../../../../shared/automation-schedules' +} from '../../../../shared/automation-schedule-parsing' import { canRerunAutomationRun, getAutomationRunViewState, diff --git a/src/renderer/src/components/automations/HermesCronOutputView.tsx b/src/renderer/src/components/automations/HermesCronOutputView.tsx index e11452976d0..6a6fc6e350e 100644 --- a/src/renderer/src/components/automations/HermesCronOutputView.tsx +++ b/src/renderer/src/components/automations/HermesCronOutputView.tsx @@ -14,7 +14,7 @@ import { import type { LucideIcon } from 'lucide-react' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' import { cn } from '@/lib/utils' -import { isValidAutomationSchedule } from '../../../../shared/automation-schedules' +import { isValidAutomationSchedule } from '../../../../shared/automation-schedule-parsing' import { formatUiAutomationSchedule } from './automation-schedule-label' import { translate } from '@/i18n/i18n' import { parseHermesOutput, type ParsedHermesSection } from './hermes-cron-output-parse' diff --git a/src/renderer/src/components/automations/automation-draft-model.ts b/src/renderer/src/components/automations/automation-draft-model.ts index 827f84543a4..271fb6d9748 100644 --- a/src/renderer/src/components/automations/automation-draft-model.ts +++ b/src/renderer/src/components/automations/automation-draft-model.ts @@ -1,6 +1,6 @@ import { getAgentCatalog } from '@/lib/agent-catalog' import type { AutomationPrecheck } from '../../../../shared/automations-types' -import { buildAutomationCronSchedule } from '../../../../shared/automation-schedules' +import { buildAutomationCronSchedule } from '../../../../shared/automation-schedule-occurrences' import type { Worktree } from '../../../../shared/worktree/types' import type { AutomationDraft } from './AutomationEditorDialog' diff --git a/src/renderer/src/components/automations/automation-edit-draft.ts b/src/renderer/src/components/automations/automation-edit-draft.ts index f4f15bff013..2e9ad08cc26 100644 --- a/src/renderer/src/components/automations/automation-edit-draft.ts +++ b/src/renderer/src/components/automations/automation-edit-draft.ts @@ -12,7 +12,7 @@ import { isValidAutomationCronSchedule, isValidAutomationSchedule, tryParseAutomationRrule -} from '../../../../shared/automation-schedules' +} from '../../../../shared/automation-schedule-parsing' import type { AutomationDraft } from './AutomationEditorDialog' import { AUTOMATION_DEFAULT_TIME, formatTimeInput } from './automation-draft-model' import { getAutomationSetupDecisionDraftValue } from './automation-setup-decision' diff --git a/src/renderer/src/components/automations/external-automation-schedule-display.ts b/src/renderer/src/components/automations/external-automation-schedule-display.ts index 39b19b1bbc6..1b21f1d475a 100644 --- a/src/renderer/src/components/automations/external-automation-schedule-display.ts +++ b/src/renderer/src/components/automations/external-automation-schedule-display.ts @@ -2,7 +2,7 @@ import type { ExternalAutomationJob, ExternalAutomationManager } from '../../../../shared/automations-types' -import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedules' +import { isValidAutomationCronSchedule } from '../../../../shared/automation-schedule-parsing' import { formatUiAutomationSchedule } from './automation-schedule-label' import { translate } from '@/i18n/i18n' diff --git a/src/renderer/src/components/linear-issue-project-selector.test.tsx b/src/renderer/src/components/linear-issue-project-selector.test.tsx index c0c16484106..eb0446e9896 100644 --- a/src/renderer/src/components/linear-issue-project-selector.test.tsx +++ b/src/renderer/src/components/linear-issue-project-selector.test.tsx @@ -20,7 +20,12 @@ const storeMocks = vi.hoisted(() => ({ } })) -vi.mock('@/runtime/runtime-linear-client', () => runtimeMocks) +vi.mock('@/runtime/runtime-linear-project-client', () => ({ + linearListProjects: runtimeMocks.linearListProjects +})) +vi.mock('@/runtime/runtime-linear-issue-mutations', () => ({ + linearUpdateIssue: runtimeMocks.linearUpdateIssue +})) vi.mock('@/store', () => ({ useAppStore: (selector: (state: typeof storeMocks.state) => unknown) => selector(storeMocks.state) })) diff --git a/src/renderer/src/components/linear-issue-project-selector.tsx b/src/renderer/src/components/linear-issue-project-selector.tsx index 118c5d1c956..4bdf2de9927 100644 --- a/src/renderer/src/components/linear-issue-project-selector.tsx +++ b/src/renderer/src/components/linear-issue-project-selector.tsx @@ -6,7 +6,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' import { getLinearProjectSearchRequestQuery } from '@/components/linear-project-search-query' -import { linearListProjects, linearUpdateIssue } from '@/runtime/runtime-linear-client' +import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations' +import { linearListProjects } from '@/runtime/runtime-linear-project-client' import { useAppStore } from '@/store' import type { LinearIssue } from '../../../shared/linear/issue-types' import type { LinearProjectSummary } from '../../../shared/linear/project-types' diff --git a/src/renderer/src/components/linear-issue-sub-issues.tsx b/src/renderer/src/components/linear-issue-sub-issues.tsx index adf88992c32..970f7997bfc 100644 --- a/src/renderer/src/components/linear-issue-sub-issues.tsx +++ b/src/renderer/src/components/linear-issue-sub-issues.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' -import { linearCreateSubIssue } from '@/runtime/runtime-linear-client' +import { linearCreateSubIssue } from '@/runtime/runtime-linear-issue-mutations' import { useAppStore } from '@/store' import type { LinearIssue, LinearIssueChildSummary } from '../../../shared/linear/issue-types' import type { TaskSourceContext } from '../../../shared/task-source-context' diff --git a/src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx b/src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx index c22b11d93b6..3ee30fc9ae0 100644 --- a/src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx +++ b/src/renderer/src/components/linear-issue-workspace-detail-state.test.tsx @@ -20,7 +20,7 @@ const runtimeMocks = vi.hoisted(() => ({ linearIssueComments: vi.fn() })) -vi.mock('@/runtime/runtime-linear-client', () => runtimeMocks) +vi.mock('@/runtime/runtime-linear-issue-mutations', () => runtimeMocks) vi.mock('@/components/LinearItemDrawer', () => ({ initLinearIssueEditState: (selected: LinearIssue) => ({ state: selected.state, diff --git a/src/renderer/src/components/linear-issue-workspace-detail-state.ts b/src/renderer/src/components/linear-issue-workspace-detail-state.ts index c2082042509..5d69d855be9 100644 --- a/src/renderer/src/components/linear-issue-workspace-detail-state.ts +++ b/src/renderer/src/components/linear-issue-workspace-detail-state.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { createBrowserUuid } from '@/lib/browser-uuid' import { useMountedRef } from '@/hooks/useMountedRef' -import { linearGetIssue, linearIssueComments } from '@/runtime/runtime-linear-client' +import { linearGetIssue, linearIssueComments } from '@/runtime/runtime-linear-issue-mutations' import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client' import { initLinearIssueEditState, diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx index 869509cbf4a..9a634120ca0 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.test.tsx @@ -10,7 +10,7 @@ import type { WorkspaceLineage } from '../../../../shared/worktree/lineage-types import type { Worktree } from '../../../../shared/worktree/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' import { prChecksCacheSuffix } from '@/store/github/cache-identity' diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx index 4c03a998d79..5693de11724 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksPanel.tsx @@ -8,7 +8,7 @@ import { translate } from '@/i18n/i18n' import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees' import { FolderWorkspacePrChecksRow } from './FolderWorkspacePrChecksRow' -import type { ParentPrChecksRefreshOutcome, ParentPrChecksRow } from './parent-pr-checks-rows' +import type { ParentPrChecksRefreshOutcome, ParentPrChecksRow } from './parent-pr-checks-row-types' import { getParentPrChecksRefreshCandidates, runLimitedParentPrChecksRefreshes diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx index 58dc1398984..36bb9d7527d 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.test.tsx @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { CheckStatus } from '../../../../shared/github/pull-request-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import type { ParentPrChecksRow, ParentPrChecksRowStatus } from './parent-pr-checks-rows' +import type { ParentPrChecksRow, ParentPrChecksRowStatus } from './parent-pr-checks-row-types' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => diff --git a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx index ba9c3c6a174..a0f77223186 100644 --- a/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx +++ b/src/renderer/src/components/right-sidebar/FolderWorkspacePrChecksRow.tsx @@ -11,7 +11,7 @@ import { PullRequestIcon } from './checks-panel/check-presentation' import { ChecksList } from './checks-panel/checks-list' -import type { ParentPrChecksRow } from './parent-pr-checks-rows' +import type { ParentPrChecksRow } from './parent-pr-checks-row-types' type FolderWorkspacePrChecksRowProps = { row: ParentPrChecksRow diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx index e423c3e8797..1a7a34fa0fc 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react' import { toast } from 'sonner' -import { refreshHostedReviewCard } from '@/store/slices/hosted-review' +import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh' import { checksPanelAsyncResultKey } from '../checks-panel-async-result-key' import { buildFixBrokenChecksPrompt, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx index 451ba042d7e..8c0a40931e6 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-context-state.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { useAppStore } from '@/store' import { isFolderRepo } from '../../../../../shared/repo-kind' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { selectReviewCacheEntry } from '../review-cache-entry-selection' import { selectChecksPanelReview, type ChecksPanelReview } from '../checks-panel-review' import { isGitLabChecksPanelReview } from './gitlab-review-client' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx index 8e34f253633..2ccb1188de7 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-create-review.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { toast } from 'sonner' -import { refreshHostedReviewCard } from '@/store/slices/hosted-review' +import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh' import { openHttpLink } from '@/lib/http-link-routing' import { resolveCreatedHostedReviewLink } from '../source-control-created-review-link' import { formatCreateError } from '../create-pull-request-review-copy' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx index efbc59e7a3b..f841968a182 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-entry-refresh-and-title-actions.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef } from 'react' import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from '../checks-entry-refresh' -import { refreshHostedReviewCard } from '@/store/slices/hosted-review' +import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh' import { toast } from 'sonner' import type { ChecksPanelContextState } from './use-checks-panel-context-state' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx index 90b264b8fd9..959ea4468ec 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.test.tsx @@ -2,12 +2,12 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as HostedReviewStore from '@/store/slices/hosted-review' +import type * as HostedReviewCardRefresh from '@/store/slices/hosted-review-card-refresh' const refresh = vi.hoisted(() => ({ calls: [] as string[] })) -vi.mock('@/store/slices/hosted-review', async (importOriginal) => { - const original = await importOriginal() +vi.mock('@/store/slices/hosted-review-card-refresh', async (importOriginal) => { + const original = await importOriginal() return { ...original, refreshHostedReviewCard: vi.fn(async () => { diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx index 870c75e3a15..25ab151a19a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-manual-refresh.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react' import { useAppStore } from '@/store' import { buildGitHubPRRefreshStateClearToken } from '@/store/github/pr-refresh-state' -import { refreshHostedReviewCard } from '@/store/slices/hosted-review' +import { refreshHostedReviewCard } from '@/store/slices/hosted-review-card-refresh' import { getRuntimeGitStatus, getRuntimeGitUpstreamStatus } from '@/runtime/runtime-git-client' import { hasChecksPanelGitStatusBranchChanged, diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts index 025817b1160..0616bb510e9 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { buildParentPrChecksProjection } from './parent-pr-checks-rows' import { createParentPrChecksProjectionSelector } from './parent-pr-checks-projection-selector' diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts index edde3fcd750..c385d03b5bc 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-refresh.ts @@ -6,10 +6,8 @@ import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { isFolderRepo } from '../../../../shared/repo-kind' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order' -import { - getParentPrChecksRefreshIdentity, - type ParentPrChecksRefreshOutcome -} from './parent-pr-checks-rows' +import { getParentPrChecksRefreshIdentity } from './parent-pr-checks-rows' +import type { ParentPrChecksRefreshOutcome } from './parent-pr-checks-row-types' type FetchHostedReview = ( repoPath: string, diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts index f44e7dca663..256eaf584a9 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.test.ts @@ -4,7 +4,7 @@ import type { PRInfo } from '../../../../shared/github/pull-request-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { getGitHubPRCacheKey, getGitHubRepoCacheKey, @@ -13,9 +13,9 @@ import { import { prChecksCacheSuffix } from '@/store/github/cache-identity' import { buildParentPrChecksProjection, - getParentPrChecksRefreshIdentity, - type ParentPrChecksRefreshOutcome + getParentPrChecksRefreshIdentity } from './parent-pr-checks-rows' +import type { ParentPrChecksRefreshOutcome } from './parent-pr-checks-row-types' const settings = null as never diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts index d3c2480514e..72bec30b52c 100644 --- a/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts +++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-rows.ts @@ -7,7 +7,10 @@ import { isFolderRepo } from '../../../../shared/repo-kind' import { getWorktreeCardPrDisplay } from '@/components/sidebar/worktree-card-pr-display' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { getGitHubRepoCacheKey } from '@/store/slices/github-cache-key' -import { getHostedReviewCacheKey, linkedReviewHintKey } from '@/store/slices/hosted-review' +import { + getHostedReviewCacheKey, + linkedReviewHintKey +} from '@/store/slices/hosted-review-cache-identity' import { prChecksCacheSuffix } from '@/store/github/cache-identity' import { PARENT_PR_CHECKS_GROUP_LABELS, @@ -33,15 +36,6 @@ import { canUseParentPrChecksHostedReviewCacheEntry } from './parent-pr-checks-h type ParentPrChecksRowSourceArgs = Omit -export type { - ParentPrChecksGroupKey, - ParentPrChecksProjection, - ParentPrChecksRefreshOutcome, - ParentPrChecksRow, - ParentPrChecksRowStatus, - ParentPrChecksSummary -} from './parent-pr-checks-row-types' - export function buildParentPrChecksProjection( args: BuildParentPrChecksRowsArgs ): ParentPrChecksProjection { diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts index befca9f4cb9..94fc5879f85 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-worktree-context.ts @@ -7,7 +7,7 @@ import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-displ import { useAppStore } from '@/store' import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' import { isFolderRepo } from '../../../../../../shared/repo-kind' diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts index 5914520603e..06e67d75381 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts @@ -3,10 +3,8 @@ import type { Repo } from '../../../../shared/repo-types' import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' -import { - buildParentPrChecksRows, - type ParentPrChecksRow -} from '@/components/right-sidebar/parent-pr-checks-rows' +import { buildParentPrChecksRows } from '@/components/right-sidebar/parent-pr-checks-rows' +import type { ParentPrChecksRow } from '@/components/right-sidebar/parent-pr-checks-row-types' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' import { getProjectedWorktreeLineageChildrenByParentId } from './worktree-lineage-projection' diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts index 36f4d5af69c..fd45c5167a3 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts @@ -1,9 +1,5 @@ -/* eslint-disable max-lines -- Why: marquee selection coordinates pointer capture, lane-scroll refresh, auto-scroll, preview cleanup, and final commit against one drag state. Splitting those phases would make the interaction easier to desynchronize. */ import React, { useCallback, useEffect, useRef } from 'react' -import { - getAreaSelectionCardRects, - type AreaSelectionCardRect -} from './workspace-kanban-area-selection-card-rects' +import { getAreaSelectionCardRects } from './workspace-kanban-area-selection-card-rects' import { clearPreviewSelection, getAreaSelectionAutoScrollDelta, @@ -16,54 +12,12 @@ import { shouldIgnoreAreaSelectionStart, updatePreviewSelection } from './workspace-kanban-area-selection-dom' - -type AreaSelectionDragState = { - startX: number - startY: number - currentX: number - currentY: number - additive: boolean - baseSelectedIds: Set - baseAnchorId: string | null - boardRect: DOMRect - cardRects: readonly AreaSelectionCardRect[] - scrollStartContentYByElement: ReadonlyMap - previewIds: Set - finalAreaIds: string[] - started: boolean - frameId: number | null - scrollFrameId: number | null -} - -type UpdateSelectionForArea = ( - areaIds: readonly string[], - additive: boolean, - baseSelectedIds?: ReadonlySet, - baseAnchorId?: string | null -) => void - -type UseWorkspaceKanbanAreaSelectionParams = { - open: boolean - boardRef: React.RefObject - overlayRef: React.RefObject - selectedWorktreeIds: ReadonlySet - selectionAnchorId: string | null - updateSelectionForArea: UpdateSelectionForArea -} - -const AREA_SELECTION_DRAG_THRESHOLD = 4 - -export function shouldCommitWorkspaceKanbanAreaSelection({ - additive, - started -}: { - additive: boolean - started: boolean -}): boolean { - // Why: a plain click on empty board space is the user's "click off" gesture; - // modifier-clicking empty space should not accidentally drop a selected batch. - return started || !additive -} +import { + AREA_SELECTION_DRAG_THRESHOLD, + shouldCommitWorkspaceKanbanAreaSelection, + type AreaSelectionDragState, + type UseWorkspaceKanbanAreaSelectionParams +} from './workspace-kanban-area-selection-state' export function useWorkspaceKanbanAreaSelection({ open, diff --git a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts index 05bee14ee80..277bff4b89e 100644 --- a/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts +++ b/src/renderer/src/components/sidebar/use-worktree-card-review-details.ts @@ -2,7 +2,7 @@ import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-displ import { useAppStore } from '@/store' import { getGitHubPRCacheKey } from '@/store/slices/github-cache-key' import { issueCacheKey as getIssueCacheKey } from '@/store/github/cache-identity' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/hosted-review-github' import type { HostedReviewInfo } from '../../../../shared/hosted-review' import { isFolderRepo } from '../../../../shared/repo-kind' diff --git a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts index 505459a99fd..b4e76ca0a17 100644 --- a/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts +++ b/src/renderer/src/components/sidebar/workspace-board-task-status-sync.ts @@ -1,10 +1,6 @@ -import { - linearGetIssue, - linearTeamStates, - linearUpdateIssue, - type LinearMutationResult, - type RuntimeLinearSettings -} from '@/runtime/runtime-linear-client' +import { linearGetIssue, linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations' +import { linearTeamStates } from '@/runtime/runtime-linear-project-client' +import type { LinearMutationResult, RuntimeLinearSettings } from '@/runtime/runtime-linear-client' import type { LinearIssue } from '../../../../shared/linear/issue-types' import type { LinearWorkflowState } from '../../../../shared/linear/workspace-types' import type { diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection-state.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-state.ts new file mode 100644 index 00000000000..6782b8e3122 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection-state.ts @@ -0,0 +1,50 @@ +import type React from 'react' +import type { AreaSelectionCardRect } from './workspace-kanban-area-selection-card-rects' + +export type AreaSelectionDragState = { + startX: number + startY: number + currentX: number + currentY: number + additive: boolean + baseSelectedIds: Set + baseAnchorId: string | null + boardRect: DOMRect + cardRects: readonly AreaSelectionCardRect[] + scrollStartContentYByElement: ReadonlyMap + previewIds: Set + finalAreaIds: string[] + started: boolean + frameId: number | null + scrollFrameId: number | null +} + +type UpdateSelectionForArea = ( + areaIds: readonly string[], + additive: boolean, + baseSelectedIds?: ReadonlySet, + baseAnchorId?: string | null +) => void + +export type UseWorkspaceKanbanAreaSelectionParams = { + open: boolean + boardRef: React.RefObject + overlayRef: React.RefObject + selectedWorktreeIds: ReadonlySet + selectionAnchorId: string | null + updateSelectionForArea: UpdateSelectionForArea +} + +export const AREA_SELECTION_DRAG_THRESHOLD = 4 + +export function shouldCommitWorkspaceKanbanAreaSelection({ + additive, + started +}: { + additive: boolean + started: boolean +}): boolean { + // Why: a plain click on empty board space is the user's "click off" gesture; + // modifier-clicking empty space should not accidentally drop a selected batch. + return started || !additive +} diff --git a/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts b/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts index 86a51e1e8a2..558922293fe 100644 --- a/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts +++ b/src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts @@ -6,7 +6,7 @@ import { getAreaSelectionCardIds, updatePreviewSelection } from './workspace-kanban-area-selection-dom' -import { shouldCommitWorkspaceKanbanAreaSelection } from './use-workspace-kanban-area-selection' +import { shouldCommitWorkspaceKanbanAreaSelection } from './workspace-kanban-area-selection-state' describe('workspace kanban area selection finish', () => { it('commits an empty non-additive surface click so selection clears', () => { diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index c4d8b080843..af74109cae9 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -54,7 +54,7 @@ import { getWorktreeMapFromState, getWorktreeOnHostFromState } from '../../store/selectors' -import { getHostedReviewCacheKey } from '../../store/slices/hosted-review' +import { getHostedReviewCacheKey } from '../../store/slices/hosted-review-cache-identity' import { issueCacheKey as getIssueCacheKey } from '../../store/github/cache-identity' import { findRepoForHost } from '@/store/slices/repo-host-identity' import { diff --git a/src/renderer/src/components/task-page/hooks/use-task-page-create-linear-submits.tsx b/src/renderer/src/components/task-page/hooks/use-task-page-create-linear-submits.tsx index 70e82bd02b4..224ae67d846 100644 --- a/src/renderer/src/components/task-page/hooks/use-task-page-create-linear-submits.tsx +++ b/src/renderer/src/components/task-page/hooks/use-task-page-create-linear-submits.tsx @@ -3,11 +3,8 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' -import { - linearCreateIssue, - linearCreateProject, - linearGetIssue -} from '@/runtime/runtime-linear-client' +import { linearCreateIssue, linearGetIssue } from '@/runtime/runtime-linear-issue-mutations' +import { linearCreateProject } from '@/runtime/runtime-linear-project-client' import type { GlobalSettings } from '../../../../../shared/global-settings-types' import type { LinearIssue } from '../../../../../shared/linear/issue-types' import type { diff --git a/src/renderer/src/components/task-page/hooks/use-task-page-linear-board.tsx b/src/renderer/src/components/task-page/hooks/use-task-page-linear-board.tsx index e1db9aacfee..2682ac33cb8 100644 --- a/src/renderer/src/components/task-page/hooks/use-task-page-linear-board.tsx +++ b/src/renderer/src/components/task-page/hooks/use-task-page-linear-board.tsx @@ -7,7 +7,8 @@ import { readLinearBoardIssueDragData, writeLinearBoardIssueDragData } from '@/lib/linear-board-drag-payload' -import { linearTeamStates, linearUpdateIssue } from '@/runtime/runtime-linear-client' +import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations' +import { linearTeamStates } from '@/runtime/runtime-linear-project-client' import type { GlobalSettings } from '../../../../../shared/global-settings-types' import type { LinearIssue } from '../../../../../shared/linear/issue-types' import type { LinearTeam } from '../../../../../shared/linear/workspace-types' diff --git a/src/renderer/src/components/task-page/hooks/use-task-page-linear-create-dialogs.ts b/src/renderer/src/components/task-page/hooks/use-task-page-linear-create-dialogs.ts index 7403c12d4b9..8d202c57bac 100644 --- a/src/renderer/src/components/task-page/hooks/use-task-page-linear-create-dialogs.ts +++ b/src/renderer/src/components/task-page/hooks/use-task-page-linear-create-dialogs.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type SetStateAction } from 'react' import { useTaskCreationDraftRetention } from '@/components/use-task-creation-draft-retention' import { useTeamLabels, useTeamMembers, useTeamStates } from '@/hooks/useIssueMetadata' -import { linearListProjects } from '@/runtime/runtime-linear-client' +import { linearListProjects } from '@/runtime/runtime-linear-project-client' import { writeNewLinearIssueDraft, writeNewLinearProjectDraft diff --git a/src/renderer/src/components/task-page/linear/linear-state-cell.tsx b/src/renderer/src/components/task-page/linear/linear-state-cell.tsx index 03f3d4aa5d4..28da17989a8 100644 --- a/src/renderer/src/components/task-page/linear/linear-state-cell.tsx +++ b/src/renderer/src/components/task-page/linear/linear-state-cell.tsx @@ -9,7 +9,7 @@ import { getLinearStatePillStyle } from '@/components/linear-state-pill-style' import { useTeamStates } from '@/hooks/useIssueMetadata' -import { linearUpdateIssue } from '@/runtime/runtime-linear-client' +import { linearUpdateIssue } from '@/runtime/runtime-linear-issue-mutations' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { LinearIssue } from '../../../../../shared/linear/issue-types' diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts index 2f24f47fb0c..f0640d42889 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import { filterWorkspaceCleanupCandidates, getWorkspaceCleanupGitLabel, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts index f3506b07e20..17b3da019ba 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts @@ -1,4 +1,4 @@ -import { getHostedReviewCacheKey } from '@/store/slices/hosted-review' +import { getHostedReviewCacheKey } from '@/store/slices/hosted-review-cache-identity' import type { AppState } from '@/store/types' import { translate } from '@/i18n/i18n' import { getRepoExecutionHostId } from '../../../../shared/execution-host' diff --git a/src/renderer/src/hooks/useIssueMetadata.test.tsx b/src/renderer/src/hooks/useIssueMetadata.test.tsx index c633d543daa..97f6b3d6b6c 100644 --- a/src/renderer/src/hooks/useIssueMetadata.test.tsx +++ b/src/renderer/src/hooks/useIssueMetadata.test.tsx @@ -21,7 +21,7 @@ const linearMocks = vi.hoisted(() => ({ const runtimeMocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() })) const githubMocks = vi.hoisted(() => ({ listLabels: vi.fn() })) -vi.mock('@/runtime/runtime-linear-client', () => ({ +vi.mock('@/runtime/runtime-linear-project-client', () => ({ linearTeamStates: linearMocks.linearTeamStates, linearTeamLabels: linearMocks.linearTeamLabels, linearTeamMembers: linearMocks.linearTeamMembers diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts index 3dc10686323..a7e55b80700 100644 --- a/src/renderer/src/hooks/useIssueMetadata.ts +++ b/src/renderer/src/hooks/useIssueMetadata.ts @@ -3,9 +3,9 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { linearTeamLabels, linearTeamMembers, - linearTeamStates, - type RuntimeLinearSettings -} from '@/runtime/runtime-linear-client' + linearTeamStates +} from '@/runtime/runtime-linear-project-client' +import type { RuntimeLinearSettings } from '@/runtime/runtime-linear-client' import type { GitHubAssignableUser } from '../../../shared/github/pull-request-types' import type { LinearLabel, diff --git a/src/renderer/src/runtime/runtime-linear-client.test.ts b/src/renderer/src/runtime/runtime-linear-client.test.ts index 8ebdfffc55e..77e4bbbf957 100644 --- a/src/renderer/src/runtime/runtime-linear-client.test.ts +++ b/src/renderer/src/runtime/runtime-linear-client.test.ts @@ -1,8 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + linearListIssues, + linearSearchIssues, + linearSelectWorkspace, + linearStatus +} from './runtime-linear-client' import { linearCreateIssue, - linearCreateProject, linearCreateSubIssue, + linearUpdateIssue +} from './runtime-linear-issue-mutations' +import { + linearCreateProject, linearGetCustomView, linearGetProject, linearListCustomViewIssues, @@ -10,13 +19,8 @@ import { linearListCustomViews, linearListProjectIssues, linearListProjects, - linearListTeams, - linearListIssues, - linearSearchIssues, - linearSelectWorkspace, - linearStatus, - linearUpdateIssue -} from './runtime-linear-client' + linearListTeams +} from './runtime-linear-project-client' import { createCompatibleRuntimeStatusResponse, createCompatibleRuntimeStatusResponseIfNeeded, diff --git a/src/renderer/src/runtime/runtime-linear-client.ts b/src/renderer/src/runtime/runtime-linear-client.ts index d960923075e..745a4d35b3a 100644 --- a/src/renderer/src/runtime/runtime-linear-client.ts +++ b/src/renderer/src/runtime/runtime-linear-client.ts @@ -1,23 +1,10 @@ -/* eslint-disable max-lines -- Why: the renderer Linear client mirrors the - preload/RPC Linear namespace so local and remote runtime routing stays in - one auditable boundary. */ import type { GlobalSettings } from '../../../shared/global-settings-types' -import type { LinearIssueUpdate } from '../../../shared/issue-mutation-types' -import type { LinearComment, LinearIssue } from '../../../shared/linear/issue-types' -import type { - LinearCustomViewModel, - LinearCustomViewSummary, - LinearProjectDetail, - LinearProjectSummary -} from '../../../shared/linear/project-types' +import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { LinearProjectDetail } from '../../../shared/linear/project-types' import type { LinearCollectionResult, LinearConnectionStatus, - LinearLabel, - LinearMember, - LinearTeam, LinearViewer, - LinearWorkflowState, LinearWorkspaceSelection } from '../../../shared/linear/workspace-types' import { @@ -71,7 +58,7 @@ export type LinearMutationResult = { ok: true } | { ok: false; error: string } export type LinearCommentResult = { ok: true; id: string } | { ok: false; error: string } export type LinearReadOptions = { force?: boolean } -function linearReadForce(options?: LinearReadOptions): { force: true } | {} { +export function linearReadForce(options?: LinearReadOptions): { force: true } | {} { return options?.force ? { force: true } : {} } @@ -81,7 +68,7 @@ function isTaskSourceRuntimeSettings( return settings !== null && settings !== undefined && 'kind' in settings } -function getLinearRuntimeTarget( +export function getLinearRuntimeTarget( settings: RuntimeLinearSettings ): ReturnType { // Why: task source context makes provider ownership explicit; legacy callers @@ -251,348 +238,3 @@ export async function linearListIssues( : await window.api.linear.listIssues(payload) return normalizeLinearIssueCollectionResult(result) } - -export async function linearCreateIssue( - settings: RuntimeLinearSettings, - args: { - teamId: string - title: string - description?: string - workspaceId?: string - parentIssueId?: string - projectId?: string | null - stateId?: string - priority?: number - assigneeId?: string | null - labelIds?: string[] - } -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc(target, 'linear.createIssue', args, { - timeoutMs: 30_000 - }) - : window.api.linear.createIssue(args) -} - -export async function linearCreateSubIssue( - settings: RuntimeLinearSettings, - args: { - parentIssueId: string - teamId: string - title: string - description?: string - workspaceId?: string - projectId?: string | null - } -): Promise { - return linearCreateIssue(settings, args) -} - -export async function linearGetIssue( - settings: RuntimeLinearSettings, - id: string, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.getIssue', - { id, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.getIssue({ id, workspaceId: workspaceId ?? undefined }) -} - -export async function linearUpdateIssue( - settings: RuntimeLinearSettings, - id: string, - updates: LinearIssueUpdate, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.updateIssue', - { id, updates, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.updateIssue({ id, updates, workspaceId: workspaceId ?? undefined }) -} - -export async function linearAddIssueComment( - settings: RuntimeLinearSettings, - issueId: string, - body: string, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.addIssueComment', - { issueId, body, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.addIssueComment({ issueId, body, workspaceId: workspaceId ?? undefined }) -} - -export async function linearIssueComments( - settings: RuntimeLinearSettings, - issueId: string, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.issueComments', - { issueId, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.issueComments({ issueId, workspaceId: workspaceId ?? undefined }) -} - -export async function linearListTeams( - settings: RuntimeLinearSettings, - workspaceId?: LinearWorkspaceSelection | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.listTeams', - workspaceId ? { workspaceId } : undefined, - { timeoutMs: 30_000 } - ) - : window.api.linear.listTeams(workspaceId ? { workspaceId } : undefined) -} - -export async function linearListProjects( - settings: RuntimeLinearSettings, - query?: string, - limit?: number, - workspaceId?: LinearWorkspaceSelection | null, - options?: LinearReadOptions -): Promise> { - if (!isRuntimeProviderSearchQueryWithinLimit(query)) { - return { items: [] } - } - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc>( - target, - 'linear.listProjects', - { query, limit, workspaceId: workspaceId ?? undefined, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : typeof window.api.linear.listProjects === 'function' - ? window.api.linear.listProjects({ - query, - limit, - workspaceId: workspaceId ?? undefined, - ...linearReadForce(options) - }) - : { items: [] } -} - -export async function linearCreateProject( - settings: RuntimeLinearSettings, - args: { - name: string - description?: string - content?: string - teamIds: string[] - workspaceId?: string - leadId?: string | null - memberIds?: string[] - labelIds?: string[] - priority?: number - startDate?: string - targetDate?: string - } -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc(target, 'linear.createProject', args, { - timeoutMs: 30_000 - }) - : window.api.linear.createProject(args) -} - -export async function linearGetProject( - settings: RuntimeLinearSettings, - id: string, - workspaceId: string, - options?: LinearReadOptions -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.getProject', - { id, workspaceId, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : window.api.linear.getProject({ id, workspaceId, ...linearReadForce(options) }) -} - -export async function linearListProjectIssues( - settings: RuntimeLinearSettings, - projectId: string, - limit: number | undefined, - workspaceId: string, - options?: LinearReadOptions -): Promise> { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc>( - target, - 'linear.listProjectIssues', - { projectId, limit, workspaceId, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : window.api.linear.listProjectIssues({ - projectId, - limit, - workspaceId, - ...linearReadForce(options) - }) -} - -export async function linearListCustomViews( - settings: RuntimeLinearSettings, - model: LinearCustomViewModel, - limit?: number, - workspaceId?: LinearWorkspaceSelection | null, - options?: LinearReadOptions -): Promise> { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc>( - target, - 'linear.listCustomViews', - { model, limit, workspaceId: workspaceId ?? undefined, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : window.api.linear.listCustomViews({ - model, - limit, - workspaceId: workspaceId ?? undefined, - ...linearReadForce(options) - }) -} - -export async function linearGetCustomView( - settings: RuntimeLinearSettings, - viewId: string, - model: LinearCustomViewModel, - workspaceId: string, - options?: LinearReadOptions -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.getCustomView', - { viewId, model, workspaceId, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : window.api.linear.getCustomView({ viewId, model, workspaceId, ...linearReadForce(options) }) -} - -export async function linearListCustomViewIssues( - settings: RuntimeLinearSettings, - viewId: string, - limit: number | undefined, - workspaceId: string, - options?: LinearReadOptions -): Promise> { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc>( - target, - 'linear.listCustomViewIssues', - { viewId, limit, workspaceId, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : window.api.linear.listCustomViewIssues({ - viewId, - limit, - workspaceId, - ...linearReadForce(options) - }) -} - -export async function linearListCustomViewProjects( - settings: RuntimeLinearSettings, - viewId: string, - limit: number | undefined, - workspaceId: string, - options?: LinearReadOptions -): Promise> { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc>( - target, - 'linear.listCustomViewProjects', - { viewId, limit, workspaceId, ...linearReadForce(options) }, - { timeoutMs: 30_000 } - ) - : window.api.linear.listCustomViewProjects({ - viewId, - limit, - workspaceId, - ...linearReadForce(options) - }) -} - -export async function linearTeamStates( - settings: RuntimeLinearSettings, - teamId: string, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.teamStates', - { teamId, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.teamStates({ teamId, workspaceId: workspaceId ?? undefined }) -} - -export async function linearTeamLabels( - settings: RuntimeLinearSettings, - teamId: string, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.teamLabels', - { teamId, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.teamLabels({ teamId, workspaceId: workspaceId ?? undefined }) -} - -export async function linearTeamMembers( - settings: RuntimeLinearSettings, - teamId: string, - workspaceId?: string | null -): Promise { - const target = getLinearRuntimeTarget(settings) - return target.kind === 'environment' - ? callRuntimeRpc( - target, - 'linear.teamMembers', - { teamId, workspaceId: workspaceId ?? undefined }, - { timeoutMs: 30_000 } - ) - : window.api.linear.teamMembers({ teamId, workspaceId: workspaceId ?? undefined }) -} diff --git a/src/renderer/src/runtime/runtime-linear-issue-mutations.ts b/src/renderer/src/runtime/runtime-linear-issue-mutations.ts new file mode 100644 index 00000000000..12b197b399f --- /dev/null +++ b/src/renderer/src/runtime/runtime-linear-issue-mutations.ts @@ -0,0 +1,113 @@ +import type { LinearIssueUpdate } from '../../../shared/issue-mutation-types' +import type { LinearComment, LinearIssue } from '../../../shared/linear/issue-types' +import { callRuntimeRpc } from './runtime-rpc-client' +import { + getLinearRuntimeTarget, + type LinearCommentResult, + type LinearCreateIssueResult, + type LinearMutationResult, + type RuntimeLinearSettings +} from './runtime-linear-client' + +export async function linearCreateIssue( + settings: RuntimeLinearSettings, + args: { + teamId: string + title: string + description?: string + workspaceId?: string + parentIssueId?: string + projectId?: string | null + stateId?: string + priority?: number + assigneeId?: string | null + labelIds?: string[] + } +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.createIssue', args, { + timeoutMs: 30_000 + }) + : window.api.linear.createIssue(args) +} + +export async function linearCreateSubIssue( + settings: RuntimeLinearSettings, + args: { + parentIssueId: string + teamId: string + title: string + description?: string + workspaceId?: string + projectId?: string | null + } +): Promise { + return linearCreateIssue(settings, args) +} + +export async function linearGetIssue( + settings: RuntimeLinearSettings, + id: string, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.getIssue', + { id, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.getIssue({ id, workspaceId: workspaceId ?? undefined }) +} + +export async function linearUpdateIssue( + settings: RuntimeLinearSettings, + id: string, + updates: LinearIssueUpdate, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.updateIssue', + { id, updates, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.updateIssue({ id, updates, workspaceId: workspaceId ?? undefined }) +} + +export async function linearAddIssueComment( + settings: RuntimeLinearSettings, + issueId: string, + body: string, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.addIssueComment', + { issueId, body, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.addIssueComment({ issueId, body, workspaceId: workspaceId ?? undefined }) +} + +export async function linearIssueComments( + settings: RuntimeLinearSettings, + issueId: string, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.issueComments', + { issueId, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.issueComments({ issueId, workspaceId: workspaceId ?? undefined }) +} diff --git a/src/renderer/src/runtime/runtime-linear-project-client.ts b/src/renderer/src/runtime/runtime-linear-project-client.ts new file mode 100644 index 00000000000..c15d615c674 --- /dev/null +++ b/src/renderer/src/runtime/runtime-linear-project-client.ts @@ -0,0 +1,266 @@ +import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { + LinearCustomViewModel, + LinearCustomViewSummary, + LinearProjectDetail, + LinearProjectSummary +} from '../../../shared/linear/project-types' +import type { + LinearCollectionResult, + LinearLabel, + LinearMember, + LinearTeam, + LinearWorkflowState, + LinearWorkspaceSelection +} from '../../../shared/linear/workspace-types' +import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds' +import { callRuntimeRpc } from './runtime-rpc-client' +import { + getLinearRuntimeTarget, + linearReadForce, + type LinearCreateProjectResult, + type LinearReadOptions, + type RuntimeLinearSettings +} from './runtime-linear-client' + +export async function linearListTeams( + settings: RuntimeLinearSettings, + workspaceId?: LinearWorkspaceSelection | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.listTeams', + workspaceId ? { workspaceId } : undefined, + { timeoutMs: 30_000 } + ) + : window.api.linear.listTeams(workspaceId ? { workspaceId } : undefined) +} + +export async function linearListProjects( + settings: RuntimeLinearSettings, + query?: string, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null, + options?: LinearReadOptions +): Promise> { + if (!isRuntimeProviderSearchQueryWithinLimit(query)) { + return { items: [] } + } + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc>( + target, + 'linear.listProjects', + { query, limit, workspaceId: workspaceId ?? undefined, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : typeof window.api.linear.listProjects === 'function' + ? window.api.linear.listProjects({ + query, + limit, + workspaceId: workspaceId ?? undefined, + ...linearReadForce(options) + }) + : { items: [] } +} + +export async function linearCreateProject( + settings: RuntimeLinearSettings, + args: { + name: string + description?: string + content?: string + teamIds: string[] + workspaceId?: string + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + } +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc(target, 'linear.createProject', args, { + timeoutMs: 30_000 + }) + : window.api.linear.createProject(args) +} + +export async function linearGetProject( + settings: RuntimeLinearSettings, + id: string, + workspaceId: string, + options?: LinearReadOptions +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.getProject', + { id, workspaceId, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : window.api.linear.getProject({ id, workspaceId, ...linearReadForce(options) }) +} + +export async function linearListProjectIssues( + settings: RuntimeLinearSettings, + projectId: string, + limit: number | undefined, + workspaceId: string, + options?: LinearReadOptions +): Promise> { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc>( + target, + 'linear.listProjectIssues', + { projectId, limit, workspaceId, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : window.api.linear.listProjectIssues({ + projectId, + limit, + workspaceId, + ...linearReadForce(options) + }) +} + +export async function linearListCustomViews( + settings: RuntimeLinearSettings, + model: LinearCustomViewModel, + limit?: number, + workspaceId?: LinearWorkspaceSelection | null, + options?: LinearReadOptions +): Promise> { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc>( + target, + 'linear.listCustomViews', + { model, limit, workspaceId: workspaceId ?? undefined, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : window.api.linear.listCustomViews({ + model, + limit, + workspaceId: workspaceId ?? undefined, + ...linearReadForce(options) + }) +} + +export async function linearGetCustomView( + settings: RuntimeLinearSettings, + viewId: string, + model: LinearCustomViewModel, + workspaceId: string, + options?: LinearReadOptions +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.getCustomView', + { viewId, model, workspaceId, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : window.api.linear.getCustomView({ viewId, model, workspaceId, ...linearReadForce(options) }) +} + +export async function linearListCustomViewIssues( + settings: RuntimeLinearSettings, + viewId: string, + limit: number | undefined, + workspaceId: string, + options?: LinearReadOptions +): Promise> { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc>( + target, + 'linear.listCustomViewIssues', + { viewId, limit, workspaceId, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : window.api.linear.listCustomViewIssues({ + viewId, + limit, + workspaceId, + ...linearReadForce(options) + }) +} + +export async function linearListCustomViewProjects( + settings: RuntimeLinearSettings, + viewId: string, + limit: number | undefined, + workspaceId: string, + options?: LinearReadOptions +): Promise> { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc>( + target, + 'linear.listCustomViewProjects', + { viewId, limit, workspaceId, ...linearReadForce(options) }, + { timeoutMs: 30_000 } + ) + : window.api.linear.listCustomViewProjects({ + viewId, + limit, + workspaceId, + ...linearReadForce(options) + }) +} + +export async function linearTeamStates( + settings: RuntimeLinearSettings, + teamId: string, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.teamStates', + { teamId, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.teamStates({ teamId, workspaceId: workspaceId ?? undefined }) +} + +export async function linearTeamLabels( + settings: RuntimeLinearSettings, + teamId: string, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.teamLabels', + { teamId, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.teamLabels({ teamId, workspaceId: workspaceId ?? undefined }) +} + +export async function linearTeamMembers( + settings: RuntimeLinearSettings, + teamId: string, + workspaceId?: string | null +): Promise { + const target = getLinearRuntimeTarget(settings) + return target.kind === 'environment' + ? callRuntimeRpc( + target, + 'linear.teamMembers', + { teamId, workspaceId: workspaceId ?? undefined }, + { timeoutMs: 30_000 } + ) + : window.api.linear.teamMembers({ teamId, workspaceId: workspaceId ?? undefined }) +} diff --git a/src/renderer/src/store/slices/diff-comment-persistence.ts b/src/renderer/src/store/slices/diff-comment-persistence.ts new file mode 100644 index 00000000000..92e1eb9ea9f --- /dev/null +++ b/src/renderer/src/store/slices/diff-comment-persistence.ts @@ -0,0 +1,322 @@ +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { DiffComment } from '../../../../shared/diff-comment-types' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { getRepoIdFromWorktreeId } from './worktree-helpers' +import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + findFolderWorkspaceOwner, + getExecutionHostIdForFolderWorkspace, + getRuntimeEnvironmentIdForFolderWorkspace +} from '@/lib/folder-workspace-runtime-owner' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' + +export function normalizeDiffComment(comment: DiffComment): DiffComment { + const rawSource = (comment as { source?: unknown }).source + const source = rawSource === 'markdown' || rawSource === 'diff' ? rawSource : undefined + const rawStartLine = (comment as { startLine?: unknown }).startLine + const startLine = + Number.isInteger(rawStartLine) && + typeof rawStartLine === 'number' && + rawStartLine >= 1 && + rawStartLine <= comment.lineNumber + ? rawStartLine + : undefined + const rawSelectedText = (comment as { selectedText?: unknown }).selectedText + const selectedText = + typeof rawSelectedText === 'string' && rawSelectedText.trim().length > 0 + ? rawSelectedText.trim() + : undefined + const rawSentAt = (comment as { sentAt?: unknown }).sentAt + const sentAt = + typeof rawSentAt === 'number' && Number.isFinite(rawSentAt) && rawSentAt > 0 + ? rawSentAt + : undefined + + return { + ...comment, + ...(source !== undefined ? { source } : {}), + ...(source === undefined ? { source: undefined } : {}), + ...(selectedText !== undefined ? { selectedText } : {}), + ...(selectedText === undefined ? { selectedText: undefined } : {}), + ...(startLine !== undefined ? { startLine } : {}), + ...(startLine === undefined ? { startLine: undefined } : {}), + ...(sentAt !== undefined ? { sentAt } : {}), + ...(sentAt === undefined ? { sentAt: undefined } : {}) + } +} + +async function persist( + state: AppState, + settings: AppState['settings'], + worktreeId: string, + diffComments: DiffComment[], + folderExecutionHostId?: ReturnType +): Promise { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const executionHostId = + folderExecutionHostId ?? getExecutionHostIdForFolderWorkspace(state, scope.folderWorkspaceId) + const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( + state, + scope.folderWorkspaceId, + executionHostId + ) + const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) + const updated = + target.kind === 'local' + ? await window.api.folderWorkspaces.update({ + folderWorkspaceId: scope.folderWorkspaceId, + updates: { diffComments } + }) + : ( + await callRuntimeRpc<{ folderWorkspace: FolderWorkspace | null }>( + target, + 'folderWorkspace.update', + { folderWorkspaceId: scope.folderWorkspaceId, updates: { diffComments } }, + { timeoutMs: 15_000 } + ) + ).folderWorkspace + if (!updated?.diffComments) { + throw new Error('Failed to persist folder workspace review notes') + } + return + } + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + await window.api.worktrees.updateMeta({ + worktreeId, + updates: { diffComments } + }) + return + } + await callRuntimeRpc( + target, + 'worktree.set', + { worktree: toRuntimeWorktreeSelector(worktreeId), diffComments }, + { timeoutMs: 15_000 } + ) +} + +function settingsForWorktreeOwner(state: AppState, worktreeId: string): AppState['settings'] { + const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } + : ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings']) +} + +// Why: IPC writes aren't ordered, so serialize per worktree to stop an older snapshot from overwriting a newer one on disk. +const persistQueueByWorktree = new Map>() + +// Why: rollback must converge to what actually reached disk; in a burst of failed writes each mutation's own predecessor is stale. +const lastPersistedByQueue = new Map() + +// Why: the floor is only valid across an unbroken mutation chain; this is how an out-of-band replacement mid-burst is detected. +const lastMutationNextByQueue = new Map() + +// Why: bumped on every re-seed, so an in-flight write can tell whether the floor it captured is still the current one. +const floorSeedEpochByQueue = new Map() + +export type DiffCommentMutation = { + previous: DiffComment[] | undefined + next: DiffComment[] + folderExecutionHostId?: ReturnType +} + +// Why: one key for the queue and both bookkeeping maps, so they can never drift apart. +function persistQueueKey( + worktreeId: string, + folderExecutionHostId?: ReturnType +): string { + return folderExecutionHostId ? `${folderExecutionHostId}\0${worktreeId}` : worktreeId +} + +// Why: chain each write onto the prior promise so writes land in call order; both then handlers keep the chain alive past a failure. +// Why: queued work reads the latest list at dequeue time, and the returned promise settles for THIS write. +// Why: this promise rejects only after this write's rollback has been applied — callers must not roll back themselves. +export function enqueueDiffCommentPersist( + set: Parameters>[0], + worktreeId: string, + get: () => AppState, + mutation: DiffCommentMutation +): Promise { + const folderExecutionHostId = mutation.folderExecutionHostId + const queueKey = persistQueueKey(worktreeId, folderExecutionHostId) + const prior = persistQueueByWorktree.get(queueKey) ?? Promise.resolve() + // Why: an idle queue means disk still holds the pre-mutation list, so that list is this burst's rollback floor. + // Why: a `previous` that isn't the last mutation's `next` means something outside the mutators replaced the list + // (hydration, folderWorkspaces refresh, remote push), so the old floor predates that state and must be re-seeded. + // Why: seed before the queue entry below, or `has` is always true and the floor is never seeded. + const chainBroken = lastMutationNextByQueue.get(queueKey) !== mutation.previous + if (!persistQueueByWorktree.has(queueKey) || chainBroken) { + lastPersistedByQueue.set(queueKey, mutation.previous) + floorSeedEpochByQueue.set(queueKey, (floorSeedEpochByQueue.get(queueKey) ?? 0) + 1) + } + lastMutationNextByQueue.set(queueKey, mutation.next) + const run = async (): Promise => { + // Why: capture at dequeue time, alongside `stateList` — an epoch read at enqueue time would bar every write + // that straddles a chain break from recording a floor its coalesced payload already carries. + const seedEpoch = floorSeedEpochByQueue.get(queueKey) + // Why: the state-side array, not the normalized copy sent to disk — restoring the same instance keeps + // `getDiffComments` identity stable instead of churning selectors. + let stateList: DiffComment[] | undefined + try { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const state = get() + const folderWorkspace = findFolderWorkspaceOwner( + state, + scope.folderWorkspaceId, + folderExecutionHostId + ) + stateList = folderWorkspace?.diffComments + await persist( + state, + state.settings, + worktreeId, + (stateList ?? []).map(normalizeDiffComment), + folderExecutionHostId + ) + } else { + const repoId = getRepoIdFromWorktreeId(worktreeId) + const target = get().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId) + stateList = target?.diffComments + const state = get() + await persist( + state, + settingsForWorktreeOwner(state, worktreeId), + worktreeId, + (stateList ?? []).map(normalizeDiffComment) + ) + } + } catch (err) { + // Why: converge to what actually landed; rollback's identity guard no-ops when a later mutation owns the array. + // Why: `has()`, not `get() ?? …` — an absent entry and a legitimately-`undefined` floor are different things. + const floor = lastPersistedByQueue.has(queueKey) + ? lastPersistedByQueue.get(queueKey) + : mutation.previous + rollback(set, worktreeId, floor, mutation.next, folderExecutionHostId) + throw err + } + // Why: a chain break re-seeded the floor to an out-of-band replacement while this write was awaiting, so the + // list captured before the await predates it and must not be reinstated as the floor. + if (floorSeedEpochByQueue.get(queueKey) === seedEpoch) { + lastPersistedByQueue.set(queueKey, stateList) + } + } + const next = prior.then(run, run) + persistQueueByWorktree.set(queueKey, next) + // Why: clear the queue entry only if still the tail, so later enqueues chain onto the real in-flight promise. + // Why: then(cleanup, cleanup) not finally, so a rejection is consumed here rather than re-thrown as unhandledRejection. + const cleanup = (): void => { + if (persistQueueByWorktree.get(queueKey) === next) { + persistQueueByWorktree.delete(queueKey) + // Why: tail-guarded only; a mid-burst delete would strand the remaining writes of the burst without a floor. + lastPersistedByQueue.delete(queueKey) + lastMutationNextByQueue.delete(queueKey) + floorSeedEpochByQueue.delete(queueKey) + } + } + next.then(cleanup, cleanup) + return next +} + +// Why: derive the next list inside the `set` updater so concurrent writes can't clobber each other via a stale closure. +export function mutateDiffComments( + set: Parameters>[0], + worktreeId: string, + mutate: (existing: DiffComment[]) => DiffComment[] | null +): DiffCommentMutation | null { + const repoId = getRepoIdFromWorktreeId(worktreeId) + let previous: DiffComment[] | undefined + let next: DiffComment[] | null = null + let folderExecutionHostId: ReturnType | undefined + set((s) => { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId) + if (!target) { + return {} + } + folderExecutionHostId = getExecutionHostIdForFolderWorkspace(s, scope.folderWorkspaceId) + previous = target.diffComments + const computed = mutate(previous ?? []) + if (computed === null) { + return {} + } + next = computed + return { + folderWorkspaces: s.folderWorkspaces.map((workspace) => + workspace === target ? { ...workspace, diffComments: computed } : workspace + ) + } + } + const repoList = s.worktreesByRepo[repoId] + if (!repoList) { + return {} + } + const target = repoList.find((w) => w.id === worktreeId) + if (!target) { + return {} + } + previous = target.diffComments + const computed = mutate(previous ?? []) + if (computed === null) { + return {} + } + next = computed + const nextList: Worktree[] = repoList.map((w) => + w.id === worktreeId ? { ...w, diffComments: computed } : w + ) + return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } } + }) + if (next === null) { + return null + } + return { previous, next, folderExecutionHostId } +} + +// Why: on IPC-write failure, roll back optimistic state so the renderer matches disk (identity-guarded below). +function rollback( + set: Parameters>[0], + worktreeId: string, + previous: DiffComment[] | undefined, + expectedCurrent: DiffComment[], + folderExecutionHostId?: ReturnType +): void { + const repoId = getRepoIdFromWorktreeId(worktreeId) + set((s) => { + const scope = parseWorkspaceKey(worktreeId) + if (scope?.type === 'folder') { + const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId, folderExecutionHostId) + if (!target || target.diffComments !== expectedCurrent) { + return {} + } + return { + folderWorkspaces: s.folderWorkspaces.map((workspace) => + workspace === target ? { ...workspace, diffComments: previous } : workspace + ) + } + } + const repoList = s.worktreesByRepo[repoId] + if (!repoList) { + return {} + } + const target = repoList.find((w) => w.id === worktreeId) + // Why: worktree gone since the mutation; bail before remapping so we don't allocate a new array identity and fire spurious notifications. + if (!target) { + return {} + } + // Why: only roll back if no later mutation replaced the array, else our stale `previous` would erase newer state. + if (target.diffComments !== expectedCurrent) { + return {} + } + const nextList: Worktree[] = repoList.map((w) => + w.id === worktreeId ? { ...w, diffComments: previous } : w + ) + return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } } + }) +} diff --git a/src/renderer/src/store/slices/diffComments.ts b/src/renderer/src/store/slices/diffComments.ts index fb4a1cb2e9e..b11615be936 100644 --- a/src/renderer/src/store/slices/diffComments.ts +++ b/src/renderer/src/store/slices/diffComments.ts @@ -1,20 +1,15 @@ -/* eslint-disable max-lines -- Why: keeps note mutation, rollback, persistence ordering, and sent-state transitions under shared queue/rollback invariants. */ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { DiffComment } from '../../../../shared/diff-comment-types' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { Worktree } from '../../../../shared/worktree/types' -import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' -import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { findWorktreeById } from './worktree-helpers' import { createBrowserUuid } from '@/lib/browser-uuid' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' -import { - findFolderWorkspaceOwner, - getExecutionHostIdForFolderWorkspace, - getRuntimeEnvironmentIdForFolderWorkspace -} from '@/lib/folder-workspace-runtime-owner' +import { findFolderWorkspaceOwner } from '@/lib/folder-workspace-runtime-owner' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { + enqueueDiffCommentPersist, + mutateDiffComments, + normalizeDiffComment +} from './diff-comment-persistence' export type DiffCommentsSlice = { getDiffComments: (worktreeId: string | null | undefined) => DiffComment[] @@ -43,41 +38,6 @@ function generateId(): string { return createBrowserUuid() } -function normalizeDiffComment(comment: DiffComment): DiffComment { - const rawSource = (comment as { source?: unknown }).source - const source = rawSource === 'markdown' || rawSource === 'diff' ? rawSource : undefined - const rawStartLine = (comment as { startLine?: unknown }).startLine - const startLine = - Number.isInteger(rawStartLine) && - typeof rawStartLine === 'number' && - rawStartLine >= 1 && - rawStartLine <= comment.lineNumber - ? rawStartLine - : undefined - const rawSelectedText = (comment as { selectedText?: unknown }).selectedText - const selectedText = - typeof rawSelectedText === 'string' && rawSelectedText.trim().length > 0 - ? rawSelectedText.trim() - : undefined - const rawSentAt = (comment as { sentAt?: unknown }).sentAt - const sentAt = - typeof rawSentAt === 'number' && Number.isFinite(rawSentAt) && rawSentAt > 0 - ? rawSentAt - : undefined - - return { - ...comment, - ...(source !== undefined ? { source } : {}), - ...(source === undefined ? { source: undefined } : {}), - ...(selectedText !== undefined ? { selectedText } : {}), - ...(selectedText === undefined ? { selectedText: undefined } : {}), - ...(startLine !== undefined ? { startLine } : {}), - ...(startLine === undefined ? { startLine: undefined } : {}), - ...(sentAt !== undefined ? { sentAt } : {}), - ...(sentAt === undefined ? { sentAt: undefined } : {}) - } -} - function deliverySnapshotMatches( comment: DiffComment, snapshot: DiffCommentDeliverySnapshot @@ -96,181 +56,6 @@ function deliverySnapshotMatches( // Why: a frozen shared sentinel avoids selector re-renders and mutation. const EMPTY_COMMENTS: readonly DiffComment[] = Object.freeze([]) -async function persist( - state: AppState, - settings: AppState['settings'], - worktreeId: string, - diffComments: DiffComment[], - folderExecutionHostId?: ReturnType -): Promise { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const executionHostId = - folderExecutionHostId ?? getExecutionHostIdForFolderWorkspace(state, scope.folderWorkspaceId) - const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace( - state, - scope.folderWorkspaceId, - executionHostId - ) - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) - const updated = - target.kind === 'local' - ? await window.api.folderWorkspaces.update({ - folderWorkspaceId: scope.folderWorkspaceId, - updates: { diffComments } - }) - : ( - await callRuntimeRpc<{ folderWorkspace: FolderWorkspace | null }>( - target, - 'folderWorkspace.update', - { folderWorkspaceId: scope.folderWorkspaceId, updates: { diffComments } }, - { timeoutMs: 15_000 } - ) - ).folderWorkspace - if (!updated?.diffComments) { - throw new Error('Failed to persist folder workspace review notes') - } - return - } - const target = getActiveRuntimeTarget(settings) - if (target.kind === 'local') { - await window.api.worktrees.updateMeta({ - worktreeId, - updates: { diffComments } - }) - return - } - await callRuntimeRpc( - target, - 'worktree.set', - { worktree: toRuntimeWorktreeSelector(worktreeId), diffComments }, - { timeoutMs: 15_000 } - ) -} - -function settingsForWorktreeOwner(state: AppState, worktreeId: string): AppState['settings'] { - const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: runtimeEnvironmentId } - : ({ activeRuntimeEnvironmentId: runtimeEnvironmentId } as AppState['settings']) -} - -// Why: IPC writes aren't ordered, so serialize per worktree to stop an older snapshot from overwriting a newer one on disk. -const persistQueueByWorktree = new Map>() - -// Why: rollback must converge to what actually reached disk; in a burst of failed writes each mutation's own predecessor is stale. -const lastPersistedByQueue = new Map() - -// Why: the floor is only valid across an unbroken mutation chain; this is how an out-of-band replacement mid-burst is detected. -const lastMutationNextByQueue = new Map() - -// Why: bumped on every re-seed, so an in-flight write can tell whether the floor it captured is still the current one. -const floorSeedEpochByQueue = new Map() - -type CommentMutation = { - previous: DiffComment[] | undefined - next: DiffComment[] - folderExecutionHostId?: ReturnType -} - -// Why: one key for the queue and both bookkeeping maps, so they can never drift apart. -function persistQueueKey( - worktreeId: string, - folderExecutionHostId?: ReturnType -): string { - return folderExecutionHostId ? `${folderExecutionHostId}\0${worktreeId}` : worktreeId -} - -// Why: chain each write onto the prior promise so writes land in call order; both then handlers keep the chain alive past a failure. -// Why: queued work reads the latest list at dequeue time, and the returned promise settles for THIS write. -// Why: this promise rejects only after this write's rollback has been applied — callers must not roll back themselves. -function enqueuePersist( - set: Parameters>[0], - worktreeId: string, - get: () => AppState, - mutation: CommentMutation -): Promise { - const folderExecutionHostId = mutation.folderExecutionHostId - const queueKey = persistQueueKey(worktreeId, folderExecutionHostId) - const prior = persistQueueByWorktree.get(queueKey) ?? Promise.resolve() - // Why: an idle queue means disk still holds the pre-mutation list, so that list is this burst's rollback floor. - // Why: a `previous` that isn't the last mutation's `next` means something outside the mutators replaced the list - // (hydration, folderWorkspaces refresh, remote push), so the old floor predates that state and must be re-seeded. - // Why: seed before the queue entry below, or `has` is always true and the floor is never seeded. - const chainBroken = lastMutationNextByQueue.get(queueKey) !== mutation.previous - if (!persistQueueByWorktree.has(queueKey) || chainBroken) { - lastPersistedByQueue.set(queueKey, mutation.previous) - floorSeedEpochByQueue.set(queueKey, (floorSeedEpochByQueue.get(queueKey) ?? 0) + 1) - } - lastMutationNextByQueue.set(queueKey, mutation.next) - const run = async (): Promise => { - // Why: capture at dequeue time, alongside `stateList` — an epoch read at enqueue time would bar every write - // that straddles a chain break from recording a floor its coalesced payload already carries. - const seedEpoch = floorSeedEpochByQueue.get(queueKey) - // Why: the state-side array, not the normalized copy sent to disk — restoring the same instance keeps - // `getDiffComments` identity stable instead of churning selectors. - let stateList: DiffComment[] | undefined - try { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const state = get() - const folderWorkspace = findFolderWorkspaceOwner( - state, - scope.folderWorkspaceId, - folderExecutionHostId - ) - stateList = folderWorkspace?.diffComments - await persist( - state, - state.settings, - worktreeId, - (stateList ?? []).map(normalizeDiffComment), - folderExecutionHostId - ) - } else { - const repoId = getRepoIdFromWorktreeId(worktreeId) - const target = get().worktreesByRepo[repoId]?.find((w) => w.id === worktreeId) - stateList = target?.diffComments - const state = get() - await persist( - state, - settingsForWorktreeOwner(state, worktreeId), - worktreeId, - (stateList ?? []).map(normalizeDiffComment) - ) - } - } catch (err) { - // Why: converge to what actually landed; rollback's identity guard no-ops when a later mutation owns the array. - // Why: `has()`, not `get() ?? …` — an absent entry and a legitimately-`undefined` floor are different things. - const floor = lastPersistedByQueue.has(queueKey) - ? lastPersistedByQueue.get(queueKey) - : mutation.previous - rollback(set, worktreeId, floor, mutation.next, folderExecutionHostId) - throw err - } - // Why: a chain break re-seeded the floor to an out-of-band replacement while this write was awaiting, so the - // list captured before the await predates it and must not be reinstated as the floor. - if (floorSeedEpochByQueue.get(queueKey) === seedEpoch) { - lastPersistedByQueue.set(queueKey, stateList) - } - } - const next = prior.then(run, run) - persistQueueByWorktree.set(queueKey, next) - // Why: clear the queue entry only if still the tail, so later enqueues chain onto the real in-flight promise. - // Why: then(cleanup, cleanup) not finally, so a rejection is consumed here rather than re-thrown as unhandledRejection. - const cleanup = (): void => { - if (persistQueueByWorktree.get(queueKey) === next) { - persistQueueByWorktree.delete(queueKey) - // Why: tail-guarded only; a mid-burst delete would strand the remaining writes of the burst without a floor. - lastPersistedByQueue.delete(queueKey) - lastMutationNextByQueue.delete(queueKey) - floorSeedEpochByQueue.delete(queueKey) - } - } - next.then(cleanup, cleanup) - return next -} - // Why: best-effort telemetry runs only after the note is on disk, so a throw here must not report a failed save. function recordReviewNoteInteraction(get: () => AppState): void { try { @@ -280,103 +65,6 @@ function recordReviewNoteInteraction(get: () => AppState): void { } } -// Why: derive the next list inside the `set` updater so concurrent writes can't clobber each other via a stale closure. -function mutateComments( - set: Parameters>[0], - worktreeId: string, - mutate: (existing: DiffComment[]) => DiffComment[] | null -): CommentMutation | null { - const repoId = getRepoIdFromWorktreeId(worktreeId) - let previous: DiffComment[] | undefined - let next: DiffComment[] | null = null - let folderExecutionHostId: ReturnType | undefined - set((s) => { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId) - if (!target) { - return {} - } - folderExecutionHostId = getExecutionHostIdForFolderWorkspace(s, scope.folderWorkspaceId) - previous = target.diffComments - const computed = mutate(previous ?? []) - if (computed === null) { - return {} - } - next = computed - return { - folderWorkspaces: s.folderWorkspaces.map((workspace) => - workspace === target ? { ...workspace, diffComments: computed } : workspace - ) - } - } - const repoList = s.worktreesByRepo[repoId] - if (!repoList) { - return {} - } - const target = repoList.find((w) => w.id === worktreeId) - if (!target) { - return {} - } - previous = target.diffComments - const computed = mutate(previous ?? []) - if (computed === null) { - return {} - } - next = computed - const nextList: Worktree[] = repoList.map((w) => - w.id === worktreeId ? { ...w, diffComments: computed } : w - ) - return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } } - }) - if (next === null) { - return null - } - return { previous, next, folderExecutionHostId } -} - -// Why: on IPC-write failure, roll back optimistic state so the renderer matches disk (identity-guarded below). -function rollback( - set: Parameters>[0], - worktreeId: string, - previous: DiffComment[] | undefined, - expectedCurrent: DiffComment[], - folderExecutionHostId?: ReturnType -): void { - const repoId = getRepoIdFromWorktreeId(worktreeId) - set((s) => { - const scope = parseWorkspaceKey(worktreeId) - if (scope?.type === 'folder') { - const target = findFolderWorkspaceOwner(s, scope.folderWorkspaceId, folderExecutionHostId) - if (!target || target.diffComments !== expectedCurrent) { - return {} - } - return { - folderWorkspaces: s.folderWorkspaces.map((workspace) => - workspace === target ? { ...workspace, diffComments: previous } : workspace - ) - } - } - const repoList = s.worktreesByRepo[repoId] - if (!repoList) { - return {} - } - const target = repoList.find((w) => w.id === worktreeId) - // Why: worktree gone since the mutation; bail before remapping so we don't allocate a new array identity and fire spurious notifications. - if (!target) { - return {} - } - // Why: only roll back if no later mutation replaced the array, else our stale `previous` would erase newer state. - if (target.diffComments !== expectedCurrent) { - return {} - } - const nextList: Worktree[] = repoList.map((w) => - w.id === worktreeId ? { ...w, diffComments: previous } : w - ) - return { worktreesByRepo: { ...s.worktreesByRepo, [repoId]: nextList } } - }) -} - export const createDiffCommentsSlice: StateCreator = ( set, get @@ -404,13 +92,13 @@ export const createDiffCommentsSlice: StateCreator [...existing, comment]) + const result = mutateDiffComments(set, input.worktreeId, (existing) => [...existing, comment]) if (!result) { return null } try { // Why: serialize through the per-worktree queue so concurrent writes can't land on disk out of call order. - await enqueuePersist(set, input.worktreeId, get, result) + await enqueueDiffCommentPersist(set, input.worktreeId, get, result) } catch (err) { console.error('Failed to persist diff comments:', err) return null @@ -436,7 +124,7 @@ export const createDiffCommentsSlice: StateCreator { + const result = mutateDiffComments(set, worktreeId, (current) => { const idx = current.findIndex((c) => c.id === commentId) if (idx === -1) { return null @@ -454,7 +142,7 @@ export const createDiffCommentsSlice: StateCreator [comment.id, comment])) - const result = mutateComments(set, worktreeId, (existing) => { + const result = mutateDiffComments(set, worktreeId, (existing) => { const next = existing.filter((comment) => { const snapshot = snapshotsById.get(comment.id) // Why: delivery is async; a note edited after its snapshot was sent is a fresh pending note that must stay visible. @@ -479,7 +167,7 @@ export const createDiffCommentsSlice: StateCreator { + const result = mutateDiffComments(set, worktreeId, (existing) => { let changed = false const next = existing.map((comment) => { if (!ids.has(comment.id) || comment.sentAt === sentAt) { @@ -508,7 +196,7 @@ export const createDiffCommentsSlice: StateCreator { - const result = mutateComments(set, worktreeId, (existing) => { + const result = mutateDiffComments(set, worktreeId, (existing) => { const next = existing.filter((c) => c.id !== commentId) return next.length === existing.length ? null : next }) @@ -527,21 +215,21 @@ export const createDiffCommentsSlice: StateCreator { - const result = mutateComments(set, worktreeId, (existing) => + const result = mutateDiffComments(set, worktreeId, (existing) => existing.length === 0 ? null : [] ) if (!result) { return true } try { - await enqueuePersist(set, worktreeId, get, result) + await enqueueDiffCommentPersist(set, worktreeId, get, result) return true } catch (err) { console.error('Failed to persist diff comments:', err) @@ -550,7 +238,7 @@ export const createDiffCommentsSlice: StateCreator { - const result = mutateComments(set, worktreeId, (existing) => { + const result = mutateDiffComments(set, worktreeId, (existing) => { const next = existing.filter((c) => c.filePath !== filePath) return next.length === existing.length ? null : next }) @@ -558,7 +246,7 @@ export const createDiffCommentsSlice: StateCreator ({ diff --git a/src/renderer/src/store/slices/hosted-review-cache-state.ts b/src/renderer/src/store/slices/hosted-review-cache-state.ts new file mode 100644 index 00000000000..1a5153c6021 --- /dev/null +++ b/src/renderer/src/store/slices/hosted-review-cache-state.ts @@ -0,0 +1,207 @@ +import type { + CreateHostedReviewInput, + CreateStackedHostedReviewInput, + HostedReviewCreationEligibility, + HostedReviewInfo +} from '../../../../shared/hosted-review' +import type { Repo } from '../../../../shared/repo-types' +import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' +import type { AppState } from '../types' + +export type HostedReviewCacheEntry = { + data: T | null + fetchedAt: number + linkedReviewHintKey?: string + branchLookupGitHubPRNumber?: number +} +export type HostedReviewCache = Record> + +export type HostedReviewFetchOptions = { + force?: boolean + repoId?: string + staleWhileRevalidate?: boolean + currentHeadOid?: string | null + /** + * Pass from surfaces that only render the selected worktree. The host re-checks + * that branch per minute and paces the O(N) card list far slower (#11532). + */ + active?: boolean +} +export type CreateHostedReviewStoreInput = CreateHostedReviewInput & { repoId?: string | null } +export type CreateStackedHostedReviewStoreInput = CreateStackedHostedReviewInput & { + repoId?: string | null +} + +const CACHE_TTL_MS = 60_000 +const HOSTED_REVIEW_CACHE_MAX = 500 +// Why: the runtime path is bounded by callRuntimeRpc's own timeout; the local +// Electron path had none, so a hung git/gh subprocess (e.g. a stalled Windows +// credential probe) could leave the Create PR header stuck in its "Checking…" +// loading state forever. Mirror the runtime bound so a never-settling probe +// rejects and the UI can fall back to an actionable/retryable state. +const CREATION_ELIGIBILITY_TIMEOUT_MS = 30_000 + +export class HostedReviewCreationEligibilityTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Timed out checking pull request creation eligibility after ${timeoutMs}ms`) + this.name = 'HostedReviewCreationEligibilityTimeoutError' + } +} + +export function withCreationEligibilityTimeout( + promise: Promise, + timeoutMs = CREATION_ELIGIBILITY_TIMEOUT_MS +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new HostedReviewCreationEligibilityTimeoutError(timeoutMs)) + }, timeoutMs) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error) => { + clearTimeout(timer) + reject(error) + } + ) + }) +} + +export function isFreshHostedReview( + entry: HostedReviewCacheEntry | undefined +): entry is HostedReviewCacheEntry { + return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS +} + +export function findHostedReviewRepoByPath( + repos: readonly Repo[] | undefined, + repoPath: string, + repoId?: string | null +): Repo | undefined { + return repos?.find((candidate) => + repoId ? candidate.id === repoId : candidate.path === repoPath + ) +} + +export function shouldRefetchForLinkedHint( + cached: HostedReviewCacheEntry | undefined, + hintKey: string +): boolean { + return cached !== undefined && hintKey !== '' && (cached.linkedReviewHintKey ?? '') !== hintKey +} + +function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean { + return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false +} + +export function shouldRefetchGitHubScopedResultForNoHint( + cached: HostedReviewCacheEntry | undefined, + hintKey: string +): boolean { + // Why: a GitHub-scoped result does not prove the branch's publishing remote + // has no GitLab/other review for neutral lookup. + return ( + cached !== undefined && + hintKey === '' && + isGitHubLinkedReviewHintKey(cached.linkedReviewHintKey) + ) +} + +export function canReuseInflightHint(inflightHintKey: string, nextHintKey: string): boolean { + return inflightHintKey === nextHintKey +} + +export function isStaleMergedGitHubReviewForHead( + cached: HostedReviewCacheEntry | undefined, + currentHeadOid: string | null | undefined +): boolean { + // Why: a merged GitHub PR is only shown when the worktree sits on its head + // or on a commit confirmed to be part of the PR. The cache key is + // branch-scoped, so a worktree that advanced off the merged line of work + // must not reuse (or, on failure, preserve) the now-stale merged review. + const head = typeof currentHeadOid === 'string' ? currentHeadOid.trim() : '' + if (head.length === 0) { + return false + } + const data = cached?.data + return ( + data?.provider === 'github' && + data.state === 'merged' && + typeof data.headSha === 'string' && + data.headSha.length > 0 && + data.headSha !== head && + data.confirmedContainedHeadOid !== head + ) +} + +export function hasNewerHostedReviewCacheEntry( + cache: HostedReviewCache, + cacheKey: string, + requestStartedAt: number, + requestStartedEntry: HostedReviewCacheEntry | undefined +): boolean { + // Why: GitHub refresh events can update this shared cache while a branch + // lookup is in flight; older lookups must not resurrect stale results. + const entry = cache[cacheKey] + return ( + entry !== undefined && + (entry.fetchedAt > requestStartedAt || + (entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry)) + ) +} + +export function withHostedReviewCacheEntry( + cache: HostedReviewCache, + cacheKey: string, + entry: HostedReviewCacheEntry +): HostedReviewCache { + const next = { ...cache, [cacheKey]: entry } + const keys = Object.keys(next) + if (keys.length <= HOSTED_REVIEW_CACHE_MAX) { + return next + } + const keep = new Set( + keys + .map((key) => ({ key, fetchedAt: next[key].fetchedAt })) + .sort((a, b) => b.fetchedAt - a.fetchedAt) + .slice(0, HOSTED_REVIEW_CACHE_MAX) + .map((item) => item.key) + ) + const pruned: HostedReviewCache = {} + for (const key of keep) { + pruned[key] = next[key] + } + return pruned +} + +export function settingsForHostedReviewRepoOwner( + settings: AppState['settings'], + repo: Pick | undefined +): AppState['settings'] { + if (!repo) { + return settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return settings + ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + // Why: local and SSH-owned reviews are served by the desktop client's local + // IPC path, even when the sidebar is focused on a runtime host. + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + +export function settingsForHostedReviewActionOwner( + settings: AppState['settings'], + repo: Pick | undefined +): AppState['settings'] { + if (!repo?.executionHostId && !repo?.connectionId) { + return settings + } + return settingsForHostedReviewRepoOwner(settings, repo) +} diff --git a/src/renderer/src/store/slices/hosted-review-cache.test.ts b/src/renderer/src/store/slices/hosted-review-cache.test.ts index 163dfbd3332..473ac657441 100644 --- a/src/renderer/src/store/slices/hosted-review-cache.test.ts +++ b/src/renderer/src/store/slices/hosted-review-cache.test.ts @@ -1,12 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' +import { createHostedReviewSlice } from './hosted-review' import { _clearHostedReviewRequestGenerationsForTest, - _getHostedReviewRequestGenerationCountForTest, - createHostedReviewSlice, - getHostedReviewCacheKey -} from './hosted-review' + _getHostedReviewRequestGenerationCountForTest +} from './hosted-review-request-state' +import { getHostedReviewCacheKey } from './hosted-review-cache-identity' import type { HostedReviewInfo } from '../../../../shared/hosted-review' const runtimeRpc = vi.hoisted(() => ({ diff --git a/src/renderer/src/store/slices/hosted-review-card-refresh.ts b/src/renderer/src/store/slices/hosted-review-card-refresh.ts new file mode 100644 index 00000000000..3ad33722f4a --- /dev/null +++ b/src/renderer/src/store/slices/hosted-review-card-refresh.ts @@ -0,0 +1,38 @@ +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { LinkedReviewHints } from './hosted-review-cache-identity' +import type { HostedReviewFetchOptions } from './hosted-review-cache-state' + +type FetchHostedReviewForBranch = ( + repoPath: string, + branch: string, + options?: HostedReviewFetchOptions & LinkedReviewHints +) => Promise + +type RefreshHostedReviewCardArgs = { + repoPath: string + repoId: string + branch: string + linkedGitHubPR?: number | null + fallbackGitHubPR?: number | null + linkedGitLabMR?: number | null + linkedBitbucketPR?: number | null + linkedAzureDevOpsPR?: number | null + linkedGiteaPR?: number | null +} + +export function refreshHostedReviewCard( + fetchHostedReviewForBranch: FetchHostedReviewForBranch, + args: RefreshHostedReviewCardArgs +): Promise { + const fallbackGitHubPR = args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null + return fetchHostedReviewForBranch(args.repoPath, args.branch, { + force: true, + repoId: args.repoId, + linkedGitHubPR: args.linkedGitHubPR ?? null, + ...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}), + linkedGitLabMR: args.linkedGitLabMR ?? null, + linkedBitbucketPR: args.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: args.linkedGiteaPR ?? null + }) +} diff --git a/src/renderer/src/store/slices/hosted-review-pr-cache.ts b/src/renderer/src/store/slices/hosted-review-pr-cache.ts new file mode 100644 index 00000000000..b58d3e01994 --- /dev/null +++ b/src/renderer/src/store/slices/hosted-review-pr-cache.ts @@ -0,0 +1,39 @@ +import type { HostedReviewInfo } from '../../../../shared/hosted-review' +import type { Repo } from '../../../../shared/repo-types' +import type { AppState } from '../types' +import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' + +export function clearHostedReviewConflictingPrCache(args: { + cache: NonNullable + review: HostedReviewInfo | null + repoPath: string + repoId: string | undefined + branch: string + settings: AppState['settings'] + repo: Repo | undefined +}): NonNullable { + if (!args.review || args.review.provider === 'github') { + return args.cache + } + const keys = [ + getGitHubPRCacheKey( + args.repoPath, + args.repoId, + args.branch, + args.settings, + args.repo?.connectionId, + args.repo?.executionHostId, + args.repo !== undefined + ), + getLegacyGitHubPRCacheKey(args.repoPath, args.repoId, args.branch), + getLegacyGitHubPRCacheKey(args.repoPath, undefined, args.branch) + ] + if (!keys.some((key) => args.cache[key])) { + return args.cache + } + const next = { ...args.cache } + for (const key of keys) { + delete next[key] + } + return next +} diff --git a/src/renderer/src/store/slices/hosted-review-request-state.ts b/src/renderer/src/store/slices/hosted-review-request-state.ts new file mode 100644 index 00000000000..5660bb6918c --- /dev/null +++ b/src/renderer/src/store/slices/hosted-review-request-state.ts @@ -0,0 +1,23 @@ +import type { HostedReviewInfo } from '../../../../shared/hosted-review' + +export const inflightHostedReviewRequests = new Map< + string, + { + promise: Promise + force: boolean + generation: number + linkedReviewHintKey: string + } +>() + +export const hostedReviewRequestGenerations = new Map() + +/** @internal - exposed for leak-regression tests only */ +export function _getHostedReviewRequestGenerationCountForTest(): number { + return hostedReviewRequestGenerations.size +} + +/** @internal - exposed for leak-regression tests only */ +export function _clearHostedReviewRequestGenerationsForTest(): void { + hostedReviewRequestGenerations.clear() +} diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index c438c2e8ebc..17d8cb20e2e 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -1,12 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' -import { - createHostedReviewSlice, - getHostedReviewCacheKey, - HostedReviewCreationEligibilityTimeoutError, - refreshHostedReviewCard -} from './hosted-review' +import { createHostedReviewSlice } from './hosted-review' +import { refreshHostedReviewCard } from './hosted-review-card-refresh' +import { getHostedReviewCacheKey } from './hosted-review-cache-identity' +import { HostedReviewCreationEligibilityTimeoutError } from './hosted-review-cache-state' import type { HostedReviewInfo } from '../../../../shared/hosted-review' const runtimeRpc = vi.hoisted(() => ({ diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 3c3940b6acc..8b26f2c9362 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -1,16 +1,11 @@ -/* eslint-disable max-lines -- Why: hosted-review cache identity, runtime dispatch, -and race protection are kept together so branch review lookup invariants stay testable. */ import type { StateCreator } from 'zustand' import type { - CreateHostedReviewInput, CreateHostedReviewResult, - CreateStackedHostedReviewInput, CreateStackedHostedReviewResult, HostedReviewCreationEligibility, HostedReviewCreationEligibilityArgs, HostedReviewInfo } from '../../../../shared/hosted-review' -import type { Repo } from '../../../../shared/repo-types' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { AppState } from '../types' import { @@ -18,228 +13,31 @@ import { linkedReviewHintKey, type LinkedReviewHints } from './hosted-review-cache-identity' -import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host' - -export { getHostedReviewCacheKey, linkedReviewHintKey } from './hosted-review-cache-identity' - -type CacheEntry = { - data: T | null - fetchedAt: number - linkedReviewHintKey?: string - branchLookupGitHubPRNumber?: number -} -type FetchOptions = { - force?: boolean - repoId?: string - staleWhileRevalidate?: boolean - currentHeadOid?: string | null - /** - * Pass from surfaces that only render the selected worktree. The host re-checks - * that branch per minute and paces the O(N) card list far slower (#11532). - */ - active?: boolean -} -type CreateHostedReviewStoreInput = CreateHostedReviewInput & { repoId?: string | null } -type CreateStackedHostedReviewStoreInput = CreateStackedHostedReviewInput & { - repoId?: string | null -} - -const CACHE_TTL_MS = 60_000 -const HOSTED_REVIEW_CACHE_MAX = 500 -// Why: the runtime path is bounded by callRuntimeRpc's own timeout; the local -// Electron path had none, so a hung git/gh subprocess (e.g. a stalled Windows -// credential probe) could leave the Create PR header stuck in its "Checking…" -// loading state forever. Mirror the runtime bound so a never-settling probe -// rejects and the UI can fall back to an actionable/retryable state. -const CREATION_ELIGIBILITY_TIMEOUT_MS = 30_000 - -export class HostedReviewCreationEligibilityTimeoutError extends Error { - constructor(timeoutMs: number) { - super(`Timed out checking pull request creation eligibility after ${timeoutMs}ms`) - this.name = 'HostedReviewCreationEligibilityTimeoutError' - } -} - -function withCreationEligibilityTimeout( - promise: Promise, - timeoutMs = CREATION_ELIGIBILITY_TIMEOUT_MS -): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new HostedReviewCreationEligibilityTimeoutError(timeoutMs)) - }, timeoutMs) - promise.then( - (value) => { - clearTimeout(timer) - resolve(value) - }, - (error) => { - clearTimeout(timer) - reject(error) - } - ) - }) -} - -const inflightHostedReviewRequests = new Map< - string, - { - promise: Promise - force: boolean - generation: number - linkedReviewHintKey: string - } ->() -const requestGenerations = new Map() - -/** @internal - exposed for leak-regression tests only */ -export function _getHostedReviewRequestGenerationCountForTest(): number { - return requestGenerations.size -} - -/** @internal - exposed for leak-regression tests only */ -export function _clearHostedReviewRequestGenerationsForTest(): void { - requestGenerations.clear() -} - -function isFresh(entry: CacheEntry | undefined): entry is CacheEntry { - return entry !== undefined && Date.now() - entry.fetchedAt < CACHE_TTL_MS -} - -function findHostedReviewRepoByPath( - repos: readonly Repo[] | undefined, - repoPath: string, - repoId?: string | null -): Repo | undefined { - return repos?.find((candidate) => - repoId ? candidate.id === repoId : candidate.path === repoPath - ) -} - -function shouldRefetchForLinkedHint( - cached: CacheEntry | undefined, - hintKey: string -): boolean { - return cached !== undefined && hintKey !== '' && (cached.linkedReviewHintKey ?? '') !== hintKey -} - -function isGitHubLinkedReviewHintKey(hintKey: string | undefined): boolean { - return hintKey?.split('|').some((key) => key.startsWith('github:')) ?? false -} - -function shouldRefetchGitHubScopedResultForNoHint( - cached: CacheEntry | undefined, - hintKey: string -): boolean { - // Why: a GitHub-scoped result does not prove the branch's publishing remote - // has no GitLab/other review for neutral lookup. - return ( - cached !== undefined && - hintKey === '' && - isGitHubLinkedReviewHintKey(cached.linkedReviewHintKey) - ) -} - -function canReuseInflightHint(inflightHintKey: string, nextHintKey: string): boolean { - return inflightHintKey === nextHintKey -} - -function isStaleMergedGitHubReviewForHead( - cached: CacheEntry | undefined, - currentHeadOid: string | null | undefined -): boolean { - // Why: a merged GitHub PR is only shown when the worktree sits on its head - // or on a commit confirmed to be part of the PR. The cache key is - // branch-scoped, so a worktree that advanced off the merged line of work - // must not reuse (or, on failure, preserve) the now-stale merged review. - const head = typeof currentHeadOid === 'string' ? currentHeadOid.trim() : '' - if (head.length === 0) { - return false - } - const data = cached?.data - return ( - data?.provider === 'github' && - data.state === 'merged' && - typeof data.headSha === 'string' && - data.headSha.length > 0 && - data.headSha !== head && - data.confirmedContainedHeadOid !== head - ) -} - -function hasNewerHostedReviewCacheEntry( - cache: HostedReviewSlice['hostedReviewCache'], - cacheKey: string, - requestStartedAt: number, - requestStartedEntry: CacheEntry | undefined -): boolean { - // Why: GitHub refresh events can update this shared cache while a branch - // lookup is in flight; older lookups must not resurrect stale results. - const entry = cache[cacheKey] - return ( - entry !== undefined && - (entry.fetchedAt > requestStartedAt || - (entry.fetchedAt === requestStartedAt && entry !== requestStartedEntry)) - ) -} - -function withHostedReviewCacheEntry( - cache: HostedReviewSlice['hostedReviewCache'], - cacheKey: string, - entry: CacheEntry -): HostedReviewSlice['hostedReviewCache'] { - const next = { ...cache, [cacheKey]: entry } - const keys = Object.keys(next) - if (keys.length <= HOSTED_REVIEW_CACHE_MAX) { - return next - } - const keep = new Set( - keys - .map((key) => ({ key, fetchedAt: next[key].fetchedAt })) - .sort((a, b) => b.fetchedAt - a.fetchedAt) - .slice(0, HOSTED_REVIEW_CACHE_MAX) - .map((item) => item.key) - ) - const pruned: HostedReviewSlice['hostedReviewCache'] = {} - for (const key of keep) { - pruned[key] = next[key] - } - return pruned -} - -function settingsForHostedReviewRepoOwner( - settings: AppState['settings'], - repo: Pick | undefined -): AppState['settings'] { - if (!repo) { - return settings - } - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (parsed?.kind === 'runtime') { - return settings - ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } - : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) - } - // Why: local and SSH-owned reviews are served by the desktop client's local - // IPC path, even when the sidebar is focused on a runtime host. - return settings - ? { ...settings, activeRuntimeEnvironmentId: null } - : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) -} - -function settingsForHostedReviewActionOwner( - settings: AppState['settings'], - repo: Pick | undefined -): AppState['settings'] { - if (!repo?.executionHostId && !repo?.connectionId) { - return settings - } - return settingsForHostedReviewRepoOwner(settings, repo) -} +import { + canReuseInflightHint, + findHostedReviewRepoByPath, + hasNewerHostedReviewCacheEntry, + isFreshHostedReview, + isStaleMergedGitHubReviewForHead, + settingsForHostedReviewActionOwner, + settingsForHostedReviewRepoOwner, + shouldRefetchForLinkedHint, + shouldRefetchGitHubScopedResultForNoHint, + withCreationEligibilityTimeout, + withHostedReviewCacheEntry, + type CreateHostedReviewStoreInput, + type CreateStackedHostedReviewStoreInput, + type HostedReviewCacheEntry, + type HostedReviewFetchOptions +} from './hosted-review-cache-state' +import { clearHostedReviewConflictingPrCache } from './hosted-review-pr-cache' +import { + hostedReviewRequestGenerations as requestGenerations, + inflightHostedReviewRequests +} from './hosted-review-request-state' export type HostedReviewSlice = { - hostedReviewCache: Record> + hostedReviewCache: Record> getHostedReviewCreationEligibility: ( args: HostedReviewCreationEligibilityArgs ) => Promise @@ -254,39 +52,10 @@ export type HostedReviewSlice = { fetchHostedReviewForBranch: ( repoPath: string, branch: string, - options?: FetchOptions & LinkedReviewHints + options?: HostedReviewFetchOptions & LinkedReviewHints ) => Promise } -type RefreshHostedReviewCardArgs = { - repoPath: string - repoId: string - branch: string - linkedGitHubPR?: number | null - fallbackGitHubPR?: number | null - linkedGitLabMR?: number | null - linkedBitbucketPR?: number | null - linkedAzureDevOpsPR?: number | null - linkedGiteaPR?: number | null -} - -export function refreshHostedReviewCard( - fetchHostedReviewForBranch: HostedReviewSlice['fetchHostedReviewForBranch'], - args: RefreshHostedReviewCardArgs -): Promise { - const fallbackGitHubPR = args.linkedGitHubPR == null ? (args.fallbackGitHubPR ?? null) : null - return fetchHostedReviewForBranch(args.repoPath, args.branch, { - force: true, - repoId: args.repoId, - linkedGitHubPR: args.linkedGitHubPR ?? null, - ...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}), - linkedGitLabMR: args.linkedGitLabMR ?? null, - linkedBitbucketPR: args.linkedBitbucketPR ?? null, - linkedAzureDevOpsPR: args.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: args.linkedGiteaPR ?? null - }) -} - export const createHostedReviewSlice: StateCreator = ( set, get @@ -406,7 +175,7 @@ export const createHostedReviewSlice: StateCreator currentPRCache[key]) - ? (() => { - const next = { ...currentPRCache } - for (const key of prCacheKeys) { - delete next[key] - } - return next - })() - : currentPRCache + const prCache = clearHostedReviewConflictingPrCache({ + cache: currentPRCache, + review, + repoPath, + repoId, + branch, + settings: ownerSettings, + repo + }) return { ...(prCache === currentPRCache ? {} : { prCache }), hostedReviewCache: withHostedReviewCacheEntry(state.hostedReviewCache, cacheKey, { diff --git a/src/renderer/src/store/slices/linear-credential-error-recovery.test.ts b/src/renderer/src/store/slices/linear-credential-error-recovery.test.ts index 64dac946c00..1644e42361a 100644 --- a/src/renderer/src/store/slices/linear-credential-error-recovery.test.ts +++ b/src/renderer/src/store/slices/linear-credential-error-recovery.test.ts @@ -25,16 +25,7 @@ vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { linearConnect: (...args: unknown[]) => linearConnect(...args), linearDisconnect: (...args: unknown[]) => linearDisconnect(...args), linearDisconnectWorkspace: vi.fn(), - linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), - linearGetProject: (...args: unknown[]) => linearGetProject(...args), - linearGetIssue: (...args: unknown[]) => linearGetIssue(...args), - linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), - linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), - linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), linearListIssues: (...args: unknown[]) => linearListIssues(...args), - linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), - linearListProjects: (...args: unknown[]) => linearListProjects(...args), - linearListTeams: (...args: unknown[]) => linearListTeams(...args), linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), linearSelectWorkspace: vi.fn(), linearStatus: (...args: unknown[]) => linearStatus(...args), @@ -42,6 +33,29 @@ vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { } }) +vi.mock('@/runtime/runtime-linear-issue-mutations', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearGetIssue: (...args: unknown[]) => linearGetIssue(...args) + } +}) + +vi.mock('@/runtime/runtime-linear-project-client', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), + linearGetProject: (...args: unknown[]) => linearGetProject(...args), + linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), + linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), + linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), + linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), + linearListProjects: (...args: unknown[]) => linearListProjects(...args), + linearListTeams: (...args: unknown[]) => linearListTeams(...args) + } +}) + vi.mock('../../hooks/useIssueMetadata', () => ({ clearLinearMetadataCache: vi.fn() })) diff --git a/src/renderer/src/store/slices/linear-invalidation.test.ts b/src/renderer/src/store/slices/linear-invalidation.test.ts index 03c917187fa..febf6bc222f 100644 --- a/src/renderer/src/store/slices/linear-invalidation.test.ts +++ b/src/renderer/src/store/slices/linear-invalidation.test.ts @@ -16,16 +16,29 @@ vi.mock('@/runtime/runtime-linear-client', () => ({ linearConnect: (...args: unknown[]) => linearConnect(...args), linearDisconnect: vi.fn(), linearDisconnectWorkspace: vi.fn(), - linearGetIssue: (...args: unknown[]) => linearGetIssue(...args), isLinearIssueAttributeFilterUnsupportedError: () => false, linearListIssues: (...args: unknown[]) => linearListIssues(...args), - linearListTeams: (...args: unknown[]) => linearListTeams(...args), linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), linearSelectWorkspace: vi.fn(), linearStatus: (...args: unknown[]) => linearStatus(...args), linearTestConnection: vi.fn() })) +vi.mock('@/runtime/runtime-linear-issue-mutations', () => ({ + linearGetIssue: (...args: unknown[]) => linearGetIssue(...args) +})) + +vi.mock('@/runtime/runtime-linear-project-client', () => ({ + linearGetCustomView: vi.fn(), + linearGetProject: vi.fn(), + linearListCustomViewIssues: vi.fn(), + linearListCustomViewProjects: vi.fn(), + linearListCustomViews: vi.fn(), + linearListProjectIssues: vi.fn(), + linearListProjects: vi.fn(), + linearListTeams: (...args: unknown[]) => linearListTeams(...args) +})) + vi.mock('../../hooks/useIssueMetadata', () => ({ clearLinearMetadataCache: vi.fn() })) diff --git a/src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts b/src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts index 0f457817bd9..46bb849b2de 100644 --- a/src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts +++ b/src/renderer/src/store/slices/linear-scoped-collection-cache.test.ts @@ -26,16 +26,7 @@ vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { linearConnect: (...args: unknown[]) => linearConnect(...args), linearDisconnect: (...args: unknown[]) => linearDisconnect(...args), linearDisconnectWorkspace: vi.fn(), - linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), - linearGetProject: (...args: unknown[]) => linearGetProject(...args), - linearGetIssue: (...args: unknown[]) => linearGetIssue(...args), - linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), - linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), - linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), linearListIssues: (...args: unknown[]) => linearListIssues(...args), - linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), - linearListProjects: (...args: unknown[]) => linearListProjects(...args), - linearListTeams: (...args: unknown[]) => linearListTeams(...args), linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), linearSelectWorkspace: vi.fn(), linearStatus: (...args: unknown[]) => linearStatus(...args), @@ -43,6 +34,29 @@ vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { } }) +vi.mock('@/runtime/runtime-linear-issue-mutations', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearGetIssue: (...args: unknown[]) => linearGetIssue(...args) + } +}) + +vi.mock('@/runtime/runtime-linear-project-client', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), + linearGetProject: (...args: unknown[]) => linearGetProject(...args), + linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), + linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), + linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), + linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), + linearListProjects: (...args: unknown[]) => linearListProjects(...args), + linearListTeams: (...args: unknown[]) => linearListTeams(...args) + } +}) + vi.mock('../../hooks/useIssueMetadata', () => ({ clearLinearMetadataCache: vi.fn() })) diff --git a/src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts b/src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts index e4f0f7c3de3..08ed1248b19 100644 --- a/src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts +++ b/src/renderer/src/store/slices/linear-source-context-cache-scope.test.ts @@ -30,16 +30,7 @@ vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { linearConnect: (...args: unknown[]) => linearConnect(...args), linearDisconnect: (...args: unknown[]) => linearDisconnect(...args), linearDisconnectWorkspace: vi.fn(), - linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), - linearGetProject: (...args: unknown[]) => linearGetProject(...args), - linearGetIssue: (...args: unknown[]) => linearGetIssue(...args), - linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), - linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), - linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), linearListIssues: (...args: unknown[]) => linearListIssues(...args), - linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), - linearListProjects: (...args: unknown[]) => linearListProjects(...args), - linearListTeams: (...args: unknown[]) => linearListTeams(...args), linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), linearSelectWorkspace: vi.fn(), linearStatus: (...args: unknown[]) => linearStatus(...args), @@ -47,6 +38,29 @@ vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { } }) +vi.mock('@/runtime/runtime-linear-issue-mutations', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearGetIssue: (...args: unknown[]) => linearGetIssue(...args) + } +}) + +vi.mock('@/runtime/runtime-linear-project-client', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), + linearGetProject: (...args: unknown[]) => linearGetProject(...args), + linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), + linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), + linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), + linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), + linearListProjects: (...args: unknown[]) => linearListProjects(...args), + linearListTeams: (...args: unknown[]) => linearListTeams(...args) + } +}) + vi.mock('../../hooks/useIssueMetadata', () => ({ clearLinearMetadataCache: vi.fn() })) diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 588c6845f3a..0f86363b6ef 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -28,21 +28,23 @@ import { linearConnect, linearDisconnect, linearDisconnectWorkspace, - linearGetCustomView, - linearGetProject, - linearGetIssue, - linearListCustomViewIssues, - linearListCustomViewProjects, - linearListCustomViews, linearListIssues, - linearListProjectIssues, - linearListProjects, - linearListTeams, linearSearchIssues, linearSelectWorkspace, linearStatus, linearTestConnection } from '@/runtime/runtime-linear-client' +import { linearGetIssue } from '@/runtime/runtime-linear-issue-mutations' +import { + linearGetCustomView, + linearGetProject, + linearListCustomViewIssues, + linearListCustomViewProjects, + linearListCustomViews, + linearListProjectIssues, + linearListProjects, + linearListTeams +} from '@/runtime/runtime-linear-project-client' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { translate } from '@/i18n/i18n' import { diff --git a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts index fb7b28c01e8..bdbfabf1373 100644 --- a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts +++ b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' import { toast } from 'sonner' import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' -import { getHostedReviewCacheKey } from './hosted-review' +import { getHostedReviewCacheKey } from './hosted-review-cache-identity' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' import { makeWorktree } from './worktrees-slice-test-fixtures' import { diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts index 3107d89ab15..a00f76b30a5 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -4,7 +4,7 @@ import { translate } from '@/i18n/i18n' import { isPositiveHostedReviewNumber } from '../../../../../../shared/hosted-review' import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { getHostedReviewCacheKey } from '../../hosted-review' +import { getHostedReviewCacheKey } from '../../hosted-review-cache-identity' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../github-cache-key' import { applyDetectedWorktreeUpdates, diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts index fa05be0e2b9..fb7e9d4b500 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts @@ -3,7 +3,7 @@ import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-sli import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' import { branchName } from '@/lib/git-utils' -import { refreshHostedReviewCard } from '../../hosted-review' +import { refreshHostedReviewCard } from '../../hosted-review-card-refresh' import { applyDetectedWorktreeUpdates, findKnownWorktreeById diff --git a/src/shared/automation-cron-occurrence.ts b/src/shared/automation-cron-occurrence.ts new file mode 100644 index 00000000000..6ad88468e16 --- /dev/null +++ b/src/shared/automation-cron-occurrence.ts @@ -0,0 +1,48 @@ +import type { ParsedCron } from './automation-schedule-parsing' + +const DAY_MS = 24 * 60 * 60 * 1000 +const CRON_SCAN_DAYS = 9 * 366 + +export function startOfLocalDay(timestamp: number): number { + const date = new Date(timestamp) + date.setHours(0, 0, 0, 0) + return date.getTime() +} + +export function floorToMinute(timestamp: number): number { + const date = new Date(timestamp) + date.setSeconds(0, 0) + return date.getTime() +} + +export function cronMatches(rule: ParsedCron, timestamp: number): boolean { + if (!cronDateMatches(rule, timestamp)) { + return false + } + const date = new Date(timestamp) + return rule.hours.has(date.getHours()) && rule.minutes.has(date.getMinutes()) +} + +export function cronDateMatches(rule: ParsedCron, timestamp: number): boolean { + const date = new Date(timestamp) + if (!rule.months.has(date.getMonth() + 1)) { + return false + } + const dayOfMonthMatches = rule.daysOfMonth.has(date.getDate()) + const dayOfWeekMatches = rule.daysOfWeek.has(date.getDay()) + if (rule.dayOfMonthRestricted && rule.dayOfWeekRestricted) { + return dayOfMonthMatches || dayOfWeekMatches + } + return dayOfMonthMatches && dayOfWeekMatches +} + +export function cronHasPossibleOccurrence(rule: ParsedCron, anchor: number): boolean { + let day = startOfLocalDay(anchor) + for (let i = 0; i < CRON_SCAN_DAYS; i += 1) { + if (cronDateMatches(rule, day)) { + return true + } + day += DAY_MS + } + return false +} diff --git a/src/shared/automation-schedule-occurrences.ts b/src/shared/automation-schedule-occurrences.ts new file mode 100644 index 00000000000..57e67743d42 --- /dev/null +++ b/src/shared/automation-schedule-occurrences.ts @@ -0,0 +1,157 @@ +import type { AutomationSchedulePreset } from './automations-types' +import { parseSchedule, type ParsedRrule } from './automation-schedule-parsing' +import { cronMatches, floorToMinute, startOfLocalDay } from './automation-cron-occurrence' + +const DAY_MS = 24 * 60 * 60 * 1000 +const HOUR_MS = 60 * 60 * 1000 +const MINUTE_MS = 60 * 1000 +// Why: valid cron expressions like Feb 29 can have an 8-year gap across non-leap centuries. +const CRON_SCAN_DAYS = 9 * 366 +const CRON_SCAN_MINUTES = CRON_SCAN_DAYS * 24 * 60 +const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const +function atLocalTime(dayMs: number, hour: number, minute: number): number { + const date = new Date(dayMs) + date.setHours(hour, minute, 0, 0) + return date.getTime() +} + +function dayMatches(rule: ParsedRrule, timestamp: number): boolean { + if (rule.freq === 'DAILY') { + return true + } + const code = DAY_CODES[new Date(timestamp).getDay()] + return rule.byDay.includes(code) +} + +function scanDayCandidates(rule: ParsedRrule, anchor: number, direction: 1 | -1): number | null { + let day = startOfLocalDay(anchor) + for (let i = 0; i < 370; i += 1) { + const candidate = atLocalTime(day, rule.byHour, rule.byMinute) + if (dayMatches(rule, candidate)) { + if (direction === 1 && candidate > anchor) { + return candidate + } + if (direction === -1 && candidate <= anchor) { + return candidate + } + } + day += direction * DAY_MS + } + return null +} + +export function buildAutomationRrule(args: { + preset: Exclude + hour: number + minute: number + dayOfWeek?: number +}): string { + const hour = Math.max(0, Math.min(23, Math.floor(args.hour))) + const minute = Math.max(0, Math.min(59, Math.floor(args.minute))) + if (args.preset === 'hourly') { + return `FREQ=HOURLY;BYMINUTE=${minute}` + } + if (args.preset === 'weekdays') { + return `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=${hour};BYMINUTE=${minute}` + } + if (args.preset === 'weekly') { + const day = DAY_CODES[Math.max(0, Math.min(6, Math.floor(args.dayOfWeek ?? 1)))] + return `FREQ=WEEKLY;BYDAY=${day};BYHOUR=${hour};BYMINUTE=${minute}` + } + return `FREQ=DAILY;BYHOUR=${hour};BYMINUTE=${minute}` +} + +export function buildAutomationCronSchedule(args: { + preset: Exclude + hour: number + minute: number + dayOfWeek?: number +}): string { + const hour = Math.max(0, Math.min(23, Math.floor(args.hour))) + const minute = Math.max(0, Math.min(59, Math.floor(args.minute))) + if (args.preset === 'hourly') { + return `${minute} * * * *` + } + if (args.preset === 'weekdays') { + return `${minute} ${hour} * * 1-5` + } + if (args.preset === 'weekly') { + const day = Math.max(0, Math.min(6, Math.floor(args.dayOfWeek ?? 1))) + return `${minute} ${hour} * * ${day}` + } + return `${minute} ${hour} * * *` +} + +export function nextAutomationOccurrenceAfter( + rrule: string, + dtstart: number, + after: number +): number { + const rule = parseSchedule(rrule) + if (rule.kind === 'cron') { + let candidate = floorToMinute(Math.max(dtstart, after)) + if (candidate <= after) { + candidate += MINUTE_MS + } + if (candidate < dtstart) { + candidate = floorToMinute(dtstart) + if (candidate < dtstart) { + candidate += MINUTE_MS + } + } + for (let i = 0; i < CRON_SCAN_MINUTES; i += 1) { + if (cronMatches(rule, candidate)) { + return candidate + } + candidate += MINUTE_MS + } + throw new Error('Unable to compute next automation run.') + } + if (rule.freq === 'HOURLY') { + const start = Math.max(dtstart, after) + const base = new Date(start) + base.setMinutes(rule.byMinute, 0, 0) + let candidate = base.getTime() + if (candidate <= after || candidate < dtstart) { + candidate += HOUR_MS + } + return candidate + } + const candidate = scanDayCandidates(rule, Math.max(dtstart - 1, after), 1) + if (candidate === null) { + throw new Error('Unable to compute next automation run.') + } + return candidate +} + +export function latestAutomationOccurrenceAtOrBefore( + rrule: string, + dtstart: number, + now: number +): number | null { + if (now < dtstart) { + return null + } + const rule = parseSchedule(rrule) + if (rule.kind === 'cron') { + let candidate = floorToMinute(now) + for (let i = 0; i < CRON_SCAN_MINUTES && candidate >= dtstart; i += 1) { + if (cronMatches(rule, candidate)) { + return candidate + } + candidate -= MINUTE_MS + } + return null + } + if (rule.freq === 'HOURLY') { + const base = new Date(now) + base.setMinutes(rule.byMinute, 0, 0) + let candidate = base.getTime() + if (candidate > now) { + candidate -= HOUR_MS + } + return candidate >= dtstart ? candidate : null + } + const candidate = scanDayCandidates(rule, now, -1) + return candidate !== null && candidate >= dtstart ? candidate : null +} diff --git a/src/shared/automation-schedule-parsing.ts b/src/shared/automation-schedule-parsing.ts new file mode 100644 index 00000000000..746bb434de2 --- /dev/null +++ b/src/shared/automation-schedule-parsing.ts @@ -0,0 +1,317 @@ +import type { AutomationSchedulePreset } from './automations-types' +import { cronHasPossibleOccurrence } from './automation-cron-occurrence' + +import { isClipboardTextByteLengthOverLimit } from './clipboard-text' + +export const AUTOMATION_CRON_EXPRESSION_MAX_BYTES = 2 * 1024 +export type ParsedRrule = { + kind: 'rrule' + freq: 'HOURLY' | 'DAILY' | 'WEEKLY' + byDay: string[] + byHour: number + byMinute: number +} + +export type ParsedCron = { + kind: 'cron' + minutes: Set + hours: Set + daysOfMonth: Set + months: Set + daysOfWeek: Set + dayOfMonthRestricted: boolean + dayOfWeekRestricted: boolean +} + +export type ParsedSchedule = ParsedRrule | ParsedCron +const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const +const WEEKDAY_CODES = ['MO', 'TU', 'WE', 'TH', 'FR'] as const +const MONTH_NAMES: Record = { + JAN: 1, + FEB: 2, + MAR: 3, + APR: 4, + MAY: 5, + JUN: 6, + JUL: 7, + AUG: 8, + SEP: 9, + OCT: 10, + NOV: 11, + DEC: 12 +} +const DAY_NAMES: Record = { + SU: 0, + MO: 1, + TU: 2, + WE: 3, + TH: 4, + FR: 5, + SA: 6, + SUN: 0, + MON: 1, + TUE: 2, + WED: 3, + THU: 4, + FRI: 5, + SAT: 6 +} + +function parseRrule(rrule: string): ParsedRrule { + const entries = new Map() + for (const part of rrule.split(';')) { + const [key, value] = part.split('=') + if (key && value) { + entries.set(key.toUpperCase(), value) + } + } + const freq = entries.get('FREQ') + if (freq !== 'HOURLY' && freq !== 'DAILY' && freq !== 'WEEKLY') { + throw new Error('Unsupported automation recurrence.') + } + const byHour = Number(entries.get('BYHOUR') ?? '9') + const byMinute = Number(entries.get('BYMINUTE') ?? '0') + if (!Number.isInteger(byHour) || byHour < 0 || byHour > 23) { + throw new Error('Invalid recurrence hour.') + } + if (!Number.isInteger(byMinute) || byMinute < 0 || byMinute > 59) { + throw new Error('Invalid recurrence minute.') + } + const byDay = (entries.get('BYDAY') ?? '').split(',').filter(Boolean) + if ( + freq === 'WEEKLY' && + (byDay.length === 0 || + byDay.some((day) => !DAY_CODES.includes(day as (typeof DAY_CODES)[number]))) + ) { + throw new Error('Invalid recurrence day.') + } + return { kind: 'rrule', freq, byDay, byHour, byMinute } +} + +function parseCronNumber( + value: string, + names: Record | null, + field: string +): number { + const normalized = value.toUpperCase() + const named = names?.[normalized] + const parsed = named ?? Number(normalized) + if (!Number.isInteger(parsed)) { + throw new Error(`Invalid cron ${field}.`) + } + return parsed +} + +function parseCronField(args: { + value: string + min: number + max: number + field: string + names?: Record + normalize?: (value: number) => number +}): Set { + const result = new Set() + for (const rawPart of args.value.split(',')) { + const part = rawPart.trim() + if (!part) { + throw new Error(`Invalid cron ${args.field}.`) + } + const stepParts = part.split('/') + if (stepParts.length > 2) { + throw new Error(`Invalid cron ${args.field}.`) + } + const [rangePart, stepPart] = stepParts + if (!rangePart) { + throw new Error(`Invalid cron ${args.field}.`) + } + const step = stepPart === undefined ? 1 : Number(stepPart) + if (!Number.isInteger(step) || step < 1) { + throw new Error(`Invalid cron ${args.field}.`) + } + + let start: number + let end: number + if (rangePart === '*') { + start = args.min + end = args.max + } else if (rangePart.includes('-')) { + const rangeParts = rangePart.split('-') + if (rangeParts.length !== 2 || !rangeParts[0] || !rangeParts[1]) { + throw new Error(`Invalid cron ${args.field}.`) + } + const [startPart, endPart] = rangeParts + start = parseCronNumber(startPart, args.names ?? null, args.field) + end = parseCronNumber(endPart, args.names ?? null, args.field) + } else { + start = parseCronNumber(rangePart, args.names ?? null, args.field) + end = start + } + + const normalizedStart = args.normalize?.(start) ?? start + const normalizedEnd = args.normalize?.(end) ?? end + if ( + start < args.min || + start > args.max || + end < args.min || + end > args.max || + normalizedStart < args.min || + normalizedStart > args.max || + normalizedEnd < args.min || + normalizedEnd > args.max || + start > end + ) { + throw new Error(`Invalid cron ${args.field}.`) + } + for (let value = start; value <= end; value += step) { + result.add(args.normalize?.(value) ?? value) + } + } + if (result.size === 0) { + throw new Error(`Invalid cron ${args.field}.`) + } + return result +} + +export function parseCronExpression(expression: string): ParsedCron { + const parts = getAutomationCronExpressionFields(expression, 6) + if (parts.length !== 5) { + throw new Error('Cron schedule must have five fields.') + } + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts + const daysOfMonth = parseCronField({ + value: dayOfMonth, + min: 1, + max: 31, + field: 'day of month' + }) + const daysOfWeek = parseCronField({ + value: dayOfWeek, + min: 0, + max: 7, + field: 'day of week', + names: DAY_NAMES, + normalize: (value) => (value === 7 ? 0 : value) + }) + return { + kind: 'cron', + minutes: parseCronField({ value: minute, min: 0, max: 59, field: 'minute' }), + hours: parseCronField({ value: hour, min: 0, max: 23, field: 'hour' }), + daysOfMonth, + months: parseCronField({ value: month, min: 1, max: 12, field: 'month', names: MONTH_NAMES }), + daysOfWeek, + dayOfMonthRestricted: daysOfMonth.size !== 31, + dayOfWeekRestricted: daysOfWeek.size !== 7 + } +} + +export function getAutomationCronExpressionFields(expression: string, maxFields = 5): string[] { + if (isClipboardTextByteLengthOverLimit(expression, AUTOMATION_CRON_EXPRESSION_MAX_BYTES)) { + return [] + } + const fields: string[] = [] + let tokenStart = -1 + for (let index = 0; index <= expression.length; index += 1) { + const isEnd = index === expression.length + if (!isEnd && !isAutomationCronFieldWhitespace(expression.charCodeAt(index))) { + if (tokenStart === -1) { + tokenStart = index + } + continue + } + if (tokenStart !== -1) { + fields.push(expression.slice(tokenStart, index)) + tokenStart = -1 + if (fields.length >= maxFields) { + break + } + } + } + return fields +} + +function isAutomationCronFieldWhitespace(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 5760 || + (code >= 8192 && code <= 8202) || + code === 8232 || + code === 8233 || + code === 8239 || + code === 8287 || + code === 12288 || + code === 65279 + ) +} + +export function parseSchedule(schedule: string): ParsedSchedule { + const trimmed = schedule.trim() + if (trimmed.includes('=')) { + return parseRrule(trimmed) + } + return parseCronExpression(trimmed) +} + +export function isValidAutomationSchedule(schedule: string): boolean { + try { + const parsed = parseSchedule(schedule) + if (parsed.kind === 'cron' && !cronHasPossibleOccurrence(parsed, Date.now())) { + throw new Error('Cron schedule has no possible run.') + } + return true + } catch { + return false + } +} + +export function isValidAutomationCronSchedule(schedule: string): boolean { + try { + const parsed = parseCronExpression(schedule.trim()) + return cronHasPossibleOccurrence(parsed, Date.now()) + } catch { + return false + } +} + +export function parseAutomationRrule(rrule: string): { + preset: AutomationSchedulePreset + hour: number + minute: number + dayOfWeek: number +} { + const rule = parseRrule(rrule) + if (rule.freq === 'HOURLY') { + return { preset: 'hourly', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 } + } + if (rule.freq === 'DAILY') { + return { preset: 'daily', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 } + } + if (rule.byDay.join(',') === WEEKDAY_CODES.join(',')) { + return { preset: 'weekdays', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 } + } + if (rule.byDay.length !== 1) { + throw new Error('Invalid recurrence day.') + } + const dayCode = rule.byDay[0] + const dayOfWeek = DAY_CODES.indexOf(dayCode as (typeof DAY_CODES)[number]) + if (dayOfWeek === -1) { + throw new Error('Invalid recurrence day.') + } + return { + preset: 'weekly', + hour: rule.byHour, + minute: rule.byMinute, + dayOfWeek + } +} + +export function tryParseAutomationRrule( + rrule: string +): ReturnType | null { + try { + return parseAutomationRrule(rrule) + } catch { + return null + } +} diff --git a/src/shared/automation-schedules.test.ts b/src/shared/automation-schedules.test.ts index 37f051366c1..7005b7d59e0 100644 --- a/src/shared/automation-schedules.test.ts +++ b/src/shared/automation-schedules.test.ts @@ -1,19 +1,23 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { - AUTOMATION_CRON_EXPRESSION_MAX_BYTES, - buildAutomationCronSchedule, - buildAutomationRrule, classifyAutomationCronSchedule, describeAutomationSchedule, - formatAutomationSchedule, + formatAutomationSchedule +} from './automation-schedules' +import { + buildAutomationCronSchedule, + buildAutomationRrule, + latestAutomationOccurrenceAtOrBefore, + nextAutomationOccurrenceAfter +} from './automation-schedule-occurrences' +import { + AUTOMATION_CRON_EXPRESSION_MAX_BYTES, getAutomationCronExpressionFields, isValidAutomationCronSchedule, isValidAutomationSchedule, - latestAutomationOccurrenceAtOrBefore, - nextAutomationOccurrenceAfter, parseAutomationRrule, tryParseAutomationRrule -} from './automation-schedules' +} from './automation-schedule-parsing' function formatTimeForTest(hour: number, minute: number): string { const date = new Date() diff --git a/src/shared/automation-schedules.ts b/src/shared/automation-schedules.ts index c53c11c7148..6d5a0053595 100644 --- a/src/shared/automation-schedules.ts +++ b/src/shared/automation-schedules.ts @@ -1,36 +1,10 @@ -/* eslint-disable max-lines -- Why: automation scheduling needs RRULE presets and - * custom cron parsing to share one execution path for main/renderer parity. */ -import type { AutomationSchedulePreset } from './automations-types' -import { isClipboardTextByteLengthOverLimit } from './clipboard-text' - -const DAY_MS = 24 * 60 * 60 * 1000 -const HOUR_MS = 60 * 60 * 1000 -const MINUTE_MS = 60 * 1000 -// Why: valid cron expressions like Feb 29 can have an 8-year gap across non-leap centuries. -const CRON_SCAN_DAYS = 9 * 366 -const CRON_SCAN_MINUTES = CRON_SCAN_DAYS * 24 * 60 -export const AUTOMATION_CRON_EXPRESSION_MAX_BYTES = 2 * 1024 - -type ParsedRrule = { - kind: 'rrule' - freq: 'HOURLY' | 'DAILY' | 'WEEKLY' - byDay: string[] - byHour: number - byMinute: number -} - -type ParsedCron = { - kind: 'cron' - minutes: Set - hours: Set - daysOfMonth: Set - months: Set - daysOfWeek: Set - dayOfMonthRestricted: boolean - dayOfWeekRestricted: boolean -} - -type ParsedSchedule = ParsedRrule | ParsedCron +import { + parseAutomationRrule, + parseCronExpression, + parseSchedule, + type ParsedCron +} from './automation-schedule-parsing' +import { cronHasPossibleOccurrence } from './automation-cron-occurrence' export type AutomationCronScheduleClassification = | { kind: 'hourly'; minute: number; label: string } @@ -49,8 +23,6 @@ export type AutomationScheduleDescriptor = | { kind: 'custom' } | { kind: 'invalid' } -const DAY_CODES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const -const WEEKDAY_CODES = ['MO', 'TU', 'WE', 'TH', 'FR'] as const // Why: shared labels feed the CLI, which must stay English regardless of OS or UI locale. const EN_DAY_NAMES = [ 'Sunday', @@ -61,285 +33,6 @@ const EN_DAY_NAMES = [ 'Friday', 'Saturday' ] as const -const MONTH_NAMES = new Map([ - ['JAN', 1], - ['FEB', 2], - ['MAR', 3], - ['APR', 4], - ['MAY', 5], - ['JUN', 6], - ['JUL', 7], - ['AUG', 8], - ['SEP', 9], - ['OCT', 10], - ['NOV', 11], - ['DEC', 12] -]) -const DAY_NAMES = new Map([ - ...DAY_CODES.map((code, index) => [code, index] as const), - ['SUN', 0], - ['MON', 1], - ['TUE', 2], - ['WED', 3], - ['THU', 4], - ['FRI', 5], - ['SAT', 6] -]) - -function parseRrule(rrule: string): ParsedRrule { - const entries = new Map() - for (const part of rrule.split(';')) { - const [key, value] = part.split('=') - if (key && value) { - entries.set(key.toUpperCase(), value) - } - } - const freq = entries.get('FREQ') - if (freq !== 'HOURLY' && freq !== 'DAILY' && freq !== 'WEEKLY') { - throw new Error('Unsupported automation recurrence.') - } - const byHour = Number(entries.get('BYHOUR') ?? '9') - const byMinute = Number(entries.get('BYMINUTE') ?? '0') - if (!Number.isInteger(byHour) || byHour < 0 || byHour > 23) { - throw new Error('Invalid recurrence hour.') - } - if (!Number.isInteger(byMinute) || byMinute < 0 || byMinute > 59) { - throw new Error('Invalid recurrence minute.') - } - const byDay = (entries.get('BYDAY') ?? '').split(',').filter(Boolean) - if ( - freq === 'WEEKLY' && - (byDay.length === 0 || - byDay.some((day) => !DAY_CODES.includes(day as (typeof DAY_CODES)[number]))) - ) { - throw new Error('Invalid recurrence day.') - } - return { kind: 'rrule', freq, byDay, byHour, byMinute } -} - -function parseCronNumber(value: string, names: Map | null, field: string): number { - const normalized = value.toUpperCase() - const named = names?.get(normalized) - const parsed = named ?? Number(normalized) - if (!Number.isInteger(parsed)) { - throw new Error(`Invalid cron ${field}.`) - } - return parsed -} - -function parseCronField(args: { - value: string - min: number - max: number - field: string - names?: Map - normalize?: (value: number) => number -}): Set { - const result = new Set() - for (const rawPart of args.value.split(',')) { - const part = rawPart.trim() - if (!part) { - throw new Error(`Invalid cron ${args.field}.`) - } - const stepParts = part.split('/') - if (stepParts.length > 2) { - throw new Error(`Invalid cron ${args.field}.`) - } - const [rangePart, stepPart] = stepParts - if (!rangePart) { - throw new Error(`Invalid cron ${args.field}.`) - } - const step = stepPart === undefined ? 1 : Number(stepPart) - if (!Number.isInteger(step) || step < 1) { - throw new Error(`Invalid cron ${args.field}.`) - } - - let start: number - let end: number - if (rangePart === '*') { - start = args.min - end = args.max - } else if (rangePart.includes('-')) { - const rangeParts = rangePart.split('-') - if (rangeParts.length !== 2 || !rangeParts[0] || !rangeParts[1]) { - throw new Error(`Invalid cron ${args.field}.`) - } - const [startPart, endPart] = rangeParts - start = parseCronNumber(startPart, args.names ?? null, args.field) - end = parseCronNumber(endPart, args.names ?? null, args.field) - } else { - start = parseCronNumber(rangePart, args.names ?? null, args.field) - end = start - } - - const normalizedStart = args.normalize?.(start) ?? start - const normalizedEnd = args.normalize?.(end) ?? end - if ( - start < args.min || - start > args.max || - end < args.min || - end > args.max || - normalizedStart < args.min || - normalizedStart > args.max || - normalizedEnd < args.min || - normalizedEnd > args.max || - start > end - ) { - throw new Error(`Invalid cron ${args.field}.`) - } - for (let value = start; value <= end; value += step) { - result.add(args.normalize?.(value) ?? value) - } - } - if (result.size === 0) { - throw new Error(`Invalid cron ${args.field}.`) - } - return result -} - -function parseCronExpression(expression: string): ParsedCron { - const parts = getAutomationCronExpressionFields(expression, 6) - if (parts.length !== 5) { - throw new Error('Cron schedule must have five fields.') - } - const [minute, hour, dayOfMonth, month, dayOfWeek] = parts - const daysOfMonth = parseCronField({ - value: dayOfMonth, - min: 1, - max: 31, - field: 'day of month' - }) - const daysOfWeek = parseCronField({ - value: dayOfWeek, - min: 0, - max: 7, - field: 'day of week', - names: DAY_NAMES, - normalize: (value) => (value === 7 ? 0 : value) - }) - return { - kind: 'cron', - minutes: parseCronField({ value: minute, min: 0, max: 59, field: 'minute' }), - hours: parseCronField({ value: hour, min: 0, max: 23, field: 'hour' }), - daysOfMonth, - months: parseCronField({ value: month, min: 1, max: 12, field: 'month', names: MONTH_NAMES }), - daysOfWeek, - dayOfMonthRestricted: daysOfMonth.size !== 31, - dayOfWeekRestricted: daysOfWeek.size !== 7 - } -} - -export function getAutomationCronExpressionFields(expression: string, maxFields = 5): string[] { - if (isClipboardTextByteLengthOverLimit(expression, AUTOMATION_CRON_EXPRESSION_MAX_BYTES)) { - return [] - } - const fields: string[] = [] - let tokenStart = -1 - for (let index = 0; index <= expression.length; index += 1) { - const isEnd = index === expression.length - if (!isEnd && !isAutomationCronFieldWhitespace(expression.charCodeAt(index))) { - if (tokenStart === -1) { - tokenStart = index - } - continue - } - if (tokenStart !== -1) { - fields.push(expression.slice(tokenStart, index)) - tokenStart = -1 - if (fields.length >= maxFields) { - break - } - } - } - return fields -} - -function isAutomationCronFieldWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - -function parseSchedule(schedule: string): ParsedSchedule { - const trimmed = schedule.trim() - if (trimmed.includes('=')) { - return parseRrule(trimmed) - } - return parseCronExpression(trimmed) -} - -export function isValidAutomationSchedule(schedule: string): boolean { - try { - const parsed = parseSchedule(schedule) - if (parsed.kind === 'cron' && !cronHasPossibleOccurrence(parsed, Date.now())) { - throw new Error('Cron schedule has no possible run.') - } - return true - } catch { - return false - } -} - -export function isValidAutomationCronSchedule(schedule: string): boolean { - try { - const parsed = parseCronExpression(schedule.trim()) - return cronHasPossibleOccurrence(parsed, Date.now()) - } catch { - return false - } -} - -export function parseAutomationRrule(rrule: string): { - preset: AutomationSchedulePreset - hour: number - minute: number - dayOfWeek: number -} { - const rule = parseRrule(rrule) - if (rule.freq === 'HOURLY') { - return { preset: 'hourly', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 } - } - if (rule.freq === 'DAILY') { - return { preset: 'daily', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 } - } - if (rule.byDay.join(',') === WEEKDAY_CODES.join(',')) { - return { preset: 'weekdays', hour: rule.byHour, minute: rule.byMinute, dayOfWeek: 1 } - } - if (rule.byDay.length !== 1) { - throw new Error('Invalid recurrence day.') - } - const dayCode = rule.byDay[0] - const dayOfWeek = DAY_CODES.indexOf(dayCode as (typeof DAY_CODES)[number]) - if (dayOfWeek === -1) { - throw new Error('Invalid recurrence day.') - } - return { - preset: 'weekly', - hour: rule.byHour, - minute: rule.byMinute, - dayOfWeek - } -} - -export function tryParseAutomationRrule( - rrule: string -): ReturnType | null { - try { - return parseAutomationRrule(rrule) - } catch { - return null - } -} /** Clock-time portion of a schedule label; numeric shape follows the OS region by convention. */ export function formatAutomationScheduleTime(hour: number, minute: number): string { @@ -502,194 +195,3 @@ export function describeAutomationSchedule( return { kind: 'invalid' } } } - -function atLocalTime(dayMs: number, hour: number, minute: number): number { - const date = new Date(dayMs) - date.setHours(hour, minute, 0, 0) - return date.getTime() -} - -function startOfLocalDay(timestamp: number): number { - const date = new Date(timestamp) - date.setHours(0, 0, 0, 0) - return date.getTime() -} - -function dayMatches(rule: ParsedRrule, timestamp: number): boolean { - if (rule.freq === 'DAILY') { - return true - } - const code = DAY_CODES[new Date(timestamp).getDay()] - return rule.byDay.includes(code) -} - -function scanDayCandidates(rule: ParsedRrule, anchor: number, direction: 1 | -1): number | null { - let day = startOfLocalDay(anchor) - for (let i = 0; i < 370; i += 1) { - const candidate = atLocalTime(day, rule.byHour, rule.byMinute) - if (dayMatches(rule, candidate)) { - if (direction === 1 && candidate > anchor) { - return candidate - } - if (direction === -1 && candidate <= anchor) { - return candidate - } - } - day += direction * DAY_MS - } - return null -} - -function floorToMinute(timestamp: number): number { - const date = new Date(timestamp) - date.setSeconds(0, 0) - return date.getTime() -} - -function cronMatches(rule: ParsedCron, timestamp: number): boolean { - if (!cronDateMatches(rule, timestamp)) { - return false - } - const date = new Date(timestamp) - return rule.hours.has(date.getHours()) && rule.minutes.has(date.getMinutes()) -} - -function cronDateMatches(rule: ParsedCron, timestamp: number): boolean { - const date = new Date(timestamp) - if (!rule.months.has(date.getMonth() + 1)) { - return false - } - const dayOfMonthMatches = rule.daysOfMonth.has(date.getDate()) - const dayOfWeekMatches = rule.daysOfWeek.has(date.getDay()) - if (rule.dayOfMonthRestricted && rule.dayOfWeekRestricted) { - return dayOfMonthMatches || dayOfWeekMatches - } - return dayOfMonthMatches && dayOfWeekMatches -} - -function cronHasPossibleOccurrence(rule: ParsedCron, anchor: number): boolean { - let day = startOfLocalDay(anchor) - for (let i = 0; i < CRON_SCAN_DAYS; i += 1) { - if (cronDateMatches(rule, day)) { - return true - } - day += DAY_MS - } - return false -} - -export function buildAutomationRrule(args: { - preset: Exclude - hour: number - minute: number - dayOfWeek?: number -}): string { - const hour = Math.max(0, Math.min(23, Math.floor(args.hour))) - const minute = Math.max(0, Math.min(59, Math.floor(args.minute))) - if (args.preset === 'hourly') { - return `FREQ=HOURLY;BYMINUTE=${minute}` - } - if (args.preset === 'weekdays') { - return `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=${hour};BYMINUTE=${minute}` - } - if (args.preset === 'weekly') { - const day = DAY_CODES[Math.max(0, Math.min(6, Math.floor(args.dayOfWeek ?? 1)))] - return `FREQ=WEEKLY;BYDAY=${day};BYHOUR=${hour};BYMINUTE=${minute}` - } - return `FREQ=DAILY;BYHOUR=${hour};BYMINUTE=${minute}` -} - -export function buildAutomationCronSchedule(args: { - preset: Exclude - hour: number - minute: number - dayOfWeek?: number -}): string { - const hour = Math.max(0, Math.min(23, Math.floor(args.hour))) - const minute = Math.max(0, Math.min(59, Math.floor(args.minute))) - if (args.preset === 'hourly') { - return `${minute} * * * *` - } - if (args.preset === 'weekdays') { - return `${minute} ${hour} * * 1-5` - } - if (args.preset === 'weekly') { - const day = Math.max(0, Math.min(6, Math.floor(args.dayOfWeek ?? 1))) - return `${minute} ${hour} * * ${day}` - } - return `${minute} ${hour} * * *` -} - -export function nextAutomationOccurrenceAfter( - rrule: string, - dtstart: number, - after: number -): number { - const rule = parseSchedule(rrule) - if (rule.kind === 'cron') { - let candidate = floorToMinute(Math.max(dtstart, after)) - if (candidate <= after) { - candidate += MINUTE_MS - } - if (candidate < dtstart) { - candidate = floorToMinute(dtstart) - if (candidate < dtstart) { - candidate += MINUTE_MS - } - } - for (let i = 0; i < CRON_SCAN_MINUTES; i += 1) { - if (cronMatches(rule, candidate)) { - return candidate - } - candidate += MINUTE_MS - } - throw new Error('Unable to compute next automation run.') - } - if (rule.freq === 'HOURLY') { - const start = Math.max(dtstart, after) - const base = new Date(start) - base.setMinutes(rule.byMinute, 0, 0) - let candidate = base.getTime() - if (candidate <= after || candidate < dtstart) { - candidate += HOUR_MS - } - return candidate - } - const candidate = scanDayCandidates(rule, Math.max(dtstart - 1, after), 1) - if (candidate === null) { - throw new Error('Unable to compute next automation run.') - } - return candidate -} - -export function latestAutomationOccurrenceAtOrBefore( - rrule: string, - dtstart: number, - now: number -): number | null { - if (now < dtstart) { - return null - } - const rule = parseSchedule(rrule) - if (rule.kind === 'cron') { - let candidate = floorToMinute(now) - for (let i = 0; i < CRON_SCAN_MINUTES && candidate >= dtstart; i += 1) { - if (cronMatches(rule, candidate)) { - return candidate - } - candidate -= MINUTE_MS - } - return null - } - if (rule.freq === 'HOURLY') { - const base = new Date(now) - base.setMinutes(rule.byMinute, 0, 0) - let candidate = base.getTime() - if (candidate > now) { - candidate -= HOUR_MS - } - return candidate >= dtstart ? candidate : null - } - const candidate = scanDayCandidates(rule, now, -1) - return candidate !== null && candidate >= dtstart ? candidate : null -} diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index ce7052d43a9..c9bfb8eb789 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { COMMIT_MESSAGE_AGENT_SPECS, - COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS, CUSTOM_AGENT_ID, DEFAULT_COMMIT_MESSAGE_AGENT_ID, getCommitMessageAgentCapability, @@ -11,14 +10,17 @@ import { isCustomAgentId, listCommitMessageAgentCapabilities, listCommitMessageAgentIds, + resolveCommitMessageAgentChoice +} from './commit-message-agent-spec' +import { + COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS, parseAntigravityModels, parseClaudeModels, parseCodexModels, parseCursorModels, parseLineModels, - parsePiModels, - resolveCommitMessageAgentChoice -} from './commit-message-agent-spec' + parsePiModels +} from './commit-message-model-parsers' afterEach(() => { vi.restoreAllMocks() diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index c65850ba08a..6223b968b67 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -1,14 +1,20 @@ import type { TuiAgent } from './tui-agent' import { isTuiAgentEnabled } from './tui-agent-selection' -import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' -import { - CLAUDE_MODEL_LIST_ARGS, - CLAUDE_MODEL_LIST_STDIN, - parseClaudeModelList -} from './claude-model-list-probe' import { labelFromModelId } from './model-id-label' - -/* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */ +import { buildPrimaryCommitMessageAgentSpecs } from './commit-message-agent-specs-primary' +import { buildSecondaryCommitMessageAgentSpecs } from './commit-message-agent-specs-secondary' +import { + BASIC_THINKING_LEVELS, + CLAUDE_THINKING_LEVELS, + OPENAI_THINKING_LEVELS, + parseAntigravityModels, + parseClaudeModels, + parseCodexModels, + parseCursorModels, + parseLineModels, + parsePiModels, + withOpenAiThinking +} from './commit-message-model-parsers' // Why: this file is the source of truth for non-interactive agent invocation // (commit-message generation). It is intentionally separate from @@ -81,646 +87,22 @@ export type CommitMessageAgentCapability = { defaultModelId: string } -export const COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS = { - structuralTokens: 64 * 1024, - nestingDepth: 16 -} as const - -const BASIC_THINKING_LEVELS: ThinkingLevel[] = [ - { id: 'low', label: 'Low' }, - { id: 'medium', label: 'Medium' }, - { id: 'high', label: 'High' } -] - -const OPENAI_THINKING_LEVELS: ThinkingLevel[] = [ - { id: 'low', label: 'Low' }, - { id: 'medium', label: 'Medium' }, - { id: 'high', label: 'High' }, - { id: 'xhigh', label: 'Extra High' } -] - -const CLAUDE_THINKING_LEVELS: ThinkingLevel[] = [ - { id: 'low', label: 'Low' }, - { id: 'medium', label: 'Medium' }, - { id: 'high', label: 'High' }, - { id: 'xhigh', label: 'Extra High' }, - { id: 'max', label: 'Max' } -] - -function uniqueModels(models: CommitMessageModel[]): CommitMessageModel[] { - const seen = new Set() - return models.filter((model) => { - if (!model.id || seen.has(model.id)) { - return false - } - seen.add(model.id) - return true - }) -} - -function* iterateModelOutputLines(output: string): Generator { - let lineStart = 0 - - for (let index = 0; index < output.length; index++) { - const code = output.charCodeAt(index) - if (code !== 10 && code !== 13) { - continue - } - - yield output.slice(lineStart, index) - if (code === 13 && output.charCodeAt(index + 1) === 10) { - index++ - } - lineStart = index + 1 - } - - if (lineStart <= output.length) { - yield output.slice(lineStart) - } -} - -function withOpenAiThinking( - id: string -): Pick { - return /(?:gpt-5|codex)/i.test(id) - ? { thinkingLevels: OPENAI_THINKING_LEVELS, defaultThinkingLevel: 'low' } - : {} -} - -export function parseClaudeModels(stdout: string): CommitMessageModel[] { - return uniqueModels( - parseClaudeModelList(stdout).map((model) => { - const thinkingLevels = CLAUDE_THINKING_LEVELS.filter((level) => - model.effortLevels.includes(level.id) - ) - return { - id: model.id, - label: model.label, - ...(model.description ? { description: model.description } : {}), - ...(thinkingLevels.length > 0 - ? { - thinkingLevels, - defaultThinkingLevel: thinkingLevels.some((level) => level.id === 'low') - ? 'low' - : thinkingLevels[0].id - } - : {}), - ...(model.supportsFastMode ? { supportsFastMode: true } : {}) - } - }) - ) -} - -export function parseCodexModels(stdout: string): CommitMessageModel[] { - try { - assertJsonTextStructureWithinLimits(stdout, COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS) - const parsed = JSON.parse(stdout) as { - models?: { - slug?: string - display_name?: string - supported_reasoning_levels?: { effort?: string }[] - default_reasoning_level?: string - }[] - } - return uniqueModels( - (parsed.models ?? []) - .filter((model) => model.slug && model.display_name) - .map((model) => ({ - id: model.slug!, - label: model.display_name!, - ...(model.supported_reasoning_levels?.length - ? { - thinkingLevels: model.supported_reasoning_levels - .map((level) => level.effort) - .filter((effort): effort is string => Boolean(effort)) - .map((effort) => ({ - id: effort, - label: effort === 'xhigh' ? 'Extra High' : labelFromModelId(effort) - })), - defaultThinkingLevel: model.default_reasoning_level ?? 'low' - } - : {}) - })) - ) - } catch { - return [] - } -} - -export function parseLineModels(stdout: string): CommitMessageModel[] { - const models: CommitMessageModel[] = [] - for (const rawLine of iterateModelOutputLines(stdout)) { - const id = rawLine.trim() - if (id.length === 0 || id.includes(' ')) { - continue - } - models.push({ - id, - label: labelFromModelId(id), - ...withOpenAiThinking(id) - }) - } - return uniqueModels(models) -} - -export function parsePiModels(stdout: string): CommitMessageModel[] { - const models: CommitMessageModel[] = [] - for (const rawLine of iterateModelOutputLines(stdout)) { - const parts = getPiModelTableFields(rawLine, 6) - if (parts.length < 6 || parts[0] === 'provider') { - continue - } - - const [provider, model, , , thinking] = parts - const id = `${provider}/${model}` - models.push({ - id, - label: `${labelFromModelId(provider)} ${labelFromModelId(model)}`, - ...(thinking === 'yes' - ? { - thinkingLevels: [ - { id: 'off', label: 'Off' }, - { id: 'low', label: 'Low' }, - { id: 'medium', label: 'Medium' }, - { id: 'high', label: 'High' }, - { id: 'xhigh', label: 'Extra High' } - ], - defaultThinkingLevel: 'low' - } - : {}) - }) - } - return uniqueModels(models) -} - -// Why: model discovery output can include paste-sized noisy lines; only the first fields matter. -function getPiModelTableFields(line: string, maxFields: number): string[] { - const fields: string[] = [] - let tokenStart = -1 - - for (let index = 0; index <= line.length; index += 1) { - const isEnd = index === line.length - if (!isEnd && !isPiModelTableWhitespace(line.charCodeAt(index))) { - if (tokenStart === -1) { - tokenStart = index - } - continue - } - if (tokenStart !== -1) { - fields.push(line.slice(tokenStart, index)) - tokenStart = -1 - if (fields.length >= maxFields) { - break - } - } - } - - return fields -} - -function isPiModelTableWhitespace(code: number): boolean { - return ( - code === 32 || - (code >= 9 && code <= 13) || - code === 160 || - code === 5760 || - (code >= 8192 && code <= 8202) || - code === 8232 || - code === 8233 || - code === 8239 || - code === 8287 || - code === 12288 || - code === 65279 - ) -} - -export function parseCursorModels(stdout: string): CommitMessageModel[] { - const models: CommitMessageModel[] = [] - for (const rawLine of iterateModelOutputLines(stdout)) { - const match = /^([^\s]+)\s+-\s+(.+)$/.exec(rawLine.trim()) - if (!match) { - continue - } - models.push({ - id: match[1], - label: match[2].replace(/\s+\((?:default|current)\)$/i, ''), - ...withOpenAiThinking(match[1]) - }) - } - return uniqueModels(models) -} - -export function parseAntigravityModels(stdout: string): CommitMessageModel[] { - const models: CommitMessageModel[] = [] - for (const rawLine of iterateModelOutputLines(stdout)) { - const id = rawLine.trim() - if (id.length === 0) { - continue - } - models.push({ - id, - label: id - }) - } - return uniqueModels(models) -} - export const COMMIT_MESSAGE_AGENT_SPECS: Partial> = { - claude: { - id: 'claude', - label: 'Claude', - binary: 'claude', - // Why: diffs can be large and `claude -p` reads from stdin natively when no - // positional prompt is provided. - promptDelivery: 'stdin', - buildArgs: ({ model, thinkingLevel }) => [ - '-p', - '--output-format', - 'text', - '--model', - model, - '--permission-mode', - 'plan', - ...(thinkingLevel ? ['--effort', thinkingLevel] : []) - ], - modelSource: 'dynamic', - // Why: the Claude CLI has no listing subcommand; one list_models control - // request over --print stream-json returns the /model picker catalog. - // Older CLIs answer with a control error and exit 0, keeping the fallback. - modelDiscovery: { - binary: 'claude', - args: [...CLAUDE_MODEL_LIST_ARGS], - stdinPayload: CLAUDE_MODEL_LIST_STDIN, - parse: parseClaudeModels - }, - models: [ - { - // Why: Claude Code aliases track the account/provider's supported - // model IDs; hardcoded version IDs can be rejected by Bedrock/Vertex. - id: 'haiku', - label: 'Haiku' - }, - { - id: 'sonnet', - label: 'Sonnet', - thinkingLevels: CLAUDE_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'opus', - label: 'Opus', - thinkingLevels: CLAUDE_THINKING_LEVELS, - defaultThinkingLevel: 'low' - } - ], - defaultModelId: 'sonnet' - }, - codex: { - id: 'codex', - label: 'Codex', - binary: 'codex', - // Why: `codex exec` reads stdin when no prompt arg is supplied. Commit - // prompts include large staged diffs, so argv would exceed Windows and - // some SSH/POSIX command-line limits. - promptDelivery: 'stdin', - buildArgs: ({ model, thinkingLevel }) => [ - 'exec', - // Why: commit-message generation needs text only, not a persisted agent - // session or workspace writes. Match the safe git-text mode used by - // local-first coding agents. - '--ephemeral', - '--skip-git-repo-check', - '-s', - 'read-only', - '--model', - model, - ...(thinkingLevel ? ['-c', `model_reasoning_effort=${thinkingLevel}`] : []) - ], - // `-c` is intentionally absent: Codex accepts repeated overrides. - singletonOptions: [['--model', '-m']], - modelSource: 'dynamic', - modelDiscovery: { - binary: 'codex', - args: ['debug', 'models'], - parse: parseCodexModels - }, - // Why: ordered to match the official `codex` model picker — descending - // by version so the frontier model lands on top and legacy models trail. - models: [ - { - id: 'gpt-5.5', - label: 'GPT-5.5', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.4', - label: 'GPT-5.4', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.4-mini', - label: 'GPT-5.4 Mini', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.3-codex', - label: 'GPT-5.3 Codex', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - // Why: Codex's Spark variant accepts `model_reasoning_effort` (the - // CLI banner reports "reasoning effort: medium" by default); the - // gating that surfaces "model not supported" is on the account - // tier, not the effort flag. - id: 'gpt-5.3-codex-spark', - label: 'GPT-5.3 Codex Spark', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.2', - label: 'GPT-5.2', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - } - ], - defaultModelId: 'gpt-5.5' - }, - opencode: { - id: 'opencode', - label: 'OpenCode', - binary: 'opencode', - // Why: Source Control AI prompts can include large staged diffs; OpenCode - // accepts the prompt on stdin, which avoids cross-platform argv limits. - promptDelivery: 'stdin', - buildArgs: ({ model, thinkingLevel }) => [ - 'run', - '--model', - model, - '--agent', - 'build', - '--format', - 'default', - ...(thinkingLevel ? ['--variant', thinkingLevel] : []) - ], - singletonOptions: [['--model', '-m'], ['--agent'], ['--format'], ['--variant']], - modelSource: 'dynamic', - modelDiscovery: { binary: 'opencode', args: ['models'], parse: parseLineModels }, - models: [ - { - // Why: OpenCode's hosted GPT models can require workspace billing even - // when `opencode models` lists them. This free model is available in - // discovery and works as a usable out-of-the-box default. - id: 'opencode/deepseek-v4-flash-free', - label: 'OpenCode DeepSeek V4 Flash Free' - }, - { - id: 'opencode/gpt-5.4-mini', - label: 'OpenCode GPT 5.4 Mini', - ...withOpenAiThinking('gpt-5.4-mini') - } - ], - defaultModelId: 'opencode/deepseek-v4-flash-free' - }, - pi: { - id: 'pi', - label: 'Pi', - binary: 'pi', - promptDelivery: 'stdin', - buildArgs: ({ model, thinkingLevel }) => [ - '--print', - '--no-session', - '--no-tools', - '--no-extensions', - '--no-skills', - '--no-context-files', - '--mode', - 'text', - '--model', - model, - ...(thinkingLevel ? ['--thinking', thinkingLevel] : []) - ], - modelSource: 'dynamic', - modelDiscovery: { binary: 'pi', args: ['--list-models'], parse: parsePiModels }, - models: [ - { - // Why: Pi commonly authenticates through GitHub Copilot locally; using - // that provider avoids selecting a raw OpenAI model when no key exists. - id: 'github-copilot/gpt-5.4-mini', - label: 'Github Copilot GPT 5.4 Mini', - ...withOpenAiThinking('gpt-5.4-mini') - } - ], - defaultModelId: 'github-copilot/gpt-5.4-mini' - }, - amp: { - id: 'amp', - label: 'Amp', - binary: 'amp', - promptDelivery: 'stdin', - buildArgs: ({ model, thinkingLevel }) => [ - '--execute', - '--no-notifications', - '--no-ide', - '--no-jetbrains', - '--mode', - model, - ...(thinkingLevel ? ['--effort', thinkingLevel] : []) - ], - // Amp selects the model with `--mode`, not `--model`. - singletonOptions: [['--mode']], - modelSource: 'static', - models: [ - { id: 'smart', label: 'Smart' }, - { id: 'rush', label: 'Rush' }, - { - id: 'large', - label: 'Large', - thinkingLevels: BASIC_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'deep', - label: 'Deep', - thinkingLevels: BASIC_THINKING_LEVELS, - defaultThinkingLevel: 'low' - } - ], - defaultModelId: 'smart' - }, - cursor: { - id: 'cursor', - label: 'Cursor', - binary: 'cursor-agent', - promptDelivery: 'argv', - buildArgs: ({ prompt, model }) => [ - '--print', - '--mode', - 'ask', - '--trust', - '--output-format', - 'text', - '--model', - model, - prompt - ], - modelSource: 'dynamic', - modelDiscovery: { binary: 'cursor-agent', args: ['--list-models'], parse: parseCursorModels }, - models: [{ id: 'auto', label: 'Auto' }], - defaultModelId: 'auto' - }, - kimi: { - id: 'kimi', - label: 'Kimi', - binary: 'kimi', - // Why: kimi-code accepts the generation prompt only via --prompt/-p (Claude's - // --print is rejected). Deliver on argv so --prompt receives the text (#11669). - promptDelivery: 'argv', - buildArgs: ({ prompt, model, thinkingLevel }) => [ - '--prompt', - prompt, - '--quiet', - ...(model && model !== 'default' ? ['--model', model] : []), - ...(thinkingLevel === 'on' - ? ['--thinking'] - : thinkingLevel === 'off' - ? ['--no-thinking'] - : []) - ], - modelSource: 'static', - models: [ - { id: 'default', label: 'Config default' }, - { - // Why: Kimi resolves its managed model by provider/model; bare model - // names are rejected by the CLI with "LLM not set". - id: 'kimi-code/kimi-for-coding', - label: 'Kimi K2.6', - thinkingLevels: [ - { id: 'on', label: 'On' }, - { id: 'off', label: 'Off' } - ], - defaultThinkingLevel: 'on' - } - ], - defaultModelId: 'default' - }, - copilot: { - id: 'copilot', - label: 'GitHub Copilot', - binary: 'copilot', - promptDelivery: 'argv', - buildArgs: ({ prompt, model, thinkingLevel }) => [ - '--prompt', - prompt, - '--silent', - '--stream', - 'off', - '--no-custom-instructions', - '--model', - model, - ...(thinkingLevel ? ['--effort', thinkingLevel] : []) - ], - modelSource: 'static', - // Why: Copilot CLI's picker is policy-filtered per account/org. Keep the - // full hosted CLI catalog here so users can select models enabled for them. - models: [ - { id: 'auto', label: 'Auto' }, - { - id: 'claude-haiku-4.5', - label: 'Claude Haiku 4.5' - }, - { - id: 'claude-sonnet-4.5', - label: 'Claude Sonnet 4.5' - }, - { - id: 'claude-sonnet-4.6', - label: 'Claude Sonnet 4.6' - }, - { - id: 'claude-opus-4.5', - label: 'Claude Opus 4.5' - }, - { - id: 'claude-opus-4.6', - label: 'Claude Opus 4.6' - }, - { - id: 'claude-opus-4.6-fast', - label: 'Claude Opus 4.6 Fast' - }, - { - id: 'claude-opus-4.7', - label: 'Claude Opus 4.7' - }, - { - id: 'gpt-4.1', - label: 'GPT-4.1' - }, - { - id: 'gpt-5-mini', - label: 'GPT-5 Mini', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.2', - label: 'GPT-5.2', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.2-codex', - label: 'GPT-5.2 Codex', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.3-codex', - label: 'GPT-5.3 Codex', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.4', - label: 'GPT-5.4', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.4-mini', - label: 'GPT-5.4 Mini', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - }, - { - id: 'gpt-5.5', - label: 'GPT-5.5', - thinkingLevels: OPENAI_THINKING_LEVELS, - defaultThinkingLevel: 'low' - } - ], - defaultModelId: 'gpt-5.4' - }, - antigravity: { - id: 'antigravity', - label: 'Antigravity', - binary: 'agy', - promptDelivery: 'stdin', - buildArgs: ({ model }) => ['--print', '--sandbox', '--model', model], - modelSource: 'dynamic', - modelDiscovery: { binary: 'agy', args: ['models'], parse: parseAntigravityModels }, - models: [ - { id: 'Gemini 3.5 Flash (Medium)', label: 'Gemini 3.5 Flash (Medium)' }, - { id: 'Gemini 3.5 Flash (High)', label: 'Gemini 3.5 Flash (High)' }, - { id: 'Gemini 3.5 Flash (Low)', label: 'Gemini 3.5 Flash (Low)' } - ], - defaultModelId: 'Gemini 3.5 Flash (Medium)' - } + ...buildPrimaryCommitMessageAgentSpecs({ + CLAUDE_THINKING_LEVELS, + OPENAI_THINKING_LEVELS, + parseClaudeModels, + parseCodexModels, + parseLineModels, + parsePiModels, + withOpenAiThinking + }), + ...buildSecondaryCommitMessageAgentSpecs({ + BASIC_THINKING_LEVELS, + OPENAI_THINKING_LEVELS, + parseCursorModels, + parseAntigravityModels + }) } export const DEFAULT_COMMIT_MESSAGE_AGENT_ID: TuiAgent = 'claude' diff --git a/src/shared/commit-message-agent-specs-primary.ts b/src/shared/commit-message-agent-specs-primary.ts new file mode 100644 index 00000000000..e6ba42f775b --- /dev/null +++ b/src/shared/commit-message-agent-specs-primary.ts @@ -0,0 +1,223 @@ +import type { TuiAgent } from './tui-agent' +import type { + CommitMessageAgentSpec, + CommitMessageModel, + ThinkingLevel +} from './commit-message-agent-spec' +import { CLAUDE_MODEL_LIST_ARGS, CLAUDE_MODEL_LIST_STDIN } from './claude-model-list-probe' + +type PrimaryAgentSpecDeps = { + CLAUDE_THINKING_LEVELS: ThinkingLevel[] + OPENAI_THINKING_LEVELS: ThinkingLevel[] + parseClaudeModels: (stdout: string) => CommitMessageModel[] + parseCodexModels: (stdout: string) => CommitMessageModel[] + parseLineModels: (stdout: string) => CommitMessageModel[] + parsePiModels: (stdout: string) => CommitMessageModel[] + withOpenAiThinking: ( + id: string + ) => Pick +} + +export function buildPrimaryCommitMessageAgentSpecs({ + CLAUDE_THINKING_LEVELS, + OPENAI_THINKING_LEVELS, + parseClaudeModels, + parseCodexModels, + parseLineModels, + parsePiModels, + withOpenAiThinking +}: PrimaryAgentSpecDeps): Partial> { + return { + claude: { + id: 'claude', + label: 'Claude', + binary: 'claude', + // Why: diffs can be large and `claude -p` reads from stdin natively when no + // positional prompt is provided. + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + '-p', + '--output-format', + 'text', + '--model', + model, + '--permission-mode', + 'plan', + ...(thinkingLevel ? ['--effort', thinkingLevel] : []) + ], + modelSource: 'dynamic', + // Why: the Claude CLI has no listing subcommand; one list_models control + // request over --print stream-json returns the /model picker catalog. + // Older CLIs answer with a control error and exit 0, keeping the fallback. + modelDiscovery: { + binary: 'claude', + args: [...CLAUDE_MODEL_LIST_ARGS], + stdinPayload: CLAUDE_MODEL_LIST_STDIN, + parse: parseClaudeModels + }, + models: [ + { + // Why: Claude Code aliases track the account/provider's supported + // model IDs; hardcoded version IDs can be rejected by Bedrock/Vertex. + id: 'haiku', + label: 'Haiku' + }, + { + id: 'sonnet', + label: 'Sonnet', + thinkingLevels: CLAUDE_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'opus', + label: 'Opus', + thinkingLevels: CLAUDE_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ], + defaultModelId: 'sonnet' + }, + codex: { + id: 'codex', + label: 'Codex', + binary: 'codex', + // Why: `codex exec` reads stdin when no prompt arg is supplied. Commit + // prompts include large staged diffs, so argv would exceed Windows and + // some SSH/POSIX command-line limits. + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + 'exec', + // Why: commit-message generation needs text only, not a persisted agent + // session or workspace writes. Match the safe git-text mode used by + // local-first coding agents. + '--ephemeral', + '--skip-git-repo-check', + '-s', + 'read-only', + '--model', + model, + ...(thinkingLevel ? ['-c', `model_reasoning_effort=${thinkingLevel}`] : []) + ], + // `-c` is intentionally absent: Codex accepts repeated overrides. + singletonOptions: [['--model', '-m']], + modelSource: 'dynamic', + modelDiscovery: { + binary: 'codex', + args: ['debug', 'models'], + parse: parseCodexModels + }, + // Why: ordered to match the official `codex` model picker — descending + // by version so the frontier model lands on top and legacy models trail. + models: [ + { + id: 'gpt-5.5', + label: 'GPT-5.5', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.4', + label: 'GPT-5.4', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.4-mini', + label: 'GPT-5.4 Mini', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.3-codex', + label: 'GPT-5.3 Codex', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + // Why: Codex's Spark variant accepts `model_reasoning_effort` (the + // CLI banner reports "reasoning effort: medium" by default); the + // gating that surfaces "model not supported" is on the account + // tier, not the effort flag. + id: 'gpt-5.3-codex-spark', + label: 'GPT-5.3 Codex Spark', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.2', + label: 'GPT-5.2', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ], + defaultModelId: 'gpt-5.5' + }, + opencode: { + id: 'opencode', + label: 'OpenCode', + binary: 'opencode', + // Why: Source Control AI prompts can include large staged diffs; OpenCode + // accepts the prompt on stdin, which avoids cross-platform argv limits. + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + 'run', + '--model', + model, + '--agent', + 'build', + '--format', + 'default', + ...(thinkingLevel ? ['--variant', thinkingLevel] : []) + ], + singletonOptions: [['--model', '-m'], ['--agent'], ['--format'], ['--variant']], + modelSource: 'dynamic', + modelDiscovery: { binary: 'opencode', args: ['models'], parse: parseLineModels }, + models: [ + { + // Why: OpenCode's hosted GPT models can require workspace billing even + // when `opencode models` lists them. This free model is available in + // discovery and works as a usable out-of-the-box default. + id: 'opencode/deepseek-v4-flash-free', + label: 'OpenCode DeepSeek V4 Flash Free' + }, + { + id: 'opencode/gpt-5.4-mini', + label: 'OpenCode GPT 5.4 Mini', + ...withOpenAiThinking('gpt-5.4-mini') + } + ], + defaultModelId: 'opencode/deepseek-v4-flash-free' + }, + pi: { + id: 'pi', + label: 'Pi', + binary: 'pi', + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + '--print', + '--no-session', + '--no-tools', + '--no-extensions', + '--no-skills', + '--no-context-files', + '--mode', + 'text', + '--model', + model, + ...(thinkingLevel ? ['--thinking', thinkingLevel] : []) + ], + modelSource: 'dynamic', + modelDiscovery: { binary: 'pi', args: ['--list-models'], parse: parsePiModels }, + models: [ + { + // Why: Pi commonly authenticates through GitHub Copilot locally; using + // that provider avoids selecting a raw OpenAI model when no key exists. + id: 'github-copilot/gpt-5.4-mini', + label: 'Github Copilot GPT 5.4 Mini', + ...withOpenAiThinking('gpt-5.4-mini') + } + ], + defaultModelId: 'github-copilot/gpt-5.4-mini' + } + } +} diff --git a/src/shared/commit-message-agent-specs-secondary.ts b/src/shared/commit-message-agent-specs-secondary.ts new file mode 100644 index 00000000000..e22fce018f8 --- /dev/null +++ b/src/shared/commit-message-agent-specs-secondary.ts @@ -0,0 +1,227 @@ +import type { TuiAgent } from './tui-agent' +import type { + CommitMessageAgentSpec, + CommitMessageModel, + ThinkingLevel +} from './commit-message-agent-spec' + +type SecondaryAgentSpecDeps = { + BASIC_THINKING_LEVELS: ThinkingLevel[] + OPENAI_THINKING_LEVELS: ThinkingLevel[] + parseCursorModels: (stdout: string) => CommitMessageModel[] + parseAntigravityModels: (stdout: string) => CommitMessageModel[] +} + +export function buildSecondaryCommitMessageAgentSpecs({ + BASIC_THINKING_LEVELS, + OPENAI_THINKING_LEVELS, + parseCursorModels, + parseAntigravityModels +}: SecondaryAgentSpecDeps): Partial> { + return { + amp: { + id: 'amp', + label: 'Amp', + binary: 'amp', + promptDelivery: 'stdin', + buildArgs: ({ model, thinkingLevel }) => [ + '--execute', + '--no-notifications', + '--no-ide', + '--no-jetbrains', + '--mode', + model, + ...(thinkingLevel ? ['--effort', thinkingLevel] : []) + ], + // Amp selects the model with `--mode`, not `--model`. + singletonOptions: [['--mode']], + modelSource: 'static', + models: [ + { id: 'smart', label: 'Smart' }, + { id: 'rush', label: 'Rush' }, + { + id: 'large', + label: 'Large', + thinkingLevels: BASIC_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'deep', + label: 'Deep', + thinkingLevels: BASIC_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ], + defaultModelId: 'smart' + }, + cursor: { + id: 'cursor', + label: 'Cursor', + binary: 'cursor-agent', + promptDelivery: 'argv', + buildArgs: ({ prompt, model }) => [ + '--print', + '--mode', + 'ask', + '--trust', + '--output-format', + 'text', + '--model', + model, + prompt + ], + modelSource: 'dynamic', + modelDiscovery: { binary: 'cursor-agent', args: ['--list-models'], parse: parseCursorModels }, + models: [{ id: 'auto', label: 'Auto' }], + defaultModelId: 'auto' + }, + kimi: { + id: 'kimi', + label: 'Kimi', + binary: 'kimi', + // Why: kimi-code accepts the generation prompt only via --prompt/-p (Claude's + // --print is rejected). Deliver on argv so --prompt receives the text (#11669). + promptDelivery: 'argv', + buildArgs: ({ prompt, model, thinkingLevel }) => [ + '--prompt', + prompt, + '--quiet', + ...(model && model !== 'default' ? ['--model', model] : []), + ...(thinkingLevel === 'on' + ? ['--thinking'] + : thinkingLevel === 'off' + ? ['--no-thinking'] + : []) + ], + modelSource: 'static', + models: [ + { id: 'default', label: 'Config default' }, + { + // Why: Kimi resolves its managed model by provider/model; bare model + // names are rejected by the CLI with "LLM not set". + id: 'kimi-code/kimi-for-coding', + label: 'Kimi K2.6', + thinkingLevels: [ + { id: 'on', label: 'On' }, + { id: 'off', label: 'Off' } + ], + defaultThinkingLevel: 'on' + } + ], + defaultModelId: 'default' + }, + copilot: { + id: 'copilot', + label: 'GitHub Copilot', + binary: 'copilot', + promptDelivery: 'argv', + buildArgs: ({ prompt, model, thinkingLevel }) => [ + '--prompt', + prompt, + '--silent', + '--stream', + 'off', + '--no-custom-instructions', + '--model', + model, + ...(thinkingLevel ? ['--effort', thinkingLevel] : []) + ], + modelSource: 'static', + // Why: Copilot CLI's picker is policy-filtered per account/org. Keep the + // full hosted CLI catalog here so users can select models enabled for them. + models: [ + { id: 'auto', label: 'Auto' }, + { + id: 'claude-haiku-4.5', + label: 'Claude Haiku 4.5' + }, + { + id: 'claude-sonnet-4.5', + label: 'Claude Sonnet 4.5' + }, + { + id: 'claude-sonnet-4.6', + label: 'Claude Sonnet 4.6' + }, + { + id: 'claude-opus-4.5', + label: 'Claude Opus 4.5' + }, + { + id: 'claude-opus-4.6', + label: 'Claude Opus 4.6' + }, + { + id: 'claude-opus-4.6-fast', + label: 'Claude Opus 4.6 Fast' + }, + { + id: 'claude-opus-4.7', + label: 'Claude Opus 4.7' + }, + { + id: 'gpt-4.1', + label: 'GPT-4.1' + }, + { + id: 'gpt-5-mini', + label: 'GPT-5 Mini', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.2', + label: 'GPT-5.2', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.2-codex', + label: 'GPT-5.2 Codex', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.3-codex', + label: 'GPT-5.3 Codex', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.4', + label: 'GPT-5.4', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.4-mini', + label: 'GPT-5.4 Mini', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + }, + { + id: 'gpt-5.5', + label: 'GPT-5.5', + thinkingLevels: OPENAI_THINKING_LEVELS, + defaultThinkingLevel: 'low' + } + ], + defaultModelId: 'gpt-5.4' + }, + antigravity: { + id: 'antigravity', + label: 'Antigravity', + binary: 'agy', + promptDelivery: 'stdin', + buildArgs: ({ model }) => ['--print', '--sandbox', '--model', model], + modelSource: 'dynamic', + modelDiscovery: { binary: 'agy', args: ['models'], parse: parseAntigravityModels }, + models: [ + { id: 'Gemini 3.5 Flash (Medium)', label: 'Gemini 3.5 Flash (Medium)' }, + { id: 'Gemini 3.5 Flash (High)', label: 'Gemini 3.5 Flash (High)' }, + { id: 'Gemini 3.5 Flash (Low)', label: 'Gemini 3.5 Flash (Low)' } + ], + defaultModelId: 'Gemini 3.5 Flash (Medium)' + } + } +} diff --git a/src/shared/commit-message-model-parsers.ts b/src/shared/commit-message-model-parsers.ts new file mode 100644 index 00000000000..036557d5b89 --- /dev/null +++ b/src/shared/commit-message-model-parsers.ts @@ -0,0 +1,248 @@ +import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { parseClaudeModelList } from './claude-model-list-probe' +import { labelFromModelId } from './model-id-label' +import type { CommitMessageModel, ThinkingLevel } from './commit-message-agent-spec' + +export const COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS = { + structuralTokens: 64 * 1024, + nestingDepth: 16 +} as const + +export const BASIC_THINKING_LEVELS: ThinkingLevel[] = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' } +] + +export const OPENAI_THINKING_LEVELS: ThinkingLevel[] = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' } +] + +export const CLAUDE_THINKING_LEVELS: ThinkingLevel[] = [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' }, + { id: 'max', label: 'Max' } +] + +function uniqueModels(models: CommitMessageModel[]): CommitMessageModel[] { + const seen = new Set() + return models.filter((model) => { + if (!model.id || seen.has(model.id)) { + return false + } + seen.add(model.id) + return true + }) +} + +function* iterateModelOutputLines(output: string): Generator { + let lineStart = 0 + + for (let index = 0; index < output.length; index++) { + const code = output.charCodeAt(index) + if (code !== 10 && code !== 13) { + continue + } + + yield output.slice(lineStart, index) + if (code === 13 && output.charCodeAt(index + 1) === 10) { + index++ + } + lineStart = index + 1 + } + + if (lineStart <= output.length) { + yield output.slice(lineStart) + } +} + +export function withOpenAiThinking( + id: string +): Pick { + return /(?:gpt-5|codex)/i.test(id) + ? { thinkingLevels: OPENAI_THINKING_LEVELS, defaultThinkingLevel: 'low' } + : {} +} + +export function parseClaudeModels(stdout: string): CommitMessageModel[] { + return uniqueModels( + parseClaudeModelList(stdout).map((model) => { + const thinkingLevels = CLAUDE_THINKING_LEVELS.filter((level) => + model.effortLevels.includes(level.id) + ) + return { + id: model.id, + label: model.label, + ...(model.description ? { description: model.description } : {}), + ...(thinkingLevels.length > 0 + ? { + thinkingLevels, + defaultThinkingLevel: thinkingLevels.some((level) => level.id === 'low') + ? 'low' + : thinkingLevels[0].id + } + : {}), + ...(model.supportsFastMode ? { supportsFastMode: true } : {}) + } + }) + ) +} + +export function parseCodexModels(stdout: string): CommitMessageModel[] { + try { + assertJsonTextStructureWithinLimits(stdout, COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS) + const parsed = JSON.parse(stdout) as { + models?: { + slug?: string + display_name?: string + supported_reasoning_levels?: { effort?: string }[] + default_reasoning_level?: string + }[] + } + return uniqueModels( + (parsed.models ?? []) + .filter((model) => model.slug && model.display_name) + .map((model) => ({ + id: model.slug!, + label: model.display_name!, + ...(model.supported_reasoning_levels?.length + ? { + thinkingLevels: model.supported_reasoning_levels + .map((level) => level.effort) + .filter((effort): effort is string => Boolean(effort)) + .map((effort) => ({ + id: effort, + label: effort === 'xhigh' ? 'Extra High' : labelFromModelId(effort) + })), + defaultThinkingLevel: model.default_reasoning_level ?? 'low' + } + : {}) + })) + ) + } catch { + return [] + } +} + +export function parseLineModels(stdout: string): CommitMessageModel[] { + const models: CommitMessageModel[] = [] + for (const rawLine of iterateModelOutputLines(stdout)) { + const id = rawLine.trim() + if (id.length === 0 || id.includes(' ')) { + continue + } + models.push({ + id, + label: labelFromModelId(id), + ...withOpenAiThinking(id) + }) + } + return uniqueModels(models) +} + +export function parsePiModels(stdout: string): CommitMessageModel[] { + const models: CommitMessageModel[] = [] + for (const rawLine of iterateModelOutputLines(stdout)) { + const parts = getPiModelTableFields(rawLine, 6) + if (parts.length < 6 || parts[0] === 'provider') { + continue + } + + const [provider, model, , , thinking] = parts + const id = `${provider}/${model}` + models.push({ + id, + label: `${labelFromModelId(provider)} ${labelFromModelId(model)}`, + ...(thinking === 'yes' + ? { + thinkingLevels: [ + { id: 'off', label: 'Off' }, + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' } + ], + defaultThinkingLevel: 'low' + } + : {}) + }) + } + return uniqueModels(models) +} + +// Why: model discovery output can include paste-sized noisy lines; only the first fields matter. +function getPiModelTableFields(line: string, maxFields: number): string[] { + const fields: string[] = [] + let tokenStart = -1 + + for (let index = 0; index <= line.length; index += 1) { + const isEnd = index === line.length + if (!isEnd && !isPiModelTableWhitespace(line.charCodeAt(index))) { + if (tokenStart === -1) { + tokenStart = index + } + continue + } + if (tokenStart !== -1) { + fields.push(line.slice(tokenStart, index)) + tokenStart = -1 + if (fields.length >= maxFields) { + break + } + } + } + + return fields +} + +function isPiModelTableWhitespace(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + code === 160 || + code === 5760 || + (code >= 8192 && code <= 8202) || + code === 8232 || + code === 8233 || + code === 8239 || + code === 8287 || + code === 12288 || + code === 65279 + ) +} + +export function parseCursorModels(stdout: string): CommitMessageModel[] { + const models: CommitMessageModel[] = [] + for (const rawLine of iterateModelOutputLines(stdout)) { + const match = /^([^\s]+)\s+-\s+(.+)$/.exec(rawLine.trim()) + if (!match) { + continue + } + models.push({ + id: match[1], + label: match[2].replace(/\s+\((?:default|current)\)$/i, ''), + ...withOpenAiThinking(match[1]) + }) + } + return uniqueModels(models) +} + +export function parseAntigravityModels(stdout: string): CommitMessageModel[] { + const models: CommitMessageModel[] = [] + for (const rawLine of iterateModelOutputLines(stdout)) { + const id = rawLine.trim() + if (id.length === 0) { + continue + } + models.push({ + id, + label: id + }) + } + return uniqueModels(models) +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 928e774eaa3..f19041b9167 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: default persisted settings live in one schema-shaped object so migrations and tests compare against one source of truth. */ import type { GlobalSettings } from './global-settings-types' import type { NotificationSettings } from './notification-settings-types' import type { OnboardingChecklistState, OnboardingState } from './onboarding-state-types' @@ -9,28 +8,14 @@ import type { AgentActivityDisplayMode } from './ui-chrome-types' import type { WorkspaceSessionState } from './workspace-session-state-types' import { EMPTY_CODEX_RESET_CREDIT_ATTEMPT_LEDGER } from './codex-reset-credit-attempt-ledger' import { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' -import { DEFAULT_TERMINAL_FONT_WEIGHT, DEFAULT_TERMINAL_FONT_WEIGHT_BOLD } from './terminal-fonts' -import { getDefaultTerminalQuickCommands } from './terminal-quick-commands' import type { VoiceSettings } from './speech-types' import { cloneDefaultWorkspaceStatuses } from './workspace-statuses' -import { TASK_PROVIDERS } from './task-providers' import { DEFAULT_WORKTREE_CARD_PROPERTIES } from './worktree/card-properties' -import { getDefaultSourceControlAiSettings } from './source-control-ai' -import { DEFAULT_APP_ICON_ID } from './app-icon' -import { DEFAULT_OPEN_IN_APPLICATIONS } from './open-in-applications' -import { DEFAULT_BROWSER_PAGE_ZOOM_LEVEL } from './browser-page-zoom' -import { DEFAULT_DISABLED_TUI_AGENTS } from './tui-agent-selection' -import { DEFAULT_TUI_AGENT_ARGS, DEFAULT_TUI_AGENT_ENV } from './tui-agent-launch-defaults' -import { UI_LANGUAGE_SYSTEM } from './ui-language' -import { - DEFAULT_LEFT_SIDEBAR_TINT_COLOR, - DEFAULT_LEFT_SIDEBAR_TINT_OPACITY -} from './left-sidebar-appearance' -import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' -import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' -import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' import { DEFAULT_USAGE_PERCENTAGE_DISPLAY } from './usage-percentage-display' import { DEFAULT_STATUS_BAR_USAGE_MODE } from './status-bar-usage-mode' +import { buildDefaultSettings } from './default-global-settings' +import { DEFAULT_SETUP_AGENT_STARTUP_POLICY } from './setup-agent-startup-policy' +import { DEFAULT_BROWSER_PAGE_ZOOM_LEVEL } from './browser-page-zoom' export { DEFAULT_STATUS_BAR_ITEMS } from './status-bar-defaults' export { @@ -171,232 +156,19 @@ export function getDefaultWorkspaceDir(homeDir: string): string { } export function getDefaultSettings(homedir: string): GlobalSettings { - return { + return buildDefaultSettings({ workspaceDir: getDefaultWorkspaceDir(homedir), - worktreeVisibilityDefaults: { external: 'hide' }, - nestWorkspaces: true, - workspaceDirHistory: [], - refreshLocalBaseRefOnWorktreeCreate: false, - localBaseRefSuggestionDismissed: false, - autoRenameBranchFromWork: true, - autoRenameBranchFromWorkDefaultedOn: true, - branchPrefix: 'git-username', - branchPrefixCustom: '', - theme: 'system', - leftSidebarAppearanceMode: 'default', - leftSidebarTintColor: DEFAULT_LEFT_SIDEBAR_TINT_COLOR, - leftSidebarTintOpacity: DEFAULT_LEFT_SIDEBAR_TINT_OPACITY, - uiLanguage: UI_LANGUAGE_SYSTEM, - appIcon: DEFAULT_APP_ICON_ID, appFontFamily: DEFAULT_APP_FONT_FAMILY, - editorAutoSave: false, editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, - editorMinimapEnabled: false, - // Why empty: the editor keeps following the terminal font unless the user opts in. - editorFontFamily: '', - editorWordWrap: true, - richMarkdownSpellcheckEnabled: true, - markdownReviewToolsEnabled: true, primarySelectionMiddleClickPaste: getDefaultPrimarySelectionMiddleClickPaste(), - primarySelectionMiddleClickPasteDefaultedForLinux: + primarySelectionDefaultedForLinux: typeof process !== 'undefined' && process.platform === 'linux', - primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: - getDefaultPrimarySelectionMiddleClickPaste(), - terminalFontSize: 14, terminalFontFamily: defaultTerminalFontFamily(), - terminalFontWeight: DEFAULT_TERMINAL_FONT_WEIGHT, - terminalFontWeightBold: DEFAULT_TERMINAL_FONT_WEIGHT_BOLD, - terminalLineHeight: 1, - terminalScrollSensitivity: 1.15, - terminalFastScrollSensitivity: 5, - terminalTuiScrollSensitivity: 1, - terminalTuiScrollSensitivityDefaultedToOne: true, - // Why: "auto" uses WebGL when supported, falling back to DOM on renderer failure or software/unknown GPU. - terminalGpuAcceleration: 'auto', - // Why 'auto': enable ligatures only for known ligature fonts, never forced. Resolver in shared/terminal-ligatures.ts. - terminalLigatures: 'auto', - terminalCursorStyle: 'block', - terminalCursorStyleDefaultedToBlock: true, - terminalCursorBlink: true, - terminalThemeDark: 'Ghostty Default Style Dark', - terminalDividerColorDark: '#3f3f46', - terminalUseSeparateLightTheme: true, - terminalThemeLight: 'Builtin Tango Light', - terminalCustomThemes: [], - terminalDividerColorLight: '#d4d4d8', terminalInactivePaneOpacity: DEFAULT_TERMINAL_INACTIVE_PANE_OPACITY, - terminalActivePaneOpacity: 1, - terminalPaneOpacityTransitionMs: 140, - terminalDividerThicknessPx: 3, - // Why: Windows paste-on-right-click matches native convention; macOS/Linux keep right-click for the context menu. terminalRightClickToPaste: getDefaultTerminalRightClickToPaste(), - terminalRightClickToPasteDefaultedForPlatform: true, - terminalWindowsShell: 'powershell.exe', - terminalWindowsWslDistro: null, - localAccountRuntime: 'auto', - localAccountRuntimeDefaultedToAutoForAllUsers: true, - localAccountWslDistro: null, - localWindowsRuntimeDefault: { kind: 'windows-host' }, - // Why: prefer modern PowerShell when installed, falling back to inbox Windows PowerShell. - terminalWindowsPowerShellImplementation: 'auto', - terminalMouseHideWhileTyping: false, - terminalQuickCommands: getDefaultTerminalQuickCommands(), - // Why: opt-in only, matching Ghostty's default (upgrades never enable it unexpectedly). - terminalFocusFollowsMouse: false, - windowBackgroundBlur: false, - minimizeToTrayOnClose: false, - // Why: default-on everywhere so it round-trips across platforms; only darwin acts on it. - showMenuBarIcon: true, - terminalClipboardOnSelect: false, - // Why: default on so Zellij/tmux/nvim copy works out of the box. Query - // replies stay disabled and payload size is capped in the OSC 52 handler. - // This default only covers new profiles; existing ones persisted `false` - // and are flipped once by the stamp below (shared/osc52-clipboard-settings.ts, - // applied by both the Electron store and the web client's localStorage store). - terminalAllowOsc52Clipboard: true, - terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true, - claudeAgentTeamsMode: 'off', - setupScriptLaunchMode: 'new-tab', - terminalScrollbackRows: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, - httpProxyUrl: '', - httpProxyBypassRules: '', - electronHttp1CompatibilityMode: false, - openLinksInApp: false, - localhostWorktreeLabelsEnabled: false, - openLinksInAppPreferencePrompted: false, - openLinksInAppModifierInverts: false, - terminalLinkActionPopoverEnabled: true, - openAgentTabsInChatByDefault: false, - experimentalNativeChat: false, - nativeChatSessionOptions: {}, - openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS], - rightSidebarOpenByDefault: true, - showGitIgnoredFiles: true, - sourceControlViewMode: 'list', - sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER, - sourceControlCompareAgainstUpstream: false, - showTitlebarAppName: true, - showTasksButton: true, - showAutomationsButton: true, - artifactsEnabled: true, - artifactSharingEnabled: false, - agentSkillSharingEnabled: false, - nestedWorkerMaxDepth: 1, - showArtifactsButton: false, - showSkillsButton: false, - showMobileButton: true, - showPinnedWorktreesInGroups: false, - ctrlTabOrderMode: 'mru', - // Why: Orca-first keeps core shortcuts working from a focused terminal; TUI-ownership users opt in. - terminalShortcutPolicy: 'orca-first', - floatingTerminalEnabled: true, - browserClientHostedRemoteEnabled: true, - floatingTerminalDefaultedForAllUsers: true, - floatingTerminalCwd: '~', - floatingTerminalTrustedCwds: [], - floatingTerminalCwdMigratedToAppWorkspace: true, - floatingTerminalTriggerLocation: 'floating-button', notifications: getDefaultNotificationSettings(), - diffDefaultView: 'inline', - diffWordWrap: false, - combinedDiffFileTreeVisibleByDefault: false, - prBotAuthorOverrides: [], - promptCacheTimerEnabled: false, - promptCacheTtlMs: 300_000, - codexManagedAccounts: [], - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }, - claudeManagedAccounts: [], - activeClaudeManagedAccountId: null, - terminalScopeHistoryByWorktree: true, - terminalHiddenViewParking: true, - // C1 kill switches — runtime reads stay `!== false` so older persisted - // settings objects (which omit them) keep the default-on behavior. - terminalSshViewParking: true, - terminalHiddenWorktreeRetentionBudget: true, - browserGuestWorktreeRetentionBudget: true, - terminalMainSideEffectAuthority: true, - terminalHiddenDeliveryGate: true, - terminalModelQueryAuthority: true, - defaultTuiAgent: null, - disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS], - pluginSystemEnabled: false, - disabledPlugins: [], - pluginConsents: {}, - devPluginPaths: [], - claudeAgentTeamsDefaultDisabledMigrated: true, - skipDeleteWorktreeConfirm: false, - skipCloseTerminalWithRunningProcessConfirm: false, - skipDeleteAutomationConfirm: false, - skipDeleteArtifactConfirm: false, - skipCodexRateLimitResetConfirm: false, - defaultTaskViewPreset: 'all', - defaultTaskSource: 'github', - visibleTaskProviders: [...TASK_PROVIDERS], - visibleTaskProvidersDefaultedForJira: true, - defaultRepoSelection: null, - defaultLinearTeamSelection: null, - opencodeSessionCookie: '', - opencodeWorkspaceId: '', - minimaxGroupId: '', - minimaxUsageModels: 'general', - geminiCliOAuthEnabled: false, - agentCmdOverrides: {}, - agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, - agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV }, - agentYoloDefaultsMigrated: true, - agentStatusHooksEnabled: true, - tabAutoGenerateTitle: false, - confirmClosePinnedTab: true, - keepComputerAwakeWhileAgentsRun: false, - // Why: 'auto' probes keyboard layout so non-US users can type Option chars like @/€/[ out of the box (issue #903). See src/renderer/src/lib/keyboard-layout/*. - terminalMacOptionAsAlt: 'auto', - terminalMacOptionAsAltMigrated: false, - terminalJISYenToBackslash: false, - experimentalMobile: false, - mobileEmulatorEnabled: true, - mobileEmulatorDefaultDeviceUdid: null, - androidSdkPath: null, - // Why: indefinite hold — the "Restore" banner is the explicit return action, no wall-clock guess. See docs/mobile-fit-hold.md. - mobileAutoRestoreFitMs: null, - // Why: Anywhere (Relay + local) is the default; local-only is written only on explicit same-network choice. - mobilePairingConnectionMode: 'automatic', - mobilePairingCustomAddress: null, - mobilePairingCustomAddresses: [], - // Why: off keeps the cosmetic overlay unmounted for users who never opt in. - experimentalPet: false, - experimentalActivity: false, - experimentalActivityDefaultedOffForAllUsers: true, - experimentalTerminalAttention: false, - experimentalAgentHibernation: false, - agentHibernationIdleMs: 30 * 60 * 1000, - experimentalNewWorktreeCardStyle: false, - experimentalEphemeralVms: false, - compactWorktreeCards: false, - // Why: local desktop stays the default until the user picks a saved runtime environment. - activeRuntimeEnvironmentId: null, - // Why: hydrate a stable empty shape so renderer optional-chained reads never hit undefined. - githubProjects: { - pinned: [], - recent: [], - lastViewByProject: {}, - activeProject: null - }, - // Why: keep agent/model maps empty so first use follows the default agent's model, not a frozen stale choice. - commitMessageAi: { - enabled: true, - agentId: null, - selectedModelByAgent: {}, - discoveredModelsByAgent: {}, - selectedModelByAgentByHost: {}, - discoveredModelsByAgentByHost: {}, - selectedThinkingByModel: {}, - customPrompt: '', - customAgentCommand: '' - }, - sourceControlAi: getDefaultSourceControlAiSettings(), voice: getDefaultVoiceSettings() - } + }) } export function getDefaultVoiceSettings(): VoiceSettings { diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts new file mode 100644 index 00000000000..a501dfd6ec6 --- /dev/null +++ b/src/shared/default-global-settings.ts @@ -0,0 +1,257 @@ +import type { GlobalSettings } from './global-settings-types' +import type { NotificationSettings } from './notification-settings-types' +import type { VoiceSettings } from './speech-types' +import { DEFAULT_TERMINAL_FONT_WEIGHT, DEFAULT_TERMINAL_FONT_WEIGHT_BOLD } from './terminal-fonts' +import { getDefaultTerminalQuickCommands } from './terminal-quick-commands' +import { TASK_PROVIDERS } from './task-providers' +import { getDefaultSourceControlAiSettings } from './source-control-ai' +import { DEFAULT_APP_ICON_ID } from './app-icon' +import { DEFAULT_OPEN_IN_APPLICATIONS } from './open-in-applications' +import { DEFAULT_DISABLED_TUI_AGENTS } from './tui-agent-selection' +import { DEFAULT_TUI_AGENT_ARGS, DEFAULT_TUI_AGENT_ENV } from './tui-agent-launch-defaults' +import { UI_LANGUAGE_SYSTEM } from './ui-language' +import { + DEFAULT_LEFT_SIDEBAR_TINT_COLOR, + DEFAULT_LEFT_SIDEBAR_TINT_OPACITY +} from './left-sidebar-appearance' +import { DEFAULT_SOURCE_CONTROL_GROUP_ORDER } from './source-control-group-order' +import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' + +export function buildDefaultSettings(args: { + workspaceDir: string + appFontFamily: string + editorAutoSaveDelayMs: number + primarySelectionMiddleClickPaste: boolean + primarySelectionDefaultedForLinux: boolean + terminalFontFamily: string + terminalInactivePaneOpacity: number + terminalRightClickToPaste: boolean + notifications: NotificationSettings + voice: VoiceSettings +}): GlobalSettings { + return { + workspaceDir: args.workspaceDir, + worktreeVisibilityDefaults: { external: 'hide' }, + nestWorkspaces: true, + workspaceDirHistory: [], + refreshLocalBaseRefOnWorktreeCreate: false, + localBaseRefSuggestionDismissed: false, + autoRenameBranchFromWork: true, + autoRenameBranchFromWorkDefaultedOn: true, + branchPrefix: 'git-username', + branchPrefixCustom: '', + theme: 'system', + leftSidebarAppearanceMode: 'default', + leftSidebarTintColor: DEFAULT_LEFT_SIDEBAR_TINT_COLOR, + leftSidebarTintOpacity: DEFAULT_LEFT_SIDEBAR_TINT_OPACITY, + uiLanguage: UI_LANGUAGE_SYSTEM, + appIcon: DEFAULT_APP_ICON_ID, + appFontFamily: args.appFontFamily, + editorAutoSave: false, + editorAutoSaveDelayMs: args.editorAutoSaveDelayMs, + editorMinimapEnabled: false, + // Why empty: the editor keeps following the terminal font unless the user opts in. + editorFontFamily: '', + editorWordWrap: true, + richMarkdownSpellcheckEnabled: true, + markdownReviewToolsEnabled: true, + primarySelectionMiddleClickPaste: args.primarySelectionMiddleClickPaste, + primarySelectionMiddleClickPasteDefaultedForLinux: args.primarySelectionDefaultedForLinux, + primarySelectionMiddleClickPasteDefaultedForTerminalDefaults: + args.primarySelectionMiddleClickPaste, + terminalFontSize: 14, + terminalFontFamily: args.terminalFontFamily, + terminalFontWeight: DEFAULT_TERMINAL_FONT_WEIGHT, + terminalFontWeightBold: DEFAULT_TERMINAL_FONT_WEIGHT_BOLD, + terminalLineHeight: 1, + terminalScrollSensitivity: 1.15, + terminalFastScrollSensitivity: 5, + terminalTuiScrollSensitivity: 1, + terminalTuiScrollSensitivityDefaultedToOne: true, + // Why: "auto" uses WebGL when supported, falling back to DOM on renderer failure or software/unknown GPU. + terminalGpuAcceleration: 'auto', + // Why 'auto': enable ligatures only for known ligature fonts, never forced. Resolver in shared/terminal-ligatures.ts. + terminalLigatures: 'auto', + terminalCursorStyle: 'block', + terminalCursorStyleDefaultedToBlock: true, + terminalCursorBlink: true, + terminalThemeDark: 'Ghostty Default Style Dark', + terminalDividerColorDark: '#3f3f46', + terminalUseSeparateLightTheme: true, + terminalThemeLight: 'Builtin Tango Light', + terminalCustomThemes: [], + terminalDividerColorLight: '#d4d4d8', + terminalInactivePaneOpacity: args.terminalInactivePaneOpacity, + terminalActivePaneOpacity: 1, + terminalPaneOpacityTransitionMs: 140, + terminalDividerThicknessPx: 3, + // Why: Windows paste-on-right-click matches native convention; macOS/Linux keep right-click for the context menu. + terminalRightClickToPaste: args.terminalRightClickToPaste, + terminalRightClickToPasteDefaultedForPlatform: true, + terminalWindowsShell: 'powershell.exe', + terminalWindowsWslDistro: null, + localAccountRuntime: 'auto', + localAccountRuntimeDefaultedToAutoForAllUsers: true, + localAccountWslDistro: null, + localWindowsRuntimeDefault: { kind: 'windows-host' }, + // Why: prefer modern PowerShell when installed, falling back to inbox Windows PowerShell. + terminalWindowsPowerShellImplementation: 'auto', + terminalMouseHideWhileTyping: false, + terminalQuickCommands: getDefaultTerminalQuickCommands(), + // Why: opt-in only, matching Ghostty's default (upgrades never enable it unexpectedly). + terminalFocusFollowsMouse: false, + windowBackgroundBlur: false, + minimizeToTrayOnClose: false, + // Why: default-on everywhere so it round-trips across platforms; only darwin acts on it. + showMenuBarIcon: true, + terminalClipboardOnSelect: false, + // Why: default on so Zellij/tmux/nvim copy works out of the box. Query + // replies stay disabled and payload size is capped in the OSC 52 handler. + // This default only covers new profiles; existing ones persisted `false` + // and are flipped once by the stamp below (shared/osc52-clipboard-settings.ts, + // applied by both the Electron store and the web client's localStorage store). + terminalAllowOsc52Clipboard: true, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true, + claudeAgentTeamsMode: 'off', + setupScriptLaunchMode: 'new-tab', + terminalScrollbackRows: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, + httpProxyUrl: '', + httpProxyBypassRules: '', + electronHttp1CompatibilityMode: false, + openLinksInApp: false, + localhostWorktreeLabelsEnabled: false, + openLinksInAppPreferencePrompted: false, + openLinksInAppModifierInverts: false, + terminalLinkActionPopoverEnabled: true, + openAgentTabsInChatByDefault: false, + experimentalNativeChat: false, + nativeChatSessionOptions: {}, + openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS], + rightSidebarOpenByDefault: true, + showGitIgnoredFiles: true, + sourceControlViewMode: 'list', + sourceControlGroupOrder: DEFAULT_SOURCE_CONTROL_GROUP_ORDER, + sourceControlCompareAgainstUpstream: false, + showTitlebarAppName: true, + showTasksButton: true, + showAutomationsButton: true, + artifactsEnabled: true, + artifactSharingEnabled: false, + agentSkillSharingEnabled: false, + nestedWorkerMaxDepth: 1, + showArtifactsButton: false, + showSkillsButton: false, + showMobileButton: true, + showPinnedWorktreesInGroups: false, + ctrlTabOrderMode: 'mru', + // Why: Orca-first keeps core shortcuts working from a focused terminal; TUI-ownership users opt in. + terminalShortcutPolicy: 'orca-first', + floatingTerminalEnabled: true, + browserClientHostedRemoteEnabled: true, + floatingTerminalDefaultedForAllUsers: true, + floatingTerminalCwd: '~', + floatingTerminalTrustedCwds: [], + floatingTerminalCwdMigratedToAppWorkspace: true, + floatingTerminalTriggerLocation: 'floating-button', + notifications: args.notifications, + diffDefaultView: 'inline', + diffWordWrap: false, + combinedDiffFileTreeVisibleByDefault: false, + prBotAuthorOverrides: [], + promptCacheTimerEnabled: false, + promptCacheTtlMs: 300_000, + codexManagedAccounts: [], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: {} }, + claudeManagedAccounts: [], + activeClaudeManagedAccountId: null, + terminalScopeHistoryByWorktree: true, + terminalHiddenViewParking: true, + // C1 kill switches — runtime reads stay `!== false` so older persisted + // settings objects (which omit them) keep the default-on behavior. + terminalSshViewParking: true, + terminalHiddenWorktreeRetentionBudget: true, + browserGuestWorktreeRetentionBudget: true, + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true, + defaultTuiAgent: null, + disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS], + pluginSystemEnabled: false, + disabledPlugins: [], + pluginConsents: {}, + devPluginPaths: [], + claudeAgentTeamsDefaultDisabledMigrated: true, + skipDeleteWorktreeConfirm: false, + skipCloseTerminalWithRunningProcessConfirm: false, + skipDeleteAutomationConfirm: false, + skipDeleteArtifactConfirm: false, + skipCodexRateLimitResetConfirm: false, + defaultTaskViewPreset: 'all', + defaultTaskSource: 'github', + visibleTaskProviders: [...TASK_PROVIDERS], + visibleTaskProvidersDefaultedForJira: true, + defaultRepoSelection: null, + defaultLinearTeamSelection: null, + opencodeSessionCookie: '', + opencodeWorkspaceId: '', + minimaxGroupId: '', + minimaxUsageModels: 'general', + geminiCliOAuthEnabled: false, + agentCmdOverrides: {}, + agentDefaultArgs: { ...DEFAULT_TUI_AGENT_ARGS }, + agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV }, + agentYoloDefaultsMigrated: true, + agentStatusHooksEnabled: true, + tabAutoGenerateTitle: false, + confirmClosePinnedTab: true, + keepComputerAwakeWhileAgentsRun: false, + // Why: 'auto' probes keyboard layout so non-US users can type Option chars like @/€/[ out of the box (issue #903). See src/renderer/src/lib/keyboard-layout/*. + terminalMacOptionAsAlt: 'auto', + terminalMacOptionAsAltMigrated: false, + terminalJISYenToBackslash: false, + experimentalMobile: false, + mobileEmulatorEnabled: true, + mobileEmulatorDefaultDeviceUdid: null, + androidSdkPath: null, + // Why: indefinite hold — the "Restore" banner is the explicit return action, no wall-clock guess. See docs/mobile-fit-hold.md. + mobileAutoRestoreFitMs: null, + // Why: Anywhere (Relay + local) is the default; local-only is written only on explicit same-network choice. + mobilePairingConnectionMode: 'automatic', + mobilePairingCustomAddress: null, + mobilePairingCustomAddresses: [], + // Why: off keeps the cosmetic overlay unmounted for users who never opt in. + experimentalPet: false, + experimentalActivity: false, + experimentalActivityDefaultedOffForAllUsers: true, + experimentalTerminalAttention: false, + experimentalAgentHibernation: false, + agentHibernationIdleMs: 30 * 60 * 1000, + experimentalNewWorktreeCardStyle: false, + experimentalEphemeralVms: false, + compactWorktreeCards: false, + // Why: local desktop stays the default until the user picks a saved runtime environment. + activeRuntimeEnvironmentId: null, + // Why: hydrate a stable empty shape so renderer optional-chained reads never hit undefined. + githubProjects: { + pinned: [], + recent: [], + lastViewByProject: {}, + activeProject: null + }, + // Why: keep agent/model maps empty so first use follows the default agent's model, not a frozen stale choice. + commitMessageAi: { + enabled: true, + agentId: null, + selectedModelByAgent: {}, + discoveredModelsByAgent: {}, + selectedModelByAgentByHost: {}, + discoveredModelsByAgentByHost: {}, + selectedThinkingByModel: {}, + customPrompt: '', + customAgentCommand: '' + }, + sourceControlAi: getDefaultSourceControlAiSettings(), + voice: args.voice + } +} diff --git a/src/shared/marine-creature-names-primary.ts b/src/shared/marine-creature-names-primary.ts new file mode 100644 index 00000000000..f23cb1267a4 --- /dev/null +++ b/src/shared/marine-creature-names-primary.ts @@ -0,0 +1,278 @@ +export const MARINE_CREATURE_NAMES_PRIMARY = [ + 'Nautilus', + 'Seahorse', + 'Starfish', + 'Coral', + 'Narwhal', + 'Jellyfish', + 'Octopus', + 'Manta', + 'Dolphin', + 'Manatee', + 'Cuttlefish', + 'Anemone', + 'Urchin', + 'Triton', + 'Nudibranch', + 'Coelacanth', + 'Oarfish', + 'Beluga', + 'Dugong', + 'Porpoise', + 'Stingray', + 'Hammerhead', + 'Wobbegong', + 'Sawfish', + 'Chimaera', + 'Anglerfish', + 'Viperfish', + 'Dragonfish', + 'Axolotl', + 'Otter', + 'Seadragon', + 'Seahare', + 'Squid', + 'Argonaut', + 'Sponge', + 'Barnacle', + 'Cowrie', + 'Guitarfish', + 'Lamprey', + 'Isopod', + 'Lionfish', + 'Clownfish', + 'Angelfish', + 'Butterflyfish', + 'Parrotfish', + 'Pufferfish', + 'Moonfish', + 'Firefish', + 'Unicornfish', + 'Rainbowfish', + 'Betta', + 'Discus', + 'Arowana', + 'Koi', + 'Piranha', + 'Barracuda', + 'Moray', + 'Sunfish', + 'Lanternfish', + 'Archerfish', + 'Mudskipper', + 'Hatchetfish', + 'Knifefish', + 'Leaffish', + 'Glassfish', + 'Ropefish', + 'Bichir', + 'Tigerfish', + 'Cardinalfish', + 'Lungfish', + 'Opah', + 'Frogfish', + 'Stonefish', + 'Cutlassfish', + 'Paddlefish', + 'Arapaima', + 'Mandarin', + 'Blobfish', + 'Thresher', + 'Vaquita', + 'Pipefish', + 'Guppy', + 'Tetra', + 'Danio', + 'Cichlid', + 'Oscar', + 'Gourami', + 'Killifish', + 'Rasbora', + 'Pleco', + 'Goldfish', + 'Molly', + 'Platy', + 'Barb', + 'Loach', + 'Medaka', + 'Pacu', + 'Filefish', + 'Boxfish', + 'Cowfish', + 'Surgeonfish', + 'Damselfish', + 'Wrasse', + 'Goby', + 'Blenny', + 'Conger', + 'Sculpin', + 'Darter', + 'Remora', + 'Pilotfish', + 'Trumpetfish', + 'Cornetfish', + 'Jawfish', + 'Toadfish', + 'Pearlfish', + 'Driftfish', + 'Lumpfish', + 'Snailfish', + 'Stickleback', + 'Halfbeak', + 'Snipefish', + 'Pencilfish', + 'Snakehead', + 'Tripletail', + 'Lookdown', + 'Sweetlips', + 'Squirrelfish', + 'Soldierfish', + 'Rabbitfish', + 'Hawkfish', + 'Bannerfish', + 'Batfish', + 'Chromis', + 'Anthias', + 'Fusilier', + 'Sweeper', + 'Emperor', + 'Ponyfish', + 'Kelpfish', + 'Weever', + 'Pearlside', + 'Opaleye', + 'Ballyhoo', + 'Needlefish', + 'Triggerfish', + 'Gar', + 'Tarpon', + 'Sailfish', + 'Conch', + 'Walrus', + 'Seal', + 'Penguin', + 'Hagfish', + 'Gulper', + 'Hydra', + 'Krill', + 'Salp', + 'Tunicate', + 'Crinoid', + 'Polyp', + 'Limpet', + 'Whelk', + 'Bowfin', + 'Minnow', + 'Gudgeon', + 'Goldeye', + 'Mooneye', + 'Soapfish', + 'Leatherjacket', + 'Rudderfish', + 'Sabrefish', + 'Puffin', + 'Albatross', + 'Osprey', + 'Pelican', + 'Petrel', + 'Gannet', + 'Tern', + 'Cormorant', + 'Leatherback', + 'Hawksbill', + 'Loggerhead', + 'Dottyback', + 'Basslet', + 'Gramma', + 'Hamlet', + 'Hogfish', + 'Dartfish', + 'Foxface', + 'Shrimpfish', + 'Pyrosome', + 'Seapen', + 'Medusa', + 'Candlefish', + 'Silverside', + 'Shiner', + 'Eelpout', + 'Madtom', + 'Shovelnose', + 'Sandlance', + 'Tubesnout', + 'Marlin', + 'Bonefish', + 'Dace', + 'Rudd', + 'Spirula', + 'Permit', + 'Grunt', + 'Trevally', + 'Croaker', + 'Drum', + 'Ladyfish', + 'Bullhead', + 'Torpedo', + 'Skate', + 'Hermit', + 'Fiddler', + 'Fireworm', + 'Tubeworm', + 'Amphipod', + 'Copepod', + 'Pteropod', + 'Seaslug', + 'Swordtail', + 'Bluegill', + 'Pickerel', + 'Redhorse', + 'Logperch', + 'Goblin', + 'Catshark', + 'Harlequin', + 'Murex', + 'Volute', + 'Wentletrap', + 'Nerite', + 'Stargazer', + 'Handfish', + 'Flyingfish', + 'Waspfish', + 'Skua', + 'Shearwater', + 'Guillemot', + 'Razorbill', + 'Auk', + 'Fulmar', + 'Murre', + 'Velvetfish', + 'Prowfish', + 'Sandperch', + 'Bobtail', + 'Chiton', + 'Featherstar', + 'Nereid', + 'Siren', + 'Kraken', + 'Selkie', + 'Leviathan', + 'Wolffish', + 'Wreckfish', + 'Horseshoe', + 'Orca', + 'Cachalot', + 'Rorqual', + 'Grampus', + 'Humpback', + 'Bowhead', + 'Finback', + 'Sealion', + 'Pinniped', + 'Cetacean', + 'Mako', + 'Porbeagle', + 'Dogfish', + 'Spurdog', + 'Tope', + 'Bonnethead' +] as const diff --git a/src/shared/marine-creature-names-secondary.ts b/src/shared/marine-creature-names-secondary.ts new file mode 100644 index 00000000000..7c33ccfbab6 --- /dev/null +++ b/src/shared/marine-creature-names-secondary.ts @@ -0,0 +1,278 @@ +export const MARINE_CREATURE_NAMES_SECONDARY = [ + 'Sixgill', + 'Sevengill', + 'Angelshark', + 'Houndshark', + 'Megamouth', + 'Cookiecutter', + 'Bramble', + 'Ghostshark', + 'Sandtiger', + 'Galeocerdo', + 'Nursehound', + 'Cownose', + 'Devilray', + 'Eagleray', + 'Butterflyray', + 'Numbfish', + 'Stingaree', + 'Mantaray', + 'Tuna', + 'Albacore', + 'Bonito', + 'Skipjack', + 'Wahoo', + 'Kingfish', + 'Mackerel', + 'Cero', + 'Dorado', + 'Mahimahi', + 'Escolar', + 'Pomfret', + 'Butterfish', + 'Cod', + 'Haddock', + 'Pollock', + 'Whiting', + 'Hake', + 'Ling', + 'Cusk', + 'Burbot', + 'Saithe', + 'Sablefish', + 'Lingcod', + 'Greenling', + 'Rockling', + 'Halibut', + 'Flounder', + 'Sole', + 'Plaice', + 'Turbot', + 'Brill', + 'Dab', + 'Fluke', + 'Megrim', + 'Sanddab', + 'Herring', + 'Sardine', + 'Anchovy', + 'Sprat', + 'Pilchard', + 'Menhaden', + 'Shad', + 'Alewife', + 'Salmon', + 'Trout', + 'Char', + 'Grayling', + 'Steelhead', + 'Kokanee', + 'Chinook', + 'Coho', + 'Sockeye', + 'Taimen', + 'Huchen', + 'Cisco', + 'Inconnu', + 'Vendace', + 'Whitefish', + 'Perch', + 'Bass', + 'Snapper', + 'Grouper', + 'Seabass', + 'Tilefish', + 'Bream', + 'Porgy', + 'Sheepshead', + 'Pinfish', + 'Scup', + 'Tautog', + 'Sander', + 'Zander', + 'Walleye', + 'Sauger', + 'Ruffe', + 'Comber', + 'Hind', + 'Coney', + 'Graysby', + 'Margate', + 'Pompano', + 'Amberjack', + 'Yellowtail', + 'Scad', + 'Runner', + 'Mullet', + 'Goatfish', + 'Threadfin', + 'Snook', + 'Barramundi', + 'Milkfish', + 'Mojarra', + 'Weakfish', + 'Corbina', + 'Queenfish', + 'Kahawai', + 'Tilapia', + 'Cunner', + 'Tuskfish', + 'Razorfish', + 'Tang', + 'Porcupinefish', + 'Burrfish', + 'Eel', + 'Wolfeel', + 'Gardeneel', + 'Ribboneel', + 'Cuskeel', + 'Catfish', + 'Cory', + 'Ratfish', + 'Elephantfish', + 'Goosefish', + 'Monkfish', + 'Sargassum', + 'Coffinfish', + 'Seadevil', + 'Scorpionfish', + 'Rockfish', + 'Cabezon', + 'Rosefish', + 'Redfish', + 'Bocaccio', + 'Thornyhead', + 'Gurnard', + 'Searobin', + 'Fangtooth', + 'Bristlemouth', + 'Barreleye', + 'Spookfish', + 'Telescopefish', + 'Lancetfish', + 'Tripodfish', + 'Spiderfish', + 'Daggertooth', + 'Pearleye', + 'Ridgehead', + 'Snaggletooth', + 'Sturgeon', + 'Sterlet', + 'Kaluga', + 'Cockle', + 'Mussel', + 'Clam', + 'Oyster', + 'Scallop', + 'Abalone', + 'Periwinkle', + 'Cerith', + 'Turban', + 'Tellin', + 'Geoduck', + 'Quahog', + 'Piddock', + 'Shipworm', + 'Lobster', + 'Crayfish', + 'Shrimp', + 'Prawn', + 'Langostino', + 'Langouste', + 'Yabby', + 'Marron', + 'Dungeness', + 'Seafan', + 'Staghorn', + 'Elkhorn', + 'Hydroid', + 'Zoanthid', + 'Gorgonian', + 'Seanettle', + 'Moonjelly', + 'Sanddollar', + 'Brittlestar', + 'Basketstar', + 'Sunstar', + 'Seacucumber', + 'Seabiscuit', + 'Hearturchin', + 'Bristleworm', + 'Featherduster', + 'Lugworm', + 'Sandworm', + 'Palolo', + 'Arrowworm', + 'Acornworm', + 'Flatworm', + 'Ribbonworm', + 'Booby', + 'Frigatebird', + 'Tropicbird', + 'Noddy', + 'Kittiwake', + 'Dovekie', + 'Shag', + 'Anhinga', + 'Skimmer', + 'Jaeger', + 'Oystercatcher', + 'Eider', + 'Scoter', + 'Merganser', + 'Brant', + 'Ridley', + 'Flatback', + 'Terrapin', + 'Seasnake', + 'Diatom', + 'Plankton', + 'Larvacean', + 'Doliolid', + 'Kelp', + 'Seagrass', + 'Eelgrass', + 'Dulse', + 'Bladderwrack', + 'Mola', + 'Scylla', + 'Charybdis', + 'Cetus', + 'Proteus', + 'Glaucus', + 'Hippocamp', + 'Jormungandr', + 'Hafgufa', + 'Kelpie', + 'Merrow', + 'Nuckelavee', + 'Afanc', + 'Rusalka', + 'Vodyanoy', + 'Umibozu', + 'Isonade', + 'Ningyo', + 'Mizuchi', + 'Naga', + 'Makara', + 'Bunyip', + 'Taniwha', + 'Marakihau', + 'Lusca', + 'Undine', + 'Nixie', + 'Melusine', + 'Ondine', + 'Tiamat', + 'Dagon', + 'Aspidochelone', + 'Capricorn', + 'Merfolk', + 'Merman', + 'Mermaid', + 'Timingila', + 'Bakekujira', + 'Jiaolong', + 'Encantado', + 'Hraesvelg' +] as const diff --git a/src/shared/marine-creatures.ts b/src/shared/marine-creatures.ts index 3402be6694d..305afb82469 100644 --- a/src/shared/marine-creatures.ts +++ b/src/shared/marine-creatures.ts @@ -1,560 +1,10 @@ -/* eslint-disable max-lines -- Curated marine-creature name corpus; one entry per line is the readable format for a flat data list. */ +import { MARINE_CREATURE_NAMES_PRIMARY } from './marine-creature-names-primary' +import { MARINE_CREATURE_NAMES_SECONDARY } from './marine-creature-names-secondary' + // Why: the auto-generated workspace name pool lives in shared (not renderer) // so the main process can recognize an Orca-generated branch name when deciding // whether auto-rename-from-work is allowed to overwrite it. export const MARINE_CREATURES = [ - 'Nautilus', - 'Seahorse', - 'Starfish', - 'Coral', - 'Narwhal', - 'Jellyfish', - 'Octopus', - 'Manta', - 'Dolphin', - 'Manatee', - 'Cuttlefish', - 'Anemone', - 'Urchin', - 'Triton', - 'Nudibranch', - 'Coelacanth', - 'Oarfish', - 'Beluga', - 'Dugong', - 'Porpoise', - 'Stingray', - 'Hammerhead', - 'Wobbegong', - 'Sawfish', - 'Chimaera', - 'Anglerfish', - 'Viperfish', - 'Dragonfish', - 'Axolotl', - 'Otter', - 'Seadragon', - 'Seahare', - 'Squid', - 'Argonaut', - 'Sponge', - 'Barnacle', - 'Cowrie', - 'Guitarfish', - 'Lamprey', - 'Isopod', - 'Lionfish', - 'Clownfish', - 'Angelfish', - 'Butterflyfish', - 'Parrotfish', - 'Pufferfish', - 'Moonfish', - 'Firefish', - 'Unicornfish', - 'Rainbowfish', - 'Betta', - 'Discus', - 'Arowana', - 'Koi', - 'Piranha', - 'Barracuda', - 'Moray', - 'Sunfish', - 'Lanternfish', - 'Archerfish', - 'Mudskipper', - 'Hatchetfish', - 'Knifefish', - 'Leaffish', - 'Glassfish', - 'Ropefish', - 'Bichir', - 'Tigerfish', - 'Cardinalfish', - 'Lungfish', - 'Opah', - 'Frogfish', - 'Stonefish', - 'Cutlassfish', - 'Paddlefish', - 'Arapaima', - 'Mandarin', - 'Blobfish', - 'Thresher', - 'Vaquita', - 'Pipefish', - 'Guppy', - 'Tetra', - 'Danio', - 'Cichlid', - 'Oscar', - 'Gourami', - 'Killifish', - 'Rasbora', - 'Pleco', - 'Goldfish', - 'Molly', - 'Platy', - 'Barb', - 'Loach', - 'Medaka', - 'Pacu', - 'Filefish', - 'Boxfish', - 'Cowfish', - 'Surgeonfish', - 'Damselfish', - 'Wrasse', - 'Goby', - 'Blenny', - 'Conger', - 'Sculpin', - 'Darter', - 'Remora', - 'Pilotfish', - 'Trumpetfish', - 'Cornetfish', - 'Jawfish', - 'Toadfish', - 'Pearlfish', - 'Driftfish', - 'Lumpfish', - 'Snailfish', - 'Stickleback', - 'Halfbeak', - 'Snipefish', - 'Pencilfish', - 'Snakehead', - 'Tripletail', - 'Lookdown', - 'Sweetlips', - 'Squirrelfish', - 'Soldierfish', - 'Rabbitfish', - 'Hawkfish', - 'Bannerfish', - 'Batfish', - 'Chromis', - 'Anthias', - 'Fusilier', - 'Sweeper', - 'Emperor', - 'Ponyfish', - 'Kelpfish', - 'Weever', - 'Pearlside', - 'Opaleye', - 'Ballyhoo', - 'Needlefish', - 'Triggerfish', - 'Gar', - 'Tarpon', - 'Sailfish', - 'Conch', - 'Walrus', - 'Seal', - 'Penguin', - 'Hagfish', - 'Gulper', - 'Hydra', - 'Krill', - 'Salp', - 'Tunicate', - 'Crinoid', - 'Polyp', - 'Limpet', - 'Whelk', - 'Bowfin', - 'Minnow', - 'Gudgeon', - 'Goldeye', - 'Mooneye', - 'Soapfish', - 'Leatherjacket', - 'Rudderfish', - 'Sabrefish', - 'Puffin', - 'Albatross', - 'Osprey', - 'Pelican', - 'Petrel', - 'Gannet', - 'Tern', - 'Cormorant', - 'Leatherback', - 'Hawksbill', - 'Loggerhead', - 'Dottyback', - 'Basslet', - 'Gramma', - 'Hamlet', - 'Hogfish', - 'Dartfish', - 'Foxface', - 'Shrimpfish', - 'Pyrosome', - 'Seapen', - 'Medusa', - 'Candlefish', - 'Silverside', - 'Shiner', - 'Eelpout', - 'Madtom', - 'Shovelnose', - 'Sandlance', - 'Tubesnout', - 'Marlin', - 'Bonefish', - 'Dace', - 'Rudd', - 'Spirula', - 'Permit', - 'Grunt', - 'Trevally', - 'Croaker', - 'Drum', - 'Ladyfish', - 'Bullhead', - 'Torpedo', - 'Skate', - 'Hermit', - 'Fiddler', - 'Fireworm', - 'Tubeworm', - 'Amphipod', - 'Copepod', - 'Pteropod', - 'Seaslug', - 'Swordtail', - 'Bluegill', - 'Pickerel', - 'Redhorse', - 'Logperch', - 'Goblin', - 'Catshark', - 'Harlequin', - 'Murex', - 'Volute', - 'Wentletrap', - 'Nerite', - 'Stargazer', - 'Handfish', - 'Flyingfish', - 'Waspfish', - 'Skua', - 'Shearwater', - 'Guillemot', - 'Razorbill', - 'Auk', - 'Fulmar', - 'Murre', - 'Velvetfish', - 'Prowfish', - 'Sandperch', - 'Bobtail', - 'Chiton', - 'Featherstar', - 'Nereid', - 'Siren', - 'Kraken', - 'Selkie', - 'Leviathan', - 'Wolffish', - 'Wreckfish', - 'Horseshoe', - 'Orca', - 'Cachalot', - 'Rorqual', - 'Grampus', - 'Humpback', - 'Bowhead', - 'Finback', - 'Sealion', - 'Pinniped', - 'Cetacean', - 'Mako', - 'Porbeagle', - 'Dogfish', - 'Spurdog', - 'Tope', - 'Bonnethead', - 'Sixgill', - 'Sevengill', - 'Angelshark', - 'Houndshark', - 'Megamouth', - 'Cookiecutter', - 'Bramble', - 'Ghostshark', - 'Sandtiger', - 'Galeocerdo', - 'Nursehound', - 'Cownose', - 'Devilray', - 'Eagleray', - 'Butterflyray', - 'Numbfish', - 'Stingaree', - 'Mantaray', - 'Tuna', - 'Albacore', - 'Bonito', - 'Skipjack', - 'Wahoo', - 'Kingfish', - 'Mackerel', - 'Cero', - 'Dorado', - 'Mahimahi', - 'Escolar', - 'Pomfret', - 'Butterfish', - 'Cod', - 'Haddock', - 'Pollock', - 'Whiting', - 'Hake', - 'Ling', - 'Cusk', - 'Burbot', - 'Saithe', - 'Sablefish', - 'Lingcod', - 'Greenling', - 'Rockling', - 'Halibut', - 'Flounder', - 'Sole', - 'Plaice', - 'Turbot', - 'Brill', - 'Dab', - 'Fluke', - 'Megrim', - 'Sanddab', - 'Herring', - 'Sardine', - 'Anchovy', - 'Sprat', - 'Pilchard', - 'Menhaden', - 'Shad', - 'Alewife', - 'Salmon', - 'Trout', - 'Char', - 'Grayling', - 'Steelhead', - 'Kokanee', - 'Chinook', - 'Coho', - 'Sockeye', - 'Taimen', - 'Huchen', - 'Cisco', - 'Inconnu', - 'Vendace', - 'Whitefish', - 'Perch', - 'Bass', - 'Snapper', - 'Grouper', - 'Seabass', - 'Tilefish', - 'Bream', - 'Porgy', - 'Sheepshead', - 'Pinfish', - 'Scup', - 'Tautog', - 'Sander', - 'Zander', - 'Walleye', - 'Sauger', - 'Ruffe', - 'Comber', - 'Hind', - 'Coney', - 'Graysby', - 'Margate', - 'Pompano', - 'Amberjack', - 'Yellowtail', - 'Scad', - 'Runner', - 'Mullet', - 'Goatfish', - 'Threadfin', - 'Snook', - 'Barramundi', - 'Milkfish', - 'Mojarra', - 'Weakfish', - 'Corbina', - 'Queenfish', - 'Kahawai', - 'Tilapia', - 'Cunner', - 'Tuskfish', - 'Razorfish', - 'Tang', - 'Porcupinefish', - 'Burrfish', - 'Eel', - 'Wolfeel', - 'Gardeneel', - 'Ribboneel', - 'Cuskeel', - 'Catfish', - 'Cory', - 'Ratfish', - 'Elephantfish', - 'Goosefish', - 'Monkfish', - 'Sargassum', - 'Coffinfish', - 'Seadevil', - 'Scorpionfish', - 'Rockfish', - 'Cabezon', - 'Rosefish', - 'Redfish', - 'Bocaccio', - 'Thornyhead', - 'Gurnard', - 'Searobin', - 'Fangtooth', - 'Bristlemouth', - 'Barreleye', - 'Spookfish', - 'Telescopefish', - 'Lancetfish', - 'Tripodfish', - 'Spiderfish', - 'Daggertooth', - 'Pearleye', - 'Ridgehead', - 'Snaggletooth', - 'Sturgeon', - 'Sterlet', - 'Kaluga', - 'Cockle', - 'Mussel', - 'Clam', - 'Oyster', - 'Scallop', - 'Abalone', - 'Periwinkle', - 'Cerith', - 'Turban', - 'Tellin', - 'Geoduck', - 'Quahog', - 'Piddock', - 'Shipworm', - 'Lobster', - 'Crayfish', - 'Shrimp', - 'Prawn', - 'Langostino', - 'Langouste', - 'Yabby', - 'Marron', - 'Dungeness', - 'Seafan', - 'Staghorn', - 'Elkhorn', - 'Hydroid', - 'Zoanthid', - 'Gorgonian', - 'Seanettle', - 'Moonjelly', - 'Sanddollar', - 'Brittlestar', - 'Basketstar', - 'Sunstar', - 'Seacucumber', - 'Seabiscuit', - 'Hearturchin', - 'Bristleworm', - 'Featherduster', - 'Lugworm', - 'Sandworm', - 'Palolo', - 'Arrowworm', - 'Acornworm', - 'Flatworm', - 'Ribbonworm', - 'Booby', - 'Frigatebird', - 'Tropicbird', - 'Noddy', - 'Kittiwake', - 'Dovekie', - 'Shag', - 'Anhinga', - 'Skimmer', - 'Jaeger', - 'Oystercatcher', - 'Eider', - 'Scoter', - 'Merganser', - 'Brant', - 'Ridley', - 'Flatback', - 'Terrapin', - 'Seasnake', - 'Diatom', - 'Plankton', - 'Larvacean', - 'Doliolid', - 'Kelp', - 'Seagrass', - 'Eelgrass', - 'Dulse', - 'Bladderwrack', - 'Mola', - // Mythological sea & water creatures from public-domain folklore — joining - // Kraken, Leviathan, Siren, Triton, Hydra, Medusa, Nereid, and Selkie above. - 'Scylla', - 'Charybdis', - 'Cetus', - 'Proteus', - 'Glaucus', - 'Hippocamp', - 'Jormungandr', - 'Hafgufa', - 'Kelpie', - 'Merrow', - 'Nuckelavee', - 'Afanc', - 'Rusalka', - 'Vodyanoy', - 'Umibozu', - 'Isonade', - 'Ningyo', - 'Mizuchi', - 'Naga', - 'Makara', - 'Bunyip', - 'Taniwha', - 'Marakihau', - 'Lusca', - 'Undine', - 'Nixie', - 'Melusine', - 'Ondine', - 'Tiamat', - 'Dagon', - 'Aspidochelone', - 'Capricorn', - 'Merfolk', - 'Merman', - 'Mermaid', - 'Timingila', - 'Bakekujira', - 'Jiaolong', - 'Encantado', - 'Hraesvelg' + ...MARINE_CREATURE_NAMES_PRIMARY, + ...MARINE_CREATURE_NAMES_SECONDARY ] as const diff --git a/src/shared/text-search-glob-patterns.ts b/src/shared/text-search-glob-patterns.ts new file mode 100644 index 00000000000..a677f39e8d8 --- /dev/null +++ b/src/shared/text-search-glob-patterns.ts @@ -0,0 +1,39 @@ +export function splitSearchGlobPatterns(patterns: string): string[] { + const out: string[] = [] + let current = '' + let escaping = false + for (const ch of patterns) { + if (escaping) { + current += `\\${ch}` + escaping = false + continue + } + if (ch === '\\') { + escaping = true + continue + } + if (ch === ',') { + const trimmed = current.trim() + if (trimmed) { + out.push(trimmed) + } + current = '' + continue + } + current += ch + } + if (escaping) { + current += '\\' + } + const trimmed = current.trim() + if (trimmed) { + out.push(trimmed) + } + return out +} + +export function toGitGlobPathspec(glob: string, exclude?: boolean): string { + const needsRecursive = !glob.includes('/') + const pattern = needsRecursive ? `**/${glob}` : glob + return exclude ? `:(exclude,glob)${pattern}` : `:(glob)${pattern}` +} diff --git a/src/shared/text-search-match-accumulator.ts b/src/shared/text-search-match-accumulator.ts new file mode 100644 index 00000000000..addad7ee707 --- /dev/null +++ b/src/shared/text-search-match-accumulator.ts @@ -0,0 +1,78 @@ +import type { SearchFileResult, SearchMatch } from './code-search-types' +import type { SearchAccumulator } from './text-search' + +const MAX_LINE_CONTENT_LENGTH = 500 +const TRUNCATION_MARKER = '…' + +type ClampedLineContext = { + lineContent: string + column: number + matchLength: number + displayColumn?: number + displayMatchLength?: number +} + +function clampLineContext( + text: string, + matchStart: number, + matchLength: number +): ClampedLineContext { + if (text.length <= MAX_LINE_CONTENT_LENGTH) { + return { lineContent: text, column: matchStart + 1, matchLength } + } + const clampedMatchLength = Math.min(matchLength, MAX_LINE_CONTENT_LENGTH) + const remaining = MAX_LINE_CONTENT_LENGTH - clampedMatchLength + const leftBudget = Math.floor(remaining / 2) + let windowStart = Math.max(0, matchStart - leftBudget) + const windowEnd = Math.min(text.length, windowStart + MAX_LINE_CONTENT_LENGTH) + windowStart = Math.max(0, windowEnd - MAX_LINE_CONTENT_LENGTH) + + let snippet = text.slice(windowStart, windowEnd) + let column = matchStart - windowStart + 1 + if (windowStart > 0) { + snippet = TRUNCATION_MARKER + snippet + column += TRUNCATION_MARKER.length + } + if (windowEnd < text.length) { + snippet += TRUNCATION_MARKER + } + return { + lineContent: snippet, + column: matchStart + 1, + matchLength, + displayColumn: column, + displayMatchLength: clampedMatchLength + } +} + +export function pushSearchMatch(args: { + fileResult: SearchFileResult + accumulator: SearchAccumulator + lineContent: string + matchStart: number + matchLength: number + lineNumber: number + maxResults: number +}): 'continue' | 'stop' { + const clamped = clampLineContext(args.lineContent, args.matchStart, args.matchLength) + const match: SearchMatch = { + line: args.lineNumber, + column: clamped.column, + matchLength: clamped.matchLength, + lineContent: clamped.lineContent + } + if (clamped.displayColumn !== undefined) { + match.displayColumn = clamped.displayColumn + } + if (clamped.displayMatchLength !== undefined) { + match.displayMatchLength = clamped.displayMatchLength + } + args.fileResult.matches.push(match) + args.fileResult.matchCount = (args.fileResult.matchCount ?? 0) + 1 + args.accumulator.totalMatches++ + if (args.accumulator.totalMatches >= args.maxResults) { + args.accumulator.truncated = true + return 'stop' + } + return 'continue' +} diff --git a/src/shared/text-search-paths.ts b/src/shared/text-search-paths.ts new file mode 100644 index 00000000000..c556a10dedf --- /dev/null +++ b/src/shared/text-search-paths.ts @@ -0,0 +1,20 @@ +import { posix, win32 } from 'node:path' + +function pathFlavor(rootPath: string): typeof posix | typeof win32 { + if (/^[a-zA-Z]:[\\/]/.test(rootPath) || rootPath.startsWith('\\\\')) { + return win32 + } + return posix +} + +export function normalizeRelativePath(path: string): string { + return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') +} + +export function relativeToSearchRoot(rootPath: string, absolutePath: string): string { + return pathFlavor(rootPath).relative(rootPath, absolutePath) +} + +export function joinSearchRoot(rootPath: string, relativePath: string): string { + return pathFlavor(rootPath).join(rootPath, relativePath) +} diff --git a/src/shared/text-search.test.ts b/src/shared/text-search.test.ts index 108d07a76c5..2ecbc53d8b4 100644 --- a/src/shared/text-search.test.ts +++ b/src/shared/text-search.test.ts @@ -12,11 +12,10 @@ import { ingestGitGrepLine, ingestRgJsonLine, MAX_LINE_CONTENT_LENGTH, - normalizeRelativePath, - SEARCH_JSON_STRUCTURE_LIMITS, - splitSearchGlobPatterns, - toGitGlobPathspec + SEARCH_JSON_STRUCTURE_LIMITS } from './text-search' +import { splitSearchGlobPatterns, toGitGlobPathspec } from './text-search-glob-patterns' +import { normalizeRelativePath } from './text-search-paths' describe('normalizeRelativePath', () => { it('collapses mixed separators and strips leading slashes', () => { diff --git a/src/shared/text-search.ts b/src/shared/text-search.ts index 7919e50cb47..289c5820397 100644 --- a/src/shared/text-search.ts +++ b/src/shared/text-search.ts @@ -1,6 +1,3 @@ -/* oxlint-disable max-lines -- Why: single source of truth for rg/git-grep arg - * construction and --json/submatch parsing shared by main process and SSH relay; - * re-splitting would re-introduce the maxBuffer divergence the design doc calls out. */ /** * Shared, pure text-search helpers used by both the local main process and the * SSH relay. No Electron, child_process, or fs — the caller owns process @@ -10,16 +7,13 @@ * can't re-diverge (notably the relay's old execFile maxBuffer that dropped matches). * Design doc: docs/design/share-text-search.md. */ -import { posix, win32 } from 'node:path' import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' import { normalizeSearchResult } from './search-match-count' import { escapeRegex } from './string-utils' -import type { - SearchFileResult, - SearchMatch, - SearchOptions, - SearchResult -} from './code-search-types' +import type { SearchFileResult, SearchOptions, SearchResult } from './code-search-types' +import { pushSearchMatch } from './text-search-match-accumulator' +import { splitSearchGlobPatterns, toGitGlobPathspec } from './text-search-glob-patterns' +import { joinSearchRoot, normalizeRelativePath, relativeToSearchRoot } from './text-search-paths' export type SearchAccumulator = { fileMap: Map @@ -31,30 +25,6 @@ export function createAccumulator(): SearchAccumulator { return { fileMap: new Map(), totalMatches: 0, truncated: false } } -function acceptMatch(fileResult: SearchFileResult): void { - fileResult.matchCount = (fileResult.matchCount ?? 0) + 1 -} - -// Why: normalize separators and strip leading `/` so results are cross-platform stable and don't break callers' `join(rootPath, relPath)`. -export function normalizeRelativePath(path: string): string { - return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '') -} - -function pathFlavor(rootPath: string): typeof posix | typeof win32 { - if (/^[a-zA-Z]:[\\/]/.test(rootPath) || rootPath.startsWith('\\\\')) { - return win32 - } - return posix -} - -function relativeToSearchRoot(rootPath: string, absPath: string): string { - return pathFlavor(rootPath).relative(rootPath, absPath) -} - -function joinSearchRoot(rootPath: string, relPath: string): string { - return pathFlavor(rootPath).join(rootPath, relPath) -} - // ─── Constants shared by both callers ──────────────────────────────── export const MAX_MATCHES_PER_FILE = 100 @@ -70,78 +40,6 @@ const SEARCH_MAX_FILE_SIZE = 5 * 1024 * 1024 // Why: mega-byte lines (minified/generated files) × 2000-match caps blow past the 16MB SSH relay MAX_MESSAGE_SIZE; clamp each match's context. export const MAX_LINE_CONTENT_LENGTH = 500 -const TRUNCATION_MARKER = '…' - -function clampLineContext( - text: string, - matchStart: number, - matchLength: number -): { - lineContent: string - column: number - matchLength: number - displayColumn?: number - displayMatchLength?: number -} { - if (text.length <= MAX_LINE_CONTENT_LENGTH) { - return { lineContent: text, column: matchStart + 1, matchLength } - } - // Clamp the match first so a pathological multi-MB regex hit can't defeat the windowing below. - const clampedMatchLength = Math.min(matchLength, MAX_LINE_CONTENT_LENGTH) - const remaining = MAX_LINE_CONTENT_LENGTH - clampedMatchLength - const leftBudget = Math.floor(remaining / 2) - let windowStart = Math.max(0, matchStart - leftBudget) - let windowEnd = Math.min(text.length, windowStart + MAX_LINE_CONTENT_LENGTH) - windowStart = Math.max(0, windowEnd - MAX_LINE_CONTENT_LENGTH) - - let snippet = text.slice(windowStart, windowEnd) - let column = matchStart - windowStart + 1 - if (windowStart > 0) { - snippet = TRUNCATION_MARKER + snippet - column += TRUNCATION_MARKER.length - } - if (windowEnd < text.length) { - snippet = snippet + TRUNCATION_MARKER - } - return { - lineContent: snippet, - column: matchStart + 1, - matchLength, - displayColumn: column, - displayMatchLength: clampedMatchLength - } -} - -// Why: shared by rg and git-grep to preserve the synchronous truncation ordering callers require. -function pushMatch( - fileResult: SearchFileResult, - acc: SearchAccumulator, - clamped: ReturnType, - lineNumber: number, - maxResults: number -): 'continue' | 'stop' { - // Why: direct assignment avoids conditional-spread allocations on the per-match hot path. - const match: SearchMatch = { - line: lineNumber, - column: clamped.column, - matchLength: clamped.matchLength, - lineContent: clamped.lineContent - } - if (clamped.displayColumn !== undefined) { - match.displayColumn = clamped.displayColumn - } - if (clamped.displayMatchLength !== undefined) { - match.displayMatchLength = clamped.displayMatchLength - } - fileResult.matches.push(match) - acceptMatch(fileResult) - acc.totalMatches++ - if (acc.totalMatches >= maxResults) { - acc.truncated = true - return 'stop' - } - return 'continue' -} // ─── rg ───────────────────────────────────────────────────────────── @@ -150,40 +48,6 @@ export type SearchOptionsLike = Pick< 'caseSensitive' | 'wholeWord' | 'useRegex' | 'includePattern' | 'excludePattern' > -export function splitSearchGlobPatterns(patterns: string): string[] { - const out: string[] = [] - let current = '' - let escaping = false - for (const ch of patterns) { - if (escaping) { - current += `\\${ch}` - escaping = false - continue - } - if (ch === '\\') { - escaping = true - continue - } - if (ch === ',') { - const trimmed = current.trim() - if (trimmed) { - out.push(trimmed) - } - current = '' - continue - } - current += ch - } - if (escaping) { - current += '\\' - } - const trimmed = current.trim() - if (trimmed) { - out.push(trimmed) - } - return out -} - /** * Build the complete rg argv (flags + `--` + query + target) for both callers to spawn as-is. * @@ -284,8 +148,17 @@ export function ingestRgJsonLine( fileResult = { filePath: absPath, relativePath: relPath, matches: [], matchCount: 0 } acc.fileMap.set(absPath, fileResult) } - const clamped = clampLineContext(lineContent, sub.start, sub.end - sub.start) - if (pushMatch(fileResult, acc, clamped, lineNumber, maxResults) === 'stop') { + if ( + pushSearchMatch({ + fileResult, + accumulator: acc, + lineContent, + matchStart: sub.start, + matchLength: sub.end - sub.start, + lineNumber, + maxResults + }) === 'stop' + ) { return 'stop' } } @@ -294,17 +167,6 @@ export function ingestRgJsonLine( // ─── git grep ─────────────────────────────────────────────────────── -/** - * Convert a user-facing glob pattern into a git pathspec. - * - * Why: bare git pathspecs only match the repo root, so wrap with `:(glob)` and prepend `**\/` to replicate rg's recursive-by-default globbing. - */ -export function toGitGlobPathspec(glob: string, exclude?: boolean): string { - const needsRecursive = !glob.includes('/') - const pattern = needsRecursive ? `**/${glob}` : glob - return exclude ? `:(exclude,glob)${pattern}` : `:(glob)${pattern}` -} - export function buildGitGrepArgs(query: string, opts: SearchOptionsLike): string[] { // Why: --no-recurse-submodules avoids failing when submodule.recurse=true conflicts with --untracked; --null disambiguates colon-containing filenames. const gitArgs: string[] = [ @@ -426,19 +288,35 @@ export function ingestGitGrepLine( // Why: no JS-side submatch regex (git accepts patterns JS RegExp rejects); fall back to whole-line highlight so the hit still shows. if (submatchRegex === null) { - const clamped = clampLineContext(lineContent, 0, lineContent.length) const fileResult = getFileResult() - return pushMatch(fileResult, acc, clamped, lineNum, maxResults) + return pushSearchMatch({ + fileResult, + accumulator: acc, + lineContent, + matchStart: 0, + matchLength: lineContent.length, + lineNumber: lineNum, + maxResults + }) } submatchRegex.lastIndex = 0 let m: RegExpExecArray | null let acceptedLineMatch = false while ((m = submatchRegex.exec(lineContent)) !== null) { - const clamped = clampLineContext(lineContent, m.index, m[0].length) const fileResult = getFileResult() acceptedLineMatch = true - if (pushMatch(fileResult, acc, clamped, lineNum, maxResults) === 'stop') { + if ( + pushSearchMatch({ + fileResult, + accumulator: acc, + lineContent, + matchStart: m.index, + matchLength: m[0].length, + lineNumber: lineNum, + maxResults + }) === 'stop' + ) { return 'stop' } // Prevent infinite loop on zero-length regex matches. @@ -448,9 +326,18 @@ export function ingestGitGrepLine( } // Why: git grep confirmed the line but JS regex found no occurrence; keep it navigable, don't drop a git-confirmed hit. if (!acceptedLineMatch) { - const clamped = clampLineContext(lineContent, 0, lineContent.length) const fileResult = getFileResult() - if (pushMatch(fileResult, acc, clamped, lineNum, maxResults) === 'stop') { + if ( + pushSearchMatch({ + fileResult, + accumulator: acc, + lineContent, + matchStart: 0, + matchLength: lineContent.length, + lineNumber: lineNum, + maxResults + }) === 'stop' + ) { return 'stop' } }