From 79838275a45554b1e338c260ddeb32f2817cc881 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 15 May 2026 16:16:55 -0400 Subject: [PATCH 1/4] Improve Claude and Codex usage tracking (#1968) Co-authored-by: Orca --- src/main/claude-usage/scanner-scan.test.ts | 136 +++++- src/main/claude-usage/scanner.test.ts | 102 ++++- src/main/claude-usage/scanner.ts | 331 ++++++++++++-- src/main/claude-usage/store.test.ts | 26 ++ src/main/claude-usage/store.ts | 164 ++++++- src/main/claude-usage/types.ts | 9 +- src/main/codex-usage/scanner.test.ts | 51 ++- src/main/codex-usage/scanner.ts | 129 +++++- src/main/codex-usage/store.test.ts | 74 +++- src/main/codex-usage/store.ts | 150 ++++++- .../src/components/settings/Settings.tsx | 2 +- .../src/components/stats/StatsPane.tsx | 19 +- .../components/stats/UsageOverviewPane.tsx | 412 ++++++++++++++++++ .../stats/usage-overview-model.test.ts | 207 +++++++++ .../components/stats/usage-overview-model.ts | 319 ++++++++++++++ tests/e2e/usage-overview.spec.ts | 35 ++ 16 files changed, 2064 insertions(+), 102 deletions(-) create mode 100644 src/renderer/src/components/stats/UsageOverviewPane.tsx create mode 100644 src/renderer/src/components/stats/usage-overview-model.test.ts create mode 100644 src/renderer/src/components/stats/usage-overview-model.ts create mode 100644 tests/e2e/usage-overview.spec.ts diff --git a/src/main/claude-usage/scanner-scan.test.ts b/src/main/claude-usage/scanner-scan.test.ts index 18491b73b49..25f4d79acaa 100644 --- a/src/main/claude-usage/scanner-scan.test.ts +++ b/src/main/claude-usage/scanner-scan.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'os' import { join } from 'path' import { afterEach, describe, expect, it, vi } from 'vitest' import type * as Os from 'os' +import type * as FsPromises from 'fs/promises' const tempRoots: string[] = [] @@ -10,11 +11,13 @@ async function makeClaudeProjectsRoot(): Promise { const root = await mkdtemp(join(tmpdir(), 'orca-claude-usage-')) tempRoots.push(root) await mkdir(join(root, '.claude', 'projects', 'project-a'), { recursive: true }) + await mkdir(join(root, '.claude', 'transcripts'), { recursive: true }) return root } afterEach(async () => { vi.doUnmock('os') + vi.doUnmock('fs/promises') vi.resetModules() await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) }) @@ -23,8 +26,10 @@ describe('scanClaudeUsageFiles', () => { it('scans transcript files from the configured Claude projects directory', async () => { const root = await makeClaudeProjectsRoot() const projectDir = join(root, '.claude', 'projects', 'project-a') + const transcriptsDir = join(root, '.claude', 'transcripts') const firstFile = join(projectDir, 'a.jsonl') const secondFile = join(projectDir, 'b.jsonl') + const transcriptFile = join(transcriptsDir, 'ses_123.jsonl') await writeFile( firstFile, @@ -63,6 +68,22 @@ describe('scanClaudeUsageFiles', () => { } }) ) + await writeFile( + transcriptFile, + JSON.stringify({ + type: 'assistant', + timestamp: '2026-04-11T10:00:00.000Z', + cwd: '/workspace/repo-a/packages/app', + message: { + model: 'claude-sonnet-4-6', + usage: { + input_tokens: 70, + output_tokens: 15, + cache_read_input_tokens: 20 + } + } + }) + ) vi.resetModules() vi.doMock('os', async () => ({ @@ -82,10 +103,119 @@ describe('scanClaudeUsageFiles', () => { expect(result.processedFiles.map((file) => [file.path, file.lineCount])).toEqual([ [firstFile, 2], - [secondFile, 1] + [secondFile, 1], + [transcriptFile, 1] ]) - expect(result.sessions.map((session) => session.sessionId)).toEqual(['session-2', 'session-1']) - expect(result.dailyAggregates).toHaveLength(2) + expect(result.sessions.map((session) => session.sessionId)).toEqual([ + 'ses_123', + 'session-2', + 'session-1' + ]) + expect(result.dailyAggregates).toHaveLength(3) expect(result.dailyAggregates[0]?.projectLabel).toBe('Repo A') + expect(result.dailyAggregates[2]?.projectLabel).toBe('Repo A') + }) + + it('reuses unchanged transcript projections from the previous scan', async () => { + const root = await makeClaudeProjectsRoot() + const projectDir = join(root, '.claude', 'projects', 'project-a') + const transcriptFile = join(projectDir, 'session-1.jsonl') + + await writeFile( + transcriptFile, + JSON.stringify({ + type: 'assistant', + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + cwd: '/workspace/repo-a', + message: { + model: 'claude-sonnet-4-6', + usage: { + input_tokens: 100, + output_tokens: 20 + } + } + }) + ) + + vi.resetModules() + vi.doMock('os', async () => ({ + ...(await vi.importActual('os')), + homedir: () => root + })) + const { scanClaudeUsageFiles } = await import('./scanner') + const worktrees = [ + { + repoId: 'repo-1', + worktreeId: 'worktree-1', + path: '/workspace/repo-a', + displayName: 'Repo A' + } + ] + + const first = await scanClaudeUsageFiles(worktrees) + const cachedFile = structuredClone(first.processedFiles[0]!) + cachedFile.sessions[0]!.totalInputTokens = 999 + cachedFile.sessions[0]!.locationBreakdown[0]!.inputTokens = 999 + cachedFile.dailyAggregates[0]!.inputTokens = 999 + + const second = await scanClaudeUsageFiles(worktrees, [cachedFile]) + + expect(second.processedFiles[0]?.sessions[0]?.totalInputTokens).toBe(999) + expect(second.dailyAggregates[0]?.inputTokens).toBe(999) + }) + + it('canonicalizes repeated cwd paths once per scan file', async () => { + const root = await makeClaudeProjectsRoot() + const projectDir = join(root, '.claude', 'projects', 'project-a') + const transcriptFile = join(projectDir, 'session-1.jsonl') + const repeatedCwd = '/workspace/repo-a/packages/app' + + await writeFile( + transcriptFile, + [1, 2, 3] + .map((index) => + JSON.stringify({ + type: 'assistant', + sessionId: 'session-1', + timestamp: `2026-04-09T10:0${index}:00.000Z`, + cwd: repeatedCwd, + message: { + model: 'claude-sonnet-4-6', + usage: { + input_tokens: 100, + output_tokens: 20 + } + } + }) + ) + .join('\n') + ) + + const realpathCalls: string[] = [] + vi.resetModules() + vi.doMock('os', async () => ({ + ...(await vi.importActual('os')), + homedir: () => root + })) + vi.doMock('fs/promises', async () => ({ + ...(await vi.importActual('fs/promises')), + realpath: vi.fn(async (pathValue: string) => { + realpathCalls.push(pathValue) + return pathValue + }) + })) + const { scanClaudeUsageFiles } = await import('./scanner') + + await scanClaudeUsageFiles([ + { + repoId: 'repo-1', + worktreeId: 'worktree-1', + path: '/workspace/repo-a', + displayName: 'Repo A' + } + ]) + + expect(realpathCalls.filter((pathValue) => pathValue === repeatedCwd)).toHaveLength(1) }) }) diff --git a/src/main/claude-usage/scanner.test.ts b/src/main/claude-usage/scanner.test.ts index e1d19fb5cdd..1448fe0b566 100644 --- a/src/main/claude-usage/scanner.test.ts +++ b/src/main/claude-usage/scanner.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' -import { aggregateClaudeUsage, attributeClaudeUsageTurns, parseClaudeUsageRecord } from './scanner' +import { + aggregateClaudeUsage, + attributeClaudeUsageTurns, + parseClaudeUsageFile, + parseClaudeUsageRecord +} from './scanner' +import { mkdtemp, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' describe('parseClaudeUsageRecord', () => { it('extracts token usage from assistant transcript lines', () => { @@ -34,6 +42,66 @@ describe('parseClaudeUsageRecord', () => { cacheWriteTokens: 5 }) }) + + it('merges duplicate streamed assistant usage by message and request id', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-dedupe-')) + const filePath = join(root, 'session.jsonl') + try { + await writeFile( + filePath, + [ + JSON.stringify({ + type: 'assistant', + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + requestId: 'req-1', + message: { + id: 'msg-1', + model: 'claude-sonnet-4-6', + usage: { + input_tokens: 100, + output_tokens: 25, + cache_read_input_tokens: 10, + cache_creation_input_tokens: 5 + } + } + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:01.000Z', + requestId: 'req-1', + message: { + id: 'msg-1', + model: 'claude-sonnet-4-6', + usage: { + input_tokens: 120, + output_tokens: 30, + cache_read_input_tokens: 12, + cache_creation_input_tokens: 7 + } + } + }) + ].join('\n') + ) + + await expect(parseClaudeUsageFile(filePath)).resolves.toEqual([ + { + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + model: 'claude-sonnet-4-6', + cwd: null, + gitBranch: null, + inputTokens: 120, + outputTokens: 30, + cacheReadTokens: 12, + cacheWriteTokens: 7 + } + ]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) }) describe('Claude usage aggregation', () => { @@ -104,4 +172,36 @@ describe('Claude usage aggregation', () => { } ]) }) + + it('attributes nested cwd paths to the containing worktree', async () => { + const attributed = await attributeClaudeUsageTurns( + [ + { + sessionId: 'session-1', + timestamp: '2026-04-09T10:00:00.000Z', + model: 'claude-sonnet-4-6', + cwd: '/workspace/repo-a/packages/app', + gitBranch: 'feature/a', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 10, + cacheWriteTokens: 5 + } + ], + new Map([ + [ + '/workspace/repo-a', + { + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo-a', + path: '/workspace/repo-a', + displayName: 'Repo A' + } + ] + ]) + ) + + expect(attributed[0]?.projectKey).toBe('worktree:repo-1::/workspace/repo-a') + expect(attributed[0]?.projectLabel).toBe('Repo A') + }) }) diff --git a/src/main/claude-usage/scanner.ts b/src/main/claude-usage/scanner.ts index 1546983e767..2b250327912 100644 --- a/src/main/claude-usage/scanner.ts +++ b/src/main/claude-usage/scanner.ts @@ -10,6 +10,7 @@ import type { ClaudeUsageDailyAggregate, ClaudeUsageLocationBreakdown, ClaudeUsageParsedTurn, + ClaudeUsagePersistedFile, ClaudeUsageProcessedFile, ClaudeUsageSession } from './types' @@ -27,7 +28,11 @@ type ClaudeUsageSourceRecord = { timestamp?: string cwd?: string gitBranch?: string + requestId?: string + isSidechain?: boolean + agentId?: string message?: { + id?: string model?: string usage?: { input_tokens?: number @@ -39,8 +44,20 @@ type ClaudeUsageSourceRecord = { } const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') +const CLAUDE_TRANSCRIPTS_DIR = join(homedir(), '.claude', 'transcripts') const FILE_SCAN_BATCH_SIZE = 4 +type ClaudeUsageParsedSourceTurn = ClaudeUsageParsedTurn & { + dedupeKey: string | null +} + +type ClaudeUsageWorktreeEntry = [string, ClaudeUsageWorktreeRef] + +const sortedWorktreeEntriesByLookup = new WeakMap< + Map, + ClaudeUsageWorktreeEntry[] +>() + function getDefaultProjectLabel(cwd: string | null): string { if (!cwd) { return 'Unknown location' @@ -66,6 +83,45 @@ function normalizeComparablePath(pathValue: string): string { return process.platform === 'win32' ? normalized.toLowerCase() : normalized } +function isContainedPath(parentPath: string, childPath: string): boolean { + const parent = normalizeComparablePath(parentPath).replace(/\/+$/, '') + const child = normalizeComparablePath(childPath).replace(/\/+$/, '') + return child === parent || child.startsWith(`${parent}/`) +} + +function findContainingWorktree( + cwd: string, + worktreeLookup: Map +): ClaudeUsageWorktreeRef | null { + const normalizedCwd = normalizeComparablePath(cwd) + const exact = worktreeLookup.get(normalizedCwd) + if (exact) { + return exact + } + + for (const [worktreePath, worktree] of getSortedWorktreeEntries(worktreeLookup)) { + if (isContainedPath(worktreePath, normalizedCwd)) { + return worktree + } + } + + return null +} + +function getSortedWorktreeEntries( + worktreeLookup: Map +): ClaudeUsageWorktreeEntry[] { + const cached = sortedWorktreeEntriesByLookup.get(worktreeLookup) + if (cached) { + return cached + } + const sorted = [...worktreeLookup.entries()].sort( + ([leftPath], [rightPath]) => rightPath.length - leftPath.length + ) + sortedWorktreeEntriesByLookup.set(worktreeLookup, sorted) + return sorted +} + async function yieldToEventLoop(): Promise { await new Promise((resolve) => setTimeout(resolve, 0)) } @@ -89,11 +145,17 @@ async function walkJsonlFiles(dirPath: string): Promise { } export async function listClaudeTranscriptFiles(): Promise { - try { - return (await walkJsonlFiles(CLAUDE_PROJECTS_DIR)).sort() - } catch { - return [] - } + const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR] + const files = await Promise.all( + roots.map(async (root) => { + try { + return await walkJsonlFiles(root) + } catch { + return [] + } + }) + ) + return [...new Set(files.flat())].sort() } export async function getProcessedFileInfo(filePath: string): Promise { @@ -109,11 +171,69 @@ export async function getProcessedFileInfo(filePath: string): Promise> { + const fileStat = await stat(filePath) + return { + path: filePath, + mtimeMs: fileStat.mtimeMs, + size: fileStat.size + } +} + +function stripClaudeSourceMetadata(turn: ClaudeUsageParsedSourceTurn): ClaudeUsageParsedTurn { + return { + sessionId: turn.sessionId, + timestamp: turn.timestamp, + model: turn.model, + cwd: turn.cwd, + gitBranch: turn.gitBranch, + inputTokens: turn.inputTokens, + outputTokens: turn.outputTokens, + cacheReadTokens: turn.cacheReadTokens, + cacheWriteTokens: turn.cacheWriteTokens + } +} + +function dedupeClaudeUsageTurns(turns: ClaudeUsageParsedSourceTurn[]): ClaudeUsageParsedTurn[] { + const dedupeIndexByKey = new Map() + const deduped: ClaudeUsageParsedTurn[] = [] + + for (const turn of turns) { + if (turn.dedupeKey) { + const existingIndex = dedupeIndexByKey.get(turn.dedupeKey) + if (existingIndex !== undefined) { + const existing = deduped[existingIndex] + // Why: Claude Code streams repeated assistant rows with the same + // message/request IDs; later rows can contain more complete usage. + existing.inputTokens = Math.max(existing.inputTokens, turn.inputTokens) + existing.outputTokens = Math.max(existing.outputTokens, turn.outputTokens) + existing.cacheReadTokens = Math.max(existing.cacheReadTokens, turn.cacheReadTokens) + existing.cacheWriteTokens = Math.max(existing.cacheWriteTokens, turn.cacheWriteTokens) + continue + } + } + + const stripped = stripClaudeSourceMetadata(turn) + deduped.push(stripped) + if (turn.dedupeKey) { + dedupeIndexByKey.set(turn.dedupeKey, deduped.length - 1) + } + } + + return deduped +} + +function parseClaudeUsageSourceRecord( + line: string, + fallbackSessionId: string | null = null +): ClaudeUsageParsedSourceTurn | null { let parsed: ClaudeUsageSourceRecord try { parsed = JSON.parse(line) as ClaudeUsageSourceRecord @@ -124,7 +244,8 @@ export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | nu if (parsed.type !== 'assistant') { return null } - if (!parsed.sessionId || !parsed.timestamp) { + const sessionId = parsed.sessionId ?? fallbackSessionId + if (!sessionId || !parsed.timestamp) { return null } @@ -139,11 +260,13 @@ export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | nu } return { - sessionId: parsed.sessionId, + sessionId, timestamp: parsed.timestamp, model: parsed.message?.model ?? null, cwd: parsed.cwd ?? null, gitBranch: parsed.gitBranch ?? null, + dedupeKey: + parsed.message?.id && parsed.requestId ? `${parsed.message.id}:${parsed.requestId}` : null, inputTokens, outputTokens, cacheReadTokens, @@ -151,21 +274,27 @@ export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | nu } } +export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null { + const parsed = parseClaudeUsageSourceRecord(line) + return parsed ? stripClaudeSourceMetadata(parsed) : null +} + export async function parseClaudeUsageFile(filePath: string): Promise { - const turns: ClaudeUsageParsedTurn[] = [] + const turns: ClaudeUsageParsedSourceTurn[] = [] + const fallbackSessionId = basename(filePath, '.jsonl') const lines = createInterface({ input: createReadStream(filePath, { encoding: 'utf-8' }), crlfDelay: Infinity }) for await (const line of lines) { - const parsed = parseClaudeUsageRecord(line) + const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId) if (parsed) { turns.push(parsed) } } - return turns + return dedupeClaudeUsageTurns(turns) } async function readClaudeUsageScanFile(filePath: string): Promise<{ @@ -174,7 +303,8 @@ async function readClaudeUsageScanFile(filePath: string): Promise<{ }> { const fileStat = await stat(filePath) let lineCount = 0 - const turns: ClaudeUsageParsedTurn[] = [] + const turns: ClaudeUsageParsedSourceTurn[] = [] + const fallbackSessionId = basename(filePath, '.jsonl') const lines = createInterface({ input: createReadStream(filePath, { encoding: 'utf-8' }), crlfDelay: Infinity @@ -182,7 +312,7 @@ async function readClaudeUsageScanFile(filePath: string): Promise<{ for await (const line of lines) { lineCount++ - const parsed = parseClaudeUsageRecord(line) + const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId) if (parsed) { turns.push(parsed) } @@ -192,9 +322,10 @@ async function readClaudeUsageScanFile(filePath: string): Promise<{ processedFile: { path: filePath, mtimeMs: fileStat.mtimeMs, + size: fileStat.size, lineCount }, - turns + turns: dedupeClaudeUsageTurns(turns) } } @@ -224,6 +355,7 @@ export async function attributeClaudeUsageTurns( worktreeLookup: Map ): Promise { const attributed: ClaudeUsageAttributedTurn[] = [] + const canonicalCwdByPath = new Map() for (const turn of turns) { const day = localDayFromTimestamp(turn.timestamp) @@ -237,7 +369,14 @@ export async function attributeClaudeUsageTurns( let projectLabel = getDefaultProjectLabel(turn.cwd) if (turn.cwd) { - const worktree = worktreeLookup.get(await canonicalizePath(turn.cwd)) + let canonicalCwd = canonicalCwdByPath.get(turn.cwd) + if (canonicalCwd === undefined) { + // Why: Claude transcripts repeat the same cwd for many consecutive + // turns. Cache realpath work so attribution scales with unique paths. + canonicalCwd = await canonicalizePath(turn.cwd) + canonicalCwdByPath.set(turn.cwd, canonicalCwd) + } + const worktree = findContainingWorktree(canonicalCwd, worktreeLookup) if (worktree) { repoId = worktree.repoId worktreeId = worktree.worktreeId @@ -261,6 +400,90 @@ export async function attributeClaudeUsageTurns( return attributed } +function mergeClaudeSessions( + target: Map, + sessions: ClaudeUsageSession[] +): void { + for (const session of sessions) { + const existing = target.get(session.sessionId) + if (!existing) { + target.set(session.sessionId, structuredClone(session)) + continue + } + + if (session.firstTimestamp < existing.firstTimestamp) { + existing.firstTimestamp = session.firstTimestamp + } + if (session.lastTimestamp > existing.lastTimestamp) { + existing.lastTimestamp = session.lastTimestamp + existing.lastCwd = session.lastCwd + existing.lastGitBranch = session.lastGitBranch + } + existing.model = session.model ?? existing.model + existing.turnCount += session.turnCount + existing.totalInputTokens += session.totalInputTokens + existing.totalOutputTokens += session.totalOutputTokens + existing.totalCacheReadTokens += session.totalCacheReadTokens + existing.totalCacheWriteTokens += session.totalCacheWriteTokens + + for (const location of session.locationBreakdown) { + const existingLocation = + existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ?? + null + if (existingLocation) { + existingLocation.turnCount += location.turnCount + existingLocation.inputTokens += location.inputTokens + existingLocation.outputTokens += location.outputTokens + existingLocation.cacheReadTokens += location.cacheReadTokens + existingLocation.cacheWriteTokens += location.cacheWriteTokens + } else { + existing.locationBreakdown.push({ ...location }) + } + } + } +} + +function mergeClaudeDailyAggregates( + target: Map, + dailyAggregates: ClaudeUsageDailyAggregate[] +): void { + for (const aggregate of dailyAggregates) { + const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::') + const existing = target.get(key) + if (!existing) { + target.set(key, { ...aggregate }) + continue + } + existing.turnCount += aggregate.turnCount + existing.zeroCacheReadTurnCount += aggregate.zeroCacheReadTurnCount + existing.inputTokens += aggregate.inputTokens + existing.outputTokens += aggregate.outputTokens + existing.cacheReadTokens += aggregate.cacheReadTokens + existing.cacheWriteTokens += aggregate.cacheWriteTokens + } +} + +function finalizeClaudeSessions( + sessionsById: Map +): ClaudeUsageSession[] { + for (const session of sessionsById.values()) { + session.locationBreakdown.sort((left, right) => { + const leftTotal = left.inputTokens + left.outputTokens + const rightTotal = right.inputTokens + right.outputTokens + return rightTotal - leftTotal + }) + const primaryLocation = session.locationBreakdown[0] ?? null + if (primaryLocation) { + session.primaryRepoId = primaryLocation.repoId + session.primaryWorktreeId = primaryLocation.worktreeId + } + } + + return [...sessionsById.values()].sort((left, right) => + right.lastTimestamp.localeCompare(left.lastTimestamp) + ) +} + export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { sessions: ClaudeUsageSession[] dailyAggregates: ClaudeUsageDailyAggregate[] @@ -356,23 +579,8 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { } } - for (const session of sessionsById.values()) { - session.locationBreakdown.sort((left, right) => { - const leftTotal = left.inputTokens + left.outputTokens - const rightTotal = right.inputTokens + right.outputTokens - return rightTotal - leftTotal - }) - const primaryLocation = session.locationBreakdown[0] ?? null - if (primaryLocation) { - session.primaryRepoId = primaryLocation.repoId - session.primaryWorktreeId = primaryLocation.worktreeId - } - } - return { - sessions: [...sessionsById.values()].sort((left, right) => - right.lastTimestamp.localeCompare(left.lastTimestamp) - ), + sessions: finalizeClaudeSessions(sessionsById), dailyAggregates: [...dailyByKey.values()].sort((left, right) => left.day === right.day ? left.projectLabel.localeCompare(right.projectLabel) @@ -381,22 +589,55 @@ export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): { } } -export async function scanClaudeUsageFiles(worktrees: ClaudeUsageWorktreeRef[]): Promise<{ - processedFiles: ClaudeUsageProcessedFile[] +async function parseClaudeUsagePersistedFile( + filePath: string, + worktreeLookup: Map +): Promise { + const { processedFile, turns } = await readClaudeUsageScanFile(filePath) + const attributed = await attributeClaudeUsageTurns(turns, worktreeLookup) + return { + ...processedFile, + ...aggregateClaudeUsage(attributed) + } +} + +export async function scanClaudeUsageFiles( + worktrees: ClaudeUsageWorktreeRef[], + previousProcessedFiles: ClaudeUsagePersistedFile[] = [] +): Promise<{ + processedFiles: ClaudeUsagePersistedFile[] sessions: ClaudeUsageSession[] dailyAggregates: ClaudeUsageDailyAggregate[] }> { const files = await listClaudeTranscriptFiles() - const processedFiles: ClaudeUsageProcessedFile[] = [] - const allTurns: ClaudeUsageParsedTurn[] = [] + const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file])) + const processedFiles: ClaudeUsagePersistedFile[] = [] const worktreeLookup = await buildWorktreeLookup(worktrees) + const sessionsById = new Map() + const dailyByKey = new Map() for (let index = 0; index < files.length; index += FILE_SCAN_BATCH_SIZE) { const batch = files.slice(index, index + FILE_SCAN_BATCH_SIZE) - const results = await Promise.all(batch.map((filePath) => readClaudeUsageScanFile(filePath))) - for (const result of results) { - processedFiles.push(result.processedFile) - allTurns.push(...result.turns) + const results = await Promise.all( + batch.map(async (filePath) => { + const fileInfo = await getProcessedFileStat(filePath) + const previous = previousByPath.get(filePath) + // Why: Claude histories can be gigabytes. Unchanged files should pay + // only stat cost on refresh while preserving exactly the old projection. + const canReuse = + previous && + previous.mtimeMs === fileInfo.mtimeMs && + previous.size === fileInfo.size && + Array.isArray(previous.sessions) && + Array.isArray(previous.dailyAggregates) + + return canReuse ? previous : parseClaudeUsagePersistedFile(filePath, worktreeLookup) + }) + ) + for (const processed of results) { + processedFiles.push(processed) + mergeClaudeSessions(sessionsById, processed.sessions) + mergeClaudeDailyAggregates(dailyByKey, processed.dailyAggregates) } // Why: transcript scans run in Electron's main process. Small parallel // batches cut independent file I/O without letting Settings stay blocked. @@ -405,10 +646,14 @@ export async function scanClaudeUsageFiles(worktrees: ClaudeUsageWorktreeRef[]): } } - const attributed = await attributeClaudeUsageTurns(allTurns, worktreeLookup) return { processedFiles, - ...aggregateClaudeUsage(attributed) + sessions: finalizeClaudeSessions(sessionsById), + dailyAggregates: [...dailyByKey.values()].sort((left, right) => + left.day === right.day + ? left.projectLabel.localeCompare(right.projectLabel) + : left.day.localeCompare(right.day) + ) } } @@ -416,6 +661,10 @@ export function getClaudeProjectsDirectory(): string { return CLAUDE_PROJECTS_DIR } +export function getClaudeTranscriptsDirectory(): string { + return CLAUDE_TRANSCRIPTS_DIR +} + export function createWorktreeRefs( repos: Repo[], worktreesByRepo: Map diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index e3e1ff416a2..2ae6a09990c 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -17,6 +17,7 @@ function createStoreWithState(state: Partial): Claude ;(store as unknown as { state: ClaudeUsagePersistedState }).state = { schemaVersion: 1, + worktreeFingerprint: null, processedFiles: [], sessions: [], dailyAggregates: [], @@ -231,4 +232,29 @@ describe('ClaudeUsageStore', () => { expect(summary.estimatedCostUsd).toBeCloseTo(110.25) }) + + it('prices Sonnet long-context usage with threshold rates', async () => { + const store = createStoreWithState({ + dailyAggregates: [ + { + day: '2026-04-09', + model: 'claude-sonnet-4-6', + projectKey: 'worktree:repo-1::/workspace/repo-a', + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo-a', + turnCount: 1, + zeroCacheReadTurnCount: 0, + inputTokens: 300_000, + outputTokens: 300_000, + cacheReadTokens: 300_000, + cacheWriteTokens: 300_000 + } + ] + }) + + const summary = await store.getSummary('orca', '30d') + + expect(summary.estimatedCostUsd).toBeCloseTo(8.07) + }) }) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 476782abf44..dbc2add004f 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -13,38 +13,87 @@ import type { ClaudeUsageSummary } from '../../shared/claude-usage-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo } from '../usage-worktree-metadata' +import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' import { createWorktreeRefs, getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' -const SCHEMA_VERSION = 1 +const SCHEMA_VERSION = 3 const STALE_MS = 5 * 60_000 // Why: capture the path after configureDevUserDataPath() but before app.setName() // mutates Electron's derived userData location, matching the persistence/store pattern. let _claudeUsageFile: string | null = null -const MODEL_PRICING: Record< - string, - { input: number; output: number; cacheRead: number; cacheWrite: number } -> = { +type ClaudeModelPricing = { + input: number + output: number + cacheRead: number + cacheWrite: number + thresholdTokens?: number + inputAboveThreshold?: number + outputAboveThreshold?: number + cacheReadAboveThreshold?: number + cacheWriteAboveThreshold?: number +} + +const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000 +const SONNET_LONG_CONTEXT_PRICING = { + thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS, + inputAboveThreshold: 6, + outputAboveThreshold: 22.5, + cacheReadAboveThreshold: 0.6, + cacheWriteAboveThreshold: 7.5 +} satisfies Partial + +const MODEL_PRICING: Record = { 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, 'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, 'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, 'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, - 'claude-sonnet-4-6': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - 'claude-sonnet-4-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - 'claude-sonnet-4': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + 'claude-sonnet-4-6': { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + ...SONNET_LONG_CONTEXT_PRICING + }, + 'claude-sonnet-4-5': { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + ...SONNET_LONG_CONTEXT_PRICING + }, + 'claude-sonnet-4': { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 3.75, + ...SONNET_LONG_CONTEXT_PRICING + }, 'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + 'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, 'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, 'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 }, 'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 } } +const MODEL_ALIASES: Record = { + model_placeholder_m26: 'claude-opus-4-6', + model_placeholder_m35: 'claude-sonnet-4-6', + 'claude-opus-4.6': 'claude-opus-4-6', + 'claude-sonnet-4.6': 'claude-sonnet-4-6', + 'claude-opus-4.6-thinking': 'claude-opus-4-6', + 'claude-sonnet-4.6-thinking': 'claude-sonnet-4-6', + 'claude-opus-4-6-thinking': 'claude-opus-4-6', + 'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6' +} + function getDefaultState(): ClaudeUsagePersistedState { return { schemaVersion: SCHEMA_VERSION, + worktreeFingerprint: null, processedFiles: [], sessions: [], dailyAggregates: [], @@ -72,7 +121,14 @@ function normalizeModelForPricing(model: string | null): string | null { if (!model) { return null } - const lower = model.toLowerCase() + const lower = model + .toLowerCase() + .trim() + .replace(/^anthropic[/:]/, '') + const alias = MODEL_ALIASES[lower] + if (alias) { + return alias + } if (lower.includes('opus-4-7')) { return 'claude-opus-4-7' } @@ -103,8 +159,13 @@ function normalizeModelForPricing(model: string | null): string | null { // Why: legacy version-first IDs like `claude-3-5-sonnet-20241022` are still // present in historical Claude Code/SDK logs read off disk. Match them so // their cost is not silently dropped from the breakdown. - if (lower.includes('3-5-sonnet') || lower.includes('3.5-sonnet')) { - return 'claude-sonnet-3-7' + if ( + lower.includes('sonnet-3-5') || + lower.includes('sonnet-3.5') || + lower.includes('3-5-sonnet') || + lower.includes('3.5-sonnet') + ) { + return 'claude-sonnet-3-5' } if (lower.includes('haiku-4-5')) { return 'claude-haiku-4-5' @@ -121,6 +182,20 @@ function normalizeModelForPricing(model: string | null): string | null { return null } +function calculateTieredCost( + tokens: number, + basePrice: number, + abovePrice?: number, + threshold?: number +): number { + if (threshold === undefined || abovePrice === undefined) { + return tokens * basePrice + } + const belowTokens = Math.min(tokens, threshold) + const aboveTokens = Math.max(tokens - threshold, 0) + return belowTokens * basePrice + aboveTokens * abovePrice +} + function estimateCostUsd( model: string | null, inputTokens: number, @@ -134,10 +209,30 @@ function estimateCostUsd( } const pricing = MODEL_PRICING[normalized] return ( - (inputTokens * pricing.input + - outputTokens * pricing.output + - cacheReadTokens * pricing.cacheRead + - cacheWriteTokens * pricing.cacheWrite) / + (calculateTieredCost( + inputTokens, + pricing.input, + pricing.inputAboveThreshold, + pricing.thresholdTokens + ) + + calculateTieredCost( + outputTokens, + pricing.output, + pricing.outputAboveThreshold, + pricing.thresholdTokens + ) + + calculateTieredCost( + cacheReadTokens, + pricing.cacheRead, + pricing.cacheReadAboveThreshold, + pricing.thresholdTokens + ) + + calculateTieredCost( + cacheWriteTokens, + pricing.cacheWrite, + pricing.cacheWriteAboveThreshold, + pricing.thresholdTokens + )) / 1_000_000 ) } @@ -167,6 +262,22 @@ function getLocalDay(timestamp: string): string | null { return `${year}-${month}-${day}` } +function getWorktreeFingerprint(worktreesByRepo: Map): string { + const rows = [...worktreesByRepo.entries()] + .flatMap(([repoId, worktrees]) => + worktrees.map((worktree) => + JSON.stringify({ + repoId, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + ) + ) + .sort() + return JSON.stringify(rows) +} + export class ClaudeUsageStore { private state: ClaudeUsagePersistedState private readonly store: Store @@ -184,6 +295,11 @@ export class ClaudeUsageStore { return getDefaultState() } const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState + if (parsed.schemaVersion !== SCHEMA_VERSION) { + // Why: scanner semantics affect persisted totals, so old Claude caches + // must be rebuilt after parser/source changes instead of reused briefly. + return getDefaultState() + } return { ...getDefaultState(), ...parsed, @@ -233,9 +349,10 @@ export class ClaudeUsageStore { if (!this.state.scanState.enabled) { return this.getScanState() } + const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() if (!force && this.state.scanState.lastScanCompletedAt) { const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS) { + if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { return this.getScanState() } } @@ -257,10 +374,15 @@ export class ClaudeUsageStore { try { const repos = this.store.getRepos() const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const result = await scanClaudeUsageFiles(createWorktreeRefs(repos, worktreesByRepo)) + const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) + const result = await scanClaudeUsageFiles( + createWorktreeRefs(repos, worktreesByRepo), + this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : [] + ) this.state.processedFiles = result.processedFiles this.state.sessions = result.sessions this.state.dailyAggregates = result.dailyAggregates + this.state.worktreeFingerprint = worktreeFingerprint this.state.scanState.lastScanCompletedAt = Date.now() this.state.scanState.lastScanError = null this.writeToDisk() @@ -532,4 +654,10 @@ export class ClaudeUsageStore { return true }) } + + private async getCurrentWorktreeFingerprint(): Promise { + const repos = this.store.getRepos() + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) + return getWorktreeFingerprint(worktreesByRepo) + } } diff --git a/src/main/claude-usage/types.ts b/src/main/claude-usage/types.ts index af16c10f444..8760621d754 100644 --- a/src/main/claude-usage/types.ts +++ b/src/main/claude-usage/types.ts @@ -1,6 +1,7 @@ export type ClaudeUsageProcessedFile = { path: string mtimeMs: number + size: number lineCount: number } @@ -50,7 +51,8 @@ export type ClaudeUsageDailyAggregate = { export type ClaudeUsagePersistedState = { schemaVersion: number - processedFiles: ClaudeUsageProcessedFile[] + worktreeFingerprint: string | null + processedFiles: ClaudeUsagePersistedFile[] sessions: ClaudeUsageSession[] dailyAggregates: ClaudeUsageDailyAggregate[] scanState: { @@ -61,6 +63,11 @@ export type ClaudeUsagePersistedState = { } } +export type ClaudeUsagePersistedFile = ClaudeUsageProcessedFile & { + sessions: ClaudeUsageSession[] + dailyAggregates: ClaudeUsageDailyAggregate[] +} + export type ClaudeUsageParsedTurn = { sessionId: string timestamp: string diff --git a/src/main/codex-usage/scanner.test.ts b/src/main/codex-usage/scanner.test.ts index 7211c78ea20..d7251b684ea 100644 --- a/src/main/codex-usage/scanner.test.ts +++ b/src/main/codex-usage/scanner.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { attributeCodexUsageEvent, parseCodexUsageRecord } from './scanner' describe('parseCodexUsageRecord', () => { - it('uses cumulative totals to avoid double-counting repeated token snapshots', () => { + it('uses token totals only as a duplicate baseline', () => { const context = { sessionId: 'session-1', sessionCwd: null, @@ -100,7 +100,7 @@ describe('parseCodexUsageRecord', () => { expect(duplicate).toBeNull() }) - it('falls back to inferred gpt-5 pricing when model metadata is missing', () => { + it('preserves unknown model metadata instead of assigning fallback pricing', () => { const context = { sessionId: 'session-1', sessionCwd: '/workspace/repo', @@ -129,9 +129,54 @@ describe('parseCodexUsageRecord', () => { context ) - expect(parsed?.model).toBe('gpt-5') + expect(parsed?.model).toBeNull() expect(parsed?.hasInferredPricing).toBe(true) }) + + it('uses last token usage for the first resumed-session event', () => { + const context = { + sessionId: 'session-1', + sessionCwd: '/workspace/repo', + currentCwd: '/workspace/repo', + currentModel: 'gpt-5.5', + previousTotals: null + } + + const parsed = parseCodexUsageRecord( + JSON.stringify({ + timestamp: '2026-04-09T10:00:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 50_000, + cached_input_tokens: 40_000, + output_tokens: 5_000, + reasoning_output_tokens: 1_000, + total_tokens: 55_000 + }, + last_token_usage: { + input_tokens: 2_000, + cached_input_tokens: 1_500, + output_tokens: 500, + reasoning_output_tokens: 100, + total_tokens: 2_500 + } + } + } + }), + context + ) + + expect(parsed).toMatchObject({ + inputTokens: 2_000, + cachedInputTokens: 1_500, + outputTokens: 500, + reasoningOutputTokens: 100, + totalTokens: 2_500 + }) + }) }) describe('attributeCodexUsageEvent', () => { diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index 8cfc89ee81c..130336204db 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -47,8 +47,11 @@ type CodexUsageParseContext = { previousTotals: CodexUsageRawUsage | null } +type CodexUsageDeltaResolution = + | { kind: 'event'; delta: CodexUsageRawUsage; nextTotals: CodexUsageRawUsage | null } + | { kind: 'baseline'; nextTotals: CodexUsageRawUsage } + const DEFAULT_CODEX_SESSIONS_DIR = join(homedir(), '.codex', 'sessions') -const LEGACY_FALLBACK_MODEL = 'gpt-5' const YIELD_EVERY_FILES = 10 function ensureNumber(value: unknown): number { @@ -171,6 +174,107 @@ function subtractRawUsage( } } +function addRawUsage(left: CodexUsageRawUsage, right: CodexUsageRawUsage): CodexUsageRawUsage { + return { + inputTokens: left.inputTokens + right.inputTokens, + cachedInputTokens: left.cachedInputTokens + right.cachedInputTokens, + outputTokens: left.outputTokens + right.outputTokens, + reasoningOutputTokens: left.reasoningOutputTokens + right.reasoningOutputTokens, + totalTokens: left.totalTokens + right.totalTokens + } +} + +function rawUsageEquals(left: CodexUsageRawUsage, right: CodexUsageRawUsage): boolean { + return ( + left.inputTokens === right.inputTokens && + left.cachedInputTokens === right.cachedInputTokens && + left.outputTokens === right.outputTokens && + left.reasoningOutputTokens === right.reasoningOutputTokens + ) +} + +function rawUsageIsMonotonic(current: CodexUsageRawUsage, previous: CodexUsageRawUsage): boolean { + return ( + current.inputTokens >= previous.inputTokens && + current.cachedInputTokens >= previous.cachedInputTokens && + current.outputTokens >= previous.outputTokens && + current.reasoningOutputTokens >= previous.reasoningOutputTokens + ) +} + +function rawUsageMagnitude(usage: CodexUsageRawUsage): number { + return ( + usage.inputTokens + usage.cachedInputTokens + usage.outputTokens + usage.reasoningOutputTokens + ) +} + +function looksLikeStaleRegression( + current: CodexUsageRawUsage, + previous: CodexUsageRawUsage, + last: CodexUsageRawUsage +): boolean { + const previousTotal = rawUsageMagnitude(previous) + const currentTotal = rawUsageMagnitude(current) + const lastTotal = rawUsageMagnitude(last) + if (previousTotal <= 0 || currentTotal <= 0 || lastTotal <= 0) { + return false + } + return currentTotal * 100 >= previousTotal * 98 || currentTotal + lastTotal * 2 >= previousTotal +} + +function resolveCodexUsageDelta( + totalUsage: CodexUsageRawUsage | null, + lastUsage: CodexUsageRawUsage | null, + previousTotals: CodexUsageRawUsage | null +): CodexUsageDeltaResolution | null { + if (totalUsage && lastUsage && previousTotals) { + if (rawUsageEquals(totalUsage, previousTotals)) { + return null + } + if ( + !rawUsageIsMonotonic(totalUsage, previousTotals) && + looksLikeStaleRegression(totalUsage, previousTotals, lastUsage) + ) { + return null + } + // Why: Codex totals are mutable snapshots after compaction/resume. The + // last_token_usage payload is the billable increment; totals are the baseline. + return { kind: 'event', delta: lastUsage, nextTotals: totalUsage } + } + + if (totalUsage && lastUsage) { + return { kind: 'event', delta: lastUsage, nextTotals: totalUsage } + } + + if (totalUsage && previousTotals) { + if (rawUsageEquals(totalUsage, previousTotals)) { + return null + } + if (!rawUsageIsMonotonic(totalUsage, previousTotals)) { + return { kind: 'baseline', nextTotals: totalUsage } + } + return { + kind: 'event', + delta: subtractRawUsage(totalUsage, previousTotals), + nextTotals: totalUsage + } + } + + if (totalUsage) { + return { kind: 'event', delta: totalUsage, nextTotals: totalUsage } + } + + if (lastUsage && previousTotals) { + return { kind: 'event', delta: lastUsage, nextTotals: addRawUsage(previousTotals, lastUsage) } + } + + if (lastUsage) { + return { kind: 'event', delta: lastUsage, nextTotals: null } + } + + return null +} + function extractString(value: unknown): string | null { if (typeof value !== 'string') { return null @@ -688,17 +792,21 @@ export function parseCodexUsageRecord( const record = info as Record const totalUsage = normalizeRawUsage(record.total_token_usage) const lastUsage = normalizeRawUsage(record.last_token_usage) - let delta = totalUsage ? subtractRawUsage(totalUsage, context.previousTotals) : lastUsage - if (totalUsage) { - context.previousTotals = totalUsage + const resolvedUsage = resolveCodexUsageDelta(totalUsage, lastUsage, context.previousTotals) + if (!resolvedUsage) { + return null } - if (!delta) { + if (resolvedUsage.kind === 'baseline') { + context.previousTotals = resolvedUsage.nextTotals return null } - delta = { - ...delta, - cachedInputTokens: Math.min(delta.cachedInputTokens, delta.inputTokens) + let delta = { + ...resolvedUsage.delta, + cachedInputTokens: Math.min( + resolvedUsage.delta.cachedInputTokens, + resolvedUsage.delta.inputTokens + ) } if ( @@ -711,15 +819,16 @@ export function parseCodexUsageRecord( return null } + context.previousTotals = resolvedUsage.nextTotals + const resolvedModel = extractModel(parsed.payload) ?? context.currentModel - const model = resolvedModel ?? LEGACY_FALLBACK_MODEL const hasInferredPricing = resolvedModel === null return { sessionId: context.sessionId, timestamp: parsed.timestamp, cwd: context.currentCwd ?? context.sessionCwd, - model, + model: resolvedModel, hasInferredPricing, inputTokens: delta.inputTokens, cachedInputTokens: delta.cachedInputTokens, diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index da0549d2387..00686de5971 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -233,15 +233,79 @@ describe('CodexUsageStore', () => { const summary = await store.getSummary('orca', '30d') const breakdown = await store.getBreakdown('orca', '30d', 'model') - expect(summary.estimatedCostUsd).toBeCloseTo(85.1) + expect(summary.estimatedCostUsd).toBeCloseTo(107.486) expect(breakdown.find((row) => row.key === 'gpt-5.2-codex')?.estimatedCostUsd).toBeCloseTo( 15.925 ) expect(breakdown.find((row) => row.key === 'gpt-5.3-codex')?.estimatedCostUsd).toBeCloseTo( 15.925 ) - expect(breakdown.find((row) => row.key === 'gpt-5.4')?.estimatedCostUsd).toBeCloseTo(17.75) - expect(breakdown.find((row) => row.key === 'gpt-5.5')?.estimatedCostUsd).toBeCloseTo(35.5) + expect(breakdown.find((row) => row.key === 'gpt-5.4')?.estimatedCostUsd).toBeCloseTo(25.212) + expect(breakdown.find((row) => row.key === 'gpt-5.5')?.estimatedCostUsd).toBeCloseTo(50.424) + }) + + it('normalizes Codex model variants and reasoning suffixes before pricing', async () => { + const store = createStoreWithState({ + dailyAggregates: [ + { + day: '2026-04-09', + model: 'gpt-5.4-mini-high', + projectKey: 'worktree:repo-1::/workspace/repo', + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + eventCount: 1, + inputTokens: 1_000_000, + cachedInputTokens: 500_000, + outputTokens: 1_000_000, + reasoningOutputTokens: 100_000, + totalTokens: 2_000_000, + hasInferredPricing: false + }, + { + day: '2026-04-09', + model: 'gpt-5.3-codex-spark-xhigh', + projectKey: 'worktree:repo-1::/workspace/repo', + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + eventCount: 1, + inputTokens: 2_000_000, + cachedInputTokens: 1_000_000, + outputTokens: 1_000_000, + reasoningOutputTokens: 100_000, + totalTokens: 3_000_000, + hasInferredPricing: false + }, + { + day: '2026-04-09', + model: 'gpt-5.5(xhigh)', + projectKey: 'worktree:repo-1::/workspace/repo', + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + eventCount: 1, + inputTokens: 100_000, + cachedInputTokens: 50_000, + outputTokens: 25_000, + reasoningOutputTokens: 5_000, + totalTokens: 125_000, + hasInferredPricing: false + } + ] + }) + + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + expect(breakdown.find((row) => row.key === 'gpt-5.4-mini-high')?.estimatedCostUsd).toBeCloseTo( + 4.9125 + ) + expect( + breakdown.find((row) => row.key === 'gpt-5.3-codex-spark-xhigh')?.estimatedCostUsd + ).toBeCloseTo(15.925) + expect(breakdown.find((row) => row.key === 'gpt-5.5(xhigh)')?.estimatedCostUsd).toBeCloseTo( + 1.025 + ) }) it('keeps cached input out of the full-price input bucket for GPT-5.5 totals', async () => { @@ -267,7 +331,7 @@ describe('CodexUsageStore', () => { const summary = await store.getSummary('orca', '30d') - expect(summary.estimatedCostUsd).toBeCloseTo(446.840002) + expect(summary.estimatedCostUsd).toBeCloseTo(858.929724) }) it('counts mixed-model sessions once for each real model row in the breakdown', async () => { @@ -584,7 +648,7 @@ describe('CodexUsageStore', () => { } as unknown as CodexUsagePersistedState) expect(normalized).toEqual({ - schemaVersion: 2, + schemaVersion: 3, worktreeFingerprint: null, processedFiles: [], sessions: [], diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index dba5c4165a0..8699e4f49d1 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -17,20 +17,71 @@ import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-w import type { CodexUsagePersistedState } from './types' import { createWorktreeRefs, scanCodexUsageFiles } from './scanner' -const SCHEMA_VERSION = 2 +const SCHEMA_VERSION = 3 const STALE_MS = 5 * 60_000 let _codexUsageFile: string | null = null -const MODEL_PRICING: Record = { +type TieredPrice = { threshold: number; price: number } +type CodexModelPricing = { + input: number + cachedInput: number + output: number + inputTiers?: TieredPrice[] + cachedInputTiers?: TieredPrice[] + outputTiers?: TieredPrice[] +} + +const LONG_CONTEXT_THRESHOLD_TOKENS = 272_000 + +const MODEL_PRICING: Record = { 'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10 }, 'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10 }, + 'gpt-5.1-codex': { input: 1.25, cachedInput: 0.125, output: 10 }, + 'gpt-5.1-codex-max': { input: 1.25, cachedInput: 0.125, output: 10 }, 'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14 }, + 'gpt-5.2-codex': { input: 1.75, cachedInput: 0.175, output: 14 }, + 'gpt-5.3': { input: 1.75, cachedInput: 0.175, output: 14 }, 'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14 }, - 'gpt-5.4': { input: 2.5, cachedInput: 0.25, output: 15 }, - 'gpt-5.5': { input: 5, cachedInput: 0.5, output: 30 } + 'gpt-5.3-codex-spark': { input: 1.75, cachedInput: 0.175, output: 14 }, + 'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, output: 4.5 }, + 'gpt-5.4-nano': { input: 0.2, cachedInput: 0.02, output: 1.25 }, + 'gpt-5.4-pro': { + input: 30, + cachedInput: 30, + output: 180, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }] + }, + 'gpt-5.4': { + input: 2.5, + cachedInput: 0.25, + output: 15, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }] + }, + 'gpt-5.5-pro': { + input: 30, + cachedInput: 30, + output: 180, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }] + }, + 'gpt-5.5': { + input: 5, + cachedInput: 0.5, + output: 30, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }] + } } +const REASONING_TIER_SUFFIXES = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto', 'none'] + function getDefaultState(): CodexUsagePersistedState { return { schemaVersion: SCHEMA_VERSION, @@ -75,33 +126,104 @@ function getCodexUsageFile(): string { return _codexUsageFile } +function stripParenthesizedReasoningTier(model: string): string | null { + const match = model.match(/^(.*)\(([^()]*)\)$/) + if (!match) { + return model + } + const tier = match[2].trim().toLowerCase() + if (!REASONING_TIER_SUFFIXES.includes(tier)) { + return null + } + return match[1] +} + +function stripDashReasoningTiers(model: string): string { + let current = model + for (let index = 0; index < 4; index++) { + const suffix = REASONING_TIER_SUFFIXES.find((tier) => current.endsWith(`-${tier}`)) + if (!suffix) { + return current + } + current = current.slice(0, -suffix.length - 1) + } + return current +} + function normalizeModelForPricing(model: string | null): string | null { if (!model) { return null } - const lower = model.toLowerCase() - if (lower === 'gpt-5' || lower === 'gpt-5-codex') { + const lower = stripParenthesizedReasoningTier(model.toLowerCase().trim()) + if (!lower) { + return null + } + + const normalized = stripDashReasoningTiers(lower) + if (normalized === 'gpt-5' || normalized === 'gpt-5-codex') { return 'gpt-5' } - if (lower.startsWith('gpt-5.1')) { + if (normalized === 'gpt-5.1-codex-max' || normalized.startsWith('gpt-5.1-codex-max-')) { + return 'gpt-5.1-codex-max' + } + if (normalized === 'gpt-5.1-codex' || normalized.startsWith('gpt-5.1-codex-')) { + return 'gpt-5.1-codex' + } + if (normalized === 'gpt-5.1' || normalized.startsWith('gpt-5.1-')) { return 'gpt-5.1' } - if (lower.startsWith('gpt-5.2')) { + if (normalized === 'gpt-5.2-codex' || normalized.startsWith('gpt-5.2-codex-')) { + return 'gpt-5.2-codex' + } + if (normalized === 'gpt-5.2' || normalized.startsWith('gpt-5.2-')) { return 'gpt-5.2' } - if (lower.startsWith('gpt-5.3-codex')) { + if (normalized === 'gpt-5.3-codex-spark' || normalized.startsWith('gpt-5.3-codex-spark-')) { + return 'gpt-5.3-codex-spark' + } + if (normalized === 'gpt-5.3-codex' || normalized.startsWith('gpt-5.3-codex-')) { return 'gpt-5.3-codex' } - if (lower.startsWith('gpt-5.4')) { + if (normalized === 'gpt-5.3' || normalized.startsWith('gpt-5.3-')) { + return 'gpt-5.3' + } + if (normalized === 'gpt-5.4-mini' || normalized.startsWith('gpt-5.4-mini-')) { + return 'gpt-5.4-mini' + } + if (normalized === 'gpt-5.4-nano' || normalized.startsWith('gpt-5.4-nano-')) { + return 'gpt-5.4-nano' + } + if (normalized === 'gpt-5.4-pro' || normalized.startsWith('gpt-5.4-pro-')) { + return 'gpt-5.4-pro' + } + if (normalized === 'gpt-5.4' || normalized.startsWith('gpt-5.4-')) { return 'gpt-5.4' } - if (lower.startsWith('gpt-5.5')) { + if (normalized === 'gpt-5.5-pro' || normalized.startsWith('gpt-5.5-pro-')) { + return 'gpt-5.5-pro' + } + if (normalized === 'gpt-5.5' || normalized.startsWith('gpt-5.5-')) { return 'gpt-5.5' } return null } +function calculateTieredCost(tokens: number, basePrice: number, tiers: TieredPrice[] = []): number { + let cost = 0 + let lowerBound = 0 + let activePrice = basePrice + for (const tier of tiers) { + if (tokens <= tier.threshold) { + return cost + Math.max(tokens - lowerBound, 0) * activePrice + } + cost += (tier.threshold - lowerBound) * activePrice + lowerBound = tier.threshold + activePrice = tier.price + } + return cost + Math.max(tokens - lowerBound, 0) * activePrice +} + function estimateCostUsd( model: string | null, inputTokens: number, @@ -119,9 +241,9 @@ function estimateCostUsd( // price and again at cache-read price. const nonCachedInputTokens = Math.max(inputTokens - clampedCached, 0) return ( - (nonCachedInputTokens * pricing.input + - clampedCached * pricing.cachedInput + - outputTokens * pricing.output) / + (calculateTieredCost(nonCachedInputTokens, pricing.input, pricing.inputTiers) + + calculateTieredCost(clampedCached, pricing.cachedInput, pricing.cachedInputTiers) + + calculateTieredCost(outputTokens, pricing.output, pricing.outputTiers)) / 1_000_000 ) } diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index d6eb79a6dbe..9d377436631 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -559,7 +559,7 @@ function Settings(): React.JSX.Element { { id: 'stats', title: 'Stats & Usage', - description: 'Orca stats and Claude usage analytics.', + description: 'Orca stats plus Claude and Codex usage analytics.', icon: BarChart3, searchEntries: STATS_PANE_SEARCH_ENTRIES }, diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx index 6722489a9fe..266b18a0e9a 100644 --- a/src/renderer/src/components/stats/StatsPane.tsx +++ b/src/renderer/src/components/stats/StatsPane.tsx @@ -4,6 +4,7 @@ import { useAppStore } from '../../store' import { StatCard } from './StatCard' import { ClaudeUsagePane } from './ClaudeUsagePane' import { CodexUsagePane } from './CodexUsagePane' +import { UsageOverviewPane } from './UsageOverviewPane' import type { SettingsSearchEntry } from '../settings/settings-search' import { cn } from '@/lib/utils' @@ -11,7 +12,7 @@ export const STATS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ { title: 'Stats & Usage', description: - 'Orca stats plus Claude and Codex usage analytics, tokens, cache, models, and sessions.', + 'Orca stats plus combined Claude and Codex usage analytics, tokens, cache, models, and sessions.', keywords: [ 'stats', 'usage', @@ -59,7 +60,7 @@ function formatTrackingSince(timestamp: number | null): string { export function StatsPane(): React.JSX.Element { const summary = useAppStore((s) => s.statsSummary) const fetchStatsSummary = useAppStore((s) => s.fetchStatsSummary) - const [activeUsageTab, setActiveUsageTab] = useState<'claude' | 'codex'>('claude') + const [activeUsageTab, setActiveUsageTab] = useState<'overview' | 'claude' | 'codex'>('overview') useEffect(() => { void fetchStatsSummary() @@ -110,7 +111,7 @@ export function StatsPane(): React.JSX.Element { aria-label="Usage analytics provider" className="inline-flex w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground" > - {(['claude', 'codex'] as const).map((tab) => ( + {(['overview', 'claude', 'codex'] as const).map((tab) => ( ))} @@ -132,7 +133,15 @@ export function StatsPane(): React.JSX.Element { {/* Why: the Stats section lives inside the scroll-tracked settings page. Keeping only the active panel mounted avoids hidden tab-content layout/focus churn that produced a visible vertical jitter below the usage card when switching disabled providers. */} -
{activeUsageTab === 'claude' ? : }
+
+ {activeUsageTab === 'overview' ? ( + + ) : activeUsageTab === 'claude' ? ( + + ) : ( + + )} +
) diff --git a/src/renderer/src/components/stats/UsageOverviewPane.tsx b/src/renderer/src/components/stats/UsageOverviewPane.tsx new file mode 100644 index 00000000000..202fcdf09d4 --- /dev/null +++ b/src/renderer/src/components/stats/UsageOverviewPane.tsx @@ -0,0 +1,412 @@ +import { useEffect, useMemo } from 'react' +import { + Activity, + AlertCircle, + CalendarDays, + Coins, + DatabaseZap, + RefreshCw, + Sparkles +} from 'lucide-react' +import { useAppStore } from '../../store' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' +import { StatCard } from './StatCard' +import { + buildUsageOverview, + formatUsageCost, + formatUsageTokens, + getRecentUsageDays, + type UsageOverviewDailyPoint, + type UsageOverviewModel, + type UsageProviderOverview +} from './usage-overview-model' + +const RECENT_DAY_COUNT = 42 + +const INTENSITY_CLASS: Record = { + 0: 'border-border/60 bg-muted/40', + 1: 'border-border/60 bg-muted-foreground/20', + 2: 'border-border/60 bg-muted-foreground/35', + 3: 'border-border/60 bg-muted-foreground/55', + 4: 'border-border/60 bg-foreground/75' +} + +function formatPercent(value: number | null): string { + if (value === null) { + return 'n/a' + } + return `${Math.round(value * 100)}%` +} + +function formatUpdatedAt(timestamp: number | null): string { + if (!timestamp) { + return 'Not scanned yet' + } + return `Updated ${new Date(timestamp).toLocaleString()}` +} + +function formatDayLabel(day: string): string { + const parsed = new Date(`${day}T12:00:00`) + if (Number.isNaN(parsed.getTime())) { + return day + } + return parsed.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +} + +function TokenMixBar({ overview }: { overview: UsageOverviewModel }): React.JSX.Element { + const segments = [ + { + key: 'new-input', + label: 'New input', + value: overview.newInputTokens, + className: 'bg-foreground' + }, + { + key: 'output', + label: 'Output', + value: overview.outputTokens, + className: 'bg-muted-foreground' + }, + { + key: 'cache', + label: 'Cache', + value: overview.cacheTokens, + className: 'bg-border' + } + ] + // Why: Codex cached input is a subset of input. The overview model normalizes + // that into new/cache buckets so the visual mix does not double-count it. + const mixTotal = segments.reduce((sum, segment) => sum + segment.value, 0) + + return ( +
+
+
+

Token mix

+

+ Combined input, output, and cache tokens across enabled providers. +

+
+ {overview.reasoningTokens > 0 ? ( + + {formatUsageTokens(overview.reasoningTokens)} reasoning + + ) : null} +
+ + {mixTotal > 0 ? ( +
+ {segments.map((segment) => + segment.value > 0 ? ( +
+ ) : null + )} +
+ ) : ( +
+ )} + +
+ {segments.map((segment) => ( +
+ + + {segment.label}: {formatUsageTokens(segment.value)} + +
+ ))} +
+
+ ) +} + +function DailyIntensityGrid({ + days, + bestDay +}: { + days: UsageOverviewDailyPoint[] + bestDay: UsageOverviewDailyPoint | null +}): React.JSX.Element { + return ( +
+
+
+

Daily intensity

+

+ Recent combined Claude and Codex token activity. +

+
+ {bestDay && bestDay.totalTokens > 0 ? ( + + Best: {formatDayLabel(bestDay.day)} + + ) : null} +
+ +
+ {days.map((day) => ( +
+ ))} +
+ +
+ {formatDayLabel(days[0]?.day ?? '')} + Less +
+ {[0, 1, 2, 3, 4].map((intensity) => ( + + ))} +
+ More + {formatDayLabel(days.at(-1)?.day ?? '')} +
+
+ ) +} + +function ProviderRow({ + provider, + totalTokens, + onEnable +}: { + provider: UsageProviderOverview + totalTokens: number + onEnable: () => void +}): React.JSX.Element { + const share = totalTokens > 0 ? provider.totalTokens / totalTokens : 0 + const status = provider.enabled ? (provider.isScanning ? 'Scanning' : 'Enabled') : 'Off' + const statusVariant = provider.enabled ? 'secondary' : 'outline' + + return ( +
+
+
+
+
{provider.label}
+ {status} +
+

+ {provider.topModel ?? 'No model yet'} + {provider.topProject ? ` - ${provider.topProject}` : ''} +

+
+ {!provider.enabled ? ( + + ) : null} +
+ +
+ {formatUsageTokens(provider.totalTokens)} tokens + + {provider.sessions.toLocaleString()} sessions - {provider.activityCount.toLocaleString()}{' '} + {provider.activityLabel} + + {formatUsageCost(provider.estimatedCostUsd)} +
+
+
0 ? 2 : 0)}%` }} + /> +
+ {provider.lastScanError ? ( +

+ + {provider.lastScanError} +

+ ) : null} +
+ ) +} + +export function UsageOverviewPane(): React.JSX.Element { + const claudeScanState = useAppStore((state) => state.claudeUsageScanState) + const claudeSummary = useAppStore((state) => state.claudeUsageSummary) + const claudeDaily = useAppStore((state) => state.claudeUsageDaily) + const codexScanState = useAppStore((state) => state.codexUsageScanState) + const codexSummary = useAppStore((state) => state.codexUsageSummary) + const codexDaily = useAppStore((state) => state.codexUsageDaily) + const fetchClaudeUsage = useAppStore((state) => state.fetchClaudeUsage) + const fetchCodexUsage = useAppStore((state) => state.fetchCodexUsage) + const refreshClaudeUsage = useAppStore((state) => state.refreshClaudeUsage) + const refreshCodexUsage = useAppStore((state) => state.refreshCodexUsage) + const enableClaudeUsage = useAppStore((state) => state.enableClaudeUsage) + const enableCodexUsage = useAppStore((state) => state.enableCodexUsage) + + useEffect(() => { + void fetchClaudeUsage() + void fetchCodexUsage() + }, [fetchClaudeUsage, fetchCodexUsage]) + + const overview = useMemo( + () => + buildUsageOverview({ + claude: { + scanState: claudeScanState, + summary: claudeSummary, + daily: claudeDaily + }, + codex: { + scanState: codexScanState, + summary: codexSummary, + daily: codexDaily + } + }), + [claudeDaily, claudeScanState, claudeSummary, codexDaily, codexScanState, codexSummary] + ) + const recentDays = useMemo( + () => getRecentUsageDays(overview.daily, RECENT_DAY_COUNT), + [overview.daily] + ) + const isScanning = overview.providers.some((provider) => provider.isScanning) + + const handleRefresh = (): void => { + void Promise.all([ + claudeScanState?.enabled ? refreshClaudeUsage() : Promise.resolve(), + codexScanState?.enabled ? refreshCodexUsage() : Promise.resolve() + ]) + } + + return ( +
+
+
+
+

Usage Overview

+

+ {formatUpdatedAt(overview.lastUpdatedAt)} + {overview.hasPartialCost ? ' - some model prices are unavailable' : ''} +

+
+ + + + + + Refresh + + +
+ + {!overview.hasAnyEnabledProvider ? ( +
+
+
+

Start tracking tokens

+

+ Enable a provider to scan local agent logs and build the combined token ledger. +

+
+
+ + +
+
+
+ ) : ( + <> +
+ } + /> + } + /> + } + /> + } + /> +
+ + {!overview.hasAnyData ? ( +
+ No local Claude or Codex usage found yet. The overview will populate after the next + agent session writes token logs. +
+ ) : ( +
+ + +
+ )} + + )} +
+ +
+
+
+

Providers

+

+ {overview.enabledProviderCount} enabled - {overview.dataProviderCount} with data +

+
+ + + {overview.sessions.toLocaleString()} sessions + +
+
+ {overview.providers.map((provider) => ( + { + if (provider.id === 'claude') { + void enableClaudeUsage() + } else { + void enableCodexUsage() + } + }} + /> + ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/stats/usage-overview-model.test.ts b/src/renderer/src/components/stats/usage-overview-model.test.ts new file mode 100644 index 00000000000..490bcc1b8f4 --- /dev/null +++ b/src/renderer/src/components/stats/usage-overview-model.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' +import type { + ClaudeUsageDailyPoint, + ClaudeUsageScanState, + ClaudeUsageSummary +} from '../../../../shared/claude-usage-types' +import type { + CodexUsageDailyPoint, + CodexUsageScanState, + CodexUsageSummary +} from '../../../../shared/codex-usage-types' +import { + buildUsageOverview, + formatUsageCost, + formatUsageTokens, + getRecentUsageDays +} from './usage-overview-model' + +function enabledClaudeScanState(): ClaudeUsageScanState { + return { + enabled: true, + isScanning: false, + lastScanStartedAt: 100, + lastScanCompletedAt: 200, + lastScanError: null, + hasAnyClaudeData: true + } +} + +function enabledCodexScanState(): CodexUsageScanState { + return { + enabled: true, + isScanning: false, + lastScanStartedAt: 300, + lastScanCompletedAt: 400, + lastScanError: null, + hasAnyCodexData: true + } +} + +describe('usage overview model', () => { + it('combines Claude and Codex totals without double-counting Codex cached input', () => { + const claudeSummary: ClaudeUsageSummary = { + scope: 'orca', + range: '30d', + sessions: 2, + turns: 4, + zeroCacheReadTurns: 1, + inputTokens: 1_000, + outputTokens: 500, + cacheReadTokens: 4_000, + cacheWriteTokens: 500, + cacheReuseRate: 0.8, + estimatedCostUsd: 0.04, + topModel: 'claude-sonnet-4-5', + topProject: 'orca-main', + hasAnyClaudeData: true + } + const codexSummary: CodexUsageSummary = { + scope: 'orca', + range: '30d', + sessions: 1, + events: 3, + inputTokens: 2_000, + cachedInputTokens: 800, + outputTokens: 1_200, + reasoningOutputTokens: 300, + totalTokens: 3_200, + estimatedCostUsd: 0.02, + topModel: 'gpt-5.4', + topProject: 'orca-secondary', + hasAnyCodexData: true + } + const claudeDaily: ClaudeUsageDailyPoint[] = [ + { + day: '2026-05-13', + inputTokens: 500, + outputTokens: 500, + cacheReadTokens: 2_500, + cacheWriteTokens: 0 + }, + { + day: '2026-05-14', + inputTokens: 500, + outputTokens: 0, + cacheReadTokens: 1_500, + cacheWriteTokens: 500 + } + ] + const codexDaily: CodexUsageDailyPoint[] = [ + { + day: '2026-05-14', + inputTokens: 1_200, + cachedInputTokens: 400, + outputTokens: 800, + reasoningOutputTokens: 100, + totalTokens: 2_000 + }, + { + day: '2026-05-15', + inputTokens: 800, + cachedInputTokens: 400, + outputTokens: 400, + reasoningOutputTokens: 200, + totalTokens: 1_200 + } + ] + + const overview = buildUsageOverview({ + claude: { + scanState: enabledClaudeScanState(), + summary: claudeSummary, + daily: claudeDaily + }, + codex: { + scanState: enabledCodexScanState(), + summary: codexSummary, + daily: codexDaily + } + }) + + expect(overview.totalTokens).toBe(9_200) + expect(overview.newInputTokens).toBe(2_200) + expect(overview.cacheTokens).toBe(5_300) + expect(overview.outputTokens).toBe(1_700) + expect(overview.reasoningTokens).toBe(300) + expect(overview.sessions).toBe(3) + expect(overview.activityCount).toBe(7) + expect(overview.activeDays).toBe(3) + expect(overview.estimatedCostUsd).toBeCloseTo(0.06) + expect(overview.cacheShare).toBeCloseTo(5_300 / 7_500) + expect(overview.bestDay).toMatchObject({ + day: '2026-05-14', + totalTokens: 4_500, + claudeTokens: 2_500, + codexTokens: 2_000, + intensity: 4 + }) + expect(overview.providers.find((provider) => provider.id === 'codex')).toMatchObject({ + newInputTokens: 1_200, + cacheTokens: 800, + totalTokens: 3_200 + }) + }) + + it('pads recent usage days with zero-token cells', () => { + const recent = getRecentUsageDays( + [ + { + day: '2026-05-14', + totalTokens: 4_500, + claudeTokens: 2_500, + codexTokens: 2_000, + intensity: 4 + } + ], + 3, + new Date('2026-05-15T12:00:00') + ) + + expect(recent).toEqual([ + { + day: '2026-05-13', + totalTokens: 0, + claudeTokens: 0, + codexTokens: 0, + intensity: 0 + }, + { + day: '2026-05-14', + totalTokens: 4_500, + claudeTokens: 2_500, + codexTokens: 2_000, + intensity: 4 + }, + { + day: '2026-05-15', + totalTokens: 0, + claudeTokens: 0, + codexTokens: 0, + intensity: 0 + } + ]) + }) + + it('reports disabled providers as an empty overview', () => { + const overview = buildUsageOverview({ + claude: { scanState: null, summary: null, daily: [] }, + codex: { scanState: null, summary: null, daily: [] } + }) + + expect(overview.hasAnyEnabledProvider).toBe(false) + expect(overview.hasAnyData).toBe(false) + expect(overview.totalTokens).toBe(0) + expect(overview.estimatedCostUsd).toBeNull() + expect(overview.cacheShare).toBeNull() + }) + + it('formats token and cost values for compact UI labels', () => { + expect(formatUsageTokens(999)).toBe('999') + expect(formatUsageTokens(1_200)).toBe('1.2k') + expect(formatUsageTokens(2_500_000)).toBe('2.5M') + expect(formatUsageCost(null)).toBe('n/a') + expect(formatUsageCost(0.0042)).toBe('$0.0042') + expect(formatUsageCost(1.234)).toBe('$1.23') + }) +}) diff --git a/src/renderer/src/components/stats/usage-overview-model.ts b/src/renderer/src/components/stats/usage-overview-model.ts new file mode 100644 index 00000000000..47104aec739 --- /dev/null +++ b/src/renderer/src/components/stats/usage-overview-model.ts @@ -0,0 +1,319 @@ +import type { + ClaudeUsageDailyPoint, + ClaudeUsageScanState, + ClaudeUsageSummary +} from '../../../../shared/claude-usage-types' +import type { + CodexUsageDailyPoint, + CodexUsageScanState, + CodexUsageSummary +} from '../../../../shared/codex-usage-types' + +export type UsageProviderId = 'claude' | 'codex' + +export type UsageProviderOverview = { + id: UsageProviderId + label: string + enabled: boolean + isScanning: boolean + hasData: boolean + lastScanCompletedAt: number | null + lastScanError: string | null + sessions: number + activityLabel: 'turns' | 'events' + activityCount: number + totalTokens: number + newInputTokens: number + outputTokens: number + cacheTokens: number + reasoningTokens: number + estimatedCostUsd: number | null + topModel: string | null + topProject: string | null + activeDays: number +} + +export type UsageOverviewDailyPoint = { + day: string + totalTokens: number + claudeTokens: number + codexTokens: number + intensity: 0 | 1 | 2 | 3 | 4 +} + +export type UsageOverviewModel = { + providers: UsageProviderOverview[] + enabledProviderCount: number + dataProviderCount: number + hasAnyEnabledProvider: boolean + hasAnyData: boolean + totalTokens: number + newInputTokens: number + outputTokens: number + cacheTokens: number + reasoningTokens: number + sessions: number + activityCount: number + activeDays: number + estimatedCostUsd: number | null + hasPartialCost: boolean + cacheShare: number | null + daily: UsageOverviewDailyPoint[] + bestDay: UsageOverviewDailyPoint | null + lastUpdatedAt: number | null +} + +export type UsageOverviewInput = { + claude: { + scanState: ClaudeUsageScanState | null + summary: ClaudeUsageSummary | null + daily: ClaudeUsageDailyPoint[] + } + codex: { + scanState: CodexUsageScanState | null + summary: CodexUsageSummary | null + daily: CodexUsageDailyPoint[] + } +} + +function getClaudeDailyTotal(entry: ClaudeUsageDailyPoint): number { + return entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens +} + +function getCodexNewInputTokens(summary: CodexUsageSummary | null): number { + if (!summary) { + return 0 + } + return Math.max(summary.inputTokens - summary.cachedInputTokens, 0) +} + +function getIntensity(totalTokens: number, maxTokens: number): 0 | 1 | 2 | 3 | 4 { + if (totalTokens <= 0 || maxTokens <= 0) { + return 0 + } + const ratio = totalTokens / maxTokens + if (ratio <= 0.25) { + return 1 + } + if (ratio <= 0.5) { + return 2 + } + if (ratio <= 0.75) { + return 3 + } + return 4 +} + +function countActiveDays(days: string[]): number { + return new Set(days).size +} + +function createClaudeProvider(input: UsageOverviewInput['claude']): UsageProviderOverview { + const summary = input.summary + const dailyActiveDays = input.daily + .filter((entry) => getClaudeDailyTotal(entry) > 0) + .map((entry) => entry.day) + return { + id: 'claude', + label: 'Claude', + enabled: input.scanState?.enabled ?? false, + isScanning: input.scanState?.isScanning ?? false, + hasData: summary?.hasAnyClaudeData ?? input.scanState?.hasAnyClaudeData ?? false, + lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null, + lastScanError: input.scanState?.lastScanError ?? null, + sessions: summary?.sessions ?? 0, + activityLabel: 'turns', + activityCount: summary?.turns ?? 0, + totalTokens: summary + ? summary.inputTokens + + summary.outputTokens + + summary.cacheReadTokens + + summary.cacheWriteTokens + : 0, + newInputTokens: summary?.inputTokens ?? 0, + outputTokens: summary?.outputTokens ?? 0, + cacheTokens: summary ? summary.cacheReadTokens + summary.cacheWriteTokens : 0, + reasoningTokens: 0, + estimatedCostUsd: summary?.estimatedCostUsd ?? null, + topModel: summary?.topModel ?? null, + topProject: summary?.topProject ?? null, + activeDays: countActiveDays(dailyActiveDays) + } +} + +function createCodexProvider(input: UsageOverviewInput['codex']): UsageProviderOverview { + const summary = input.summary + const dailyActiveDays = input.daily + .filter((entry) => entry.totalTokens > 0) + .map((entry) => entry.day) + return { + id: 'codex', + label: 'Codex', + enabled: input.scanState?.enabled ?? false, + isScanning: input.scanState?.isScanning ?? false, + hasData: summary?.hasAnyCodexData ?? input.scanState?.hasAnyCodexData ?? false, + lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null, + lastScanError: input.scanState?.lastScanError ?? null, + sessions: summary?.sessions ?? 0, + activityLabel: 'events', + activityCount: summary?.events ?? 0, + totalTokens: summary?.totalTokens ?? 0, + newInputTokens: getCodexNewInputTokens(summary), + outputTokens: summary?.outputTokens ?? 0, + cacheTokens: summary?.cachedInputTokens ?? 0, + reasoningTokens: summary?.reasoningOutputTokens ?? 0, + estimatedCostUsd: summary?.estimatedCostUsd ?? null, + topModel: summary?.topModel ?? null, + topProject: summary?.topProject ?? null, + activeDays: countActiveDays(dailyActiveDays) + } +} + +function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[] { + const byDay = new Map>() + + for (const entry of input.claude.daily) { + const current = byDay.get(entry.day) ?? { + day: entry.day, + totalTokens: 0, + claudeTokens: 0, + codexTokens: 0 + } + const total = getClaudeDailyTotal(entry) + current.totalTokens += total + current.claudeTokens += total + byDay.set(entry.day, current) + } + + for (const entry of input.codex.daily) { + const current = byDay.get(entry.day) ?? { + day: entry.day, + totalTokens: 0, + claudeTokens: 0, + codexTokens: 0 + } + current.totalTokens += entry.totalTokens + current.codexTokens += entry.totalTokens + byDay.set(entry.day, current) + } + + const maxTokens = Math.max(0, ...[...byDay.values()].map((entry) => entry.totalTokens)) + return [...byDay.values()] + .sort((left, right) => left.day.localeCompare(right.day)) + .map((entry) => ({ + ...entry, + intensity: getIntensity(entry.totalTokens, maxTokens) + })) +} + +function formatLocalDay(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function getRecentUsageDays( + daily: UsageOverviewDailyPoint[], + dayCount: number, + anchorDate = new Date() +): UsageOverviewDailyPoint[] { + const byDay = new Map(daily.map((entry) => [entry.day, entry])) + const count = Math.max(1, Math.floor(dayCount)) + const end = new Date(anchorDate) + end.setHours(0, 0, 0, 0) + + const result: UsageOverviewDailyPoint[] = [] + for (let offset = count - 1; offset >= 0; offset--) { + const date = new Date(end) + date.setDate(end.getDate() - offset) + const day = formatLocalDay(date) + result.push( + byDay.get(day) ?? { + day, + totalTokens: 0, + claudeTokens: 0, + codexTokens: 0, + intensity: 0 + } + ) + } + return result +} + +export function buildUsageOverview(input: UsageOverviewInput): UsageOverviewModel { + const providers = [createClaudeProvider(input.claude), createCodexProvider(input.codex)] + const daily = buildDailyOverview(input) + const bestDay = + daily.length === 0 + ? null + : daily.reduce( + (best, entry) => (!best || entry.totalTokens > best.totalTokens ? entry : best), + null + ) + const totalTokens = providers.reduce((sum, provider) => sum + provider.totalTokens, 0) + const newInputTokens = providers.reduce((sum, provider) => sum + provider.newInputTokens, 0) + const outputTokens = providers.reduce((sum, provider) => sum + provider.outputTokens, 0) + const cacheTokens = providers.reduce((sum, provider) => sum + provider.cacheTokens, 0) + const reasoningTokens = providers.reduce((sum, provider) => sum + provider.reasoningTokens, 0) + const sessions = providers.reduce((sum, provider) => sum + provider.sessions, 0) + const activityCount = providers.reduce((sum, provider) => sum + provider.activityCount, 0) + const knownCost = providers.reduce((sum, provider) => sum + (provider.estimatedCostUsd ?? 0), 0) + const hasKnownCost = providers.some((provider) => provider.estimatedCostUsd !== null) + const hasPartialCost = providers.some( + (provider) => provider.hasData && provider.estimatedCostUsd === null + ) + const lastUpdatedAt = + providers.reduce( + (latest, provider) => + provider.lastScanCompletedAt && (!latest || provider.lastScanCompletedAt > latest) + ? provider.lastScanCompletedAt + : latest, + null + ) ?? null + + return { + providers, + enabledProviderCount: providers.filter((provider) => provider.enabled).length, + dataProviderCount: providers.filter((provider) => provider.hasData).length, + hasAnyEnabledProvider: providers.some((provider) => provider.enabled), + hasAnyData: providers.some((provider) => provider.hasData), + totalTokens, + newInputTokens, + outputTokens, + cacheTokens, + reasoningTokens, + sessions, + activityCount, + activeDays: countActiveDays( + daily.filter((entry) => entry.totalTokens > 0).map((entry) => entry.day) + ), + estimatedCostUsd: hasKnownCost ? knownCost : null, + hasPartialCost, + cacheShare: + newInputTokens + cacheTokens > 0 ? cacheTokens / (newInputTokens + cacheTokens) : null, + daily, + bestDay, + lastUpdatedAt + } +} + +export function formatUsageTokens(value: number): string { + if (value >= 1_000_000_000) { + return `${(value / 1_000_000_000).toFixed(1)}B` + } + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M` + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k` + } + return value.toLocaleString() +} + +export function formatUsageCost(value: number | null): string { + if (value === null) { + return 'n/a' + } + return value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}` +} diff --git a/tests/e2e/usage-overview.spec.ts b/tests/e2e/usage-overview.spec.ts new file mode 100644 index 00000000000..07559cbb535 --- /dev/null +++ b/tests/e2e/usage-overview.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from './helpers/orca-app' +import { getStoreState, waitForSessionReady } from './helpers/store' + +test.describe('usage overview', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + }) + + test('Stats & Usage opens on the combined overview with provider controls', async ({ + orcaPage + }) => { + await orcaPage.evaluate(() => { + const state = window.__store!.getState() + state.openSettingsTarget({ pane: 'stats' }) + state.openSettingsPage() + }) + + await expect + .poll(async () => getStoreState(orcaPage, 'activeView'), { timeout: 5_000 }) + .toBe('settings') + await expect(orcaPage.getByRole('heading', { name: 'Usage Analytics' })).toBeVisible() + const providerTabs = orcaPage.getByRole('group', { name: 'Usage analytics provider' }) + await expect( + providerTabs.getByRole('button', { name: 'Overview', exact: true }) + ).toHaveAttribute('aria-pressed', 'true') + await expect(orcaPage.getByTestId('usage-overview-pane')).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: 'Usage Overview' })).toBeVisible() + await expect(orcaPage.getByRole('heading', { name: 'Providers' })).toBeVisible() + await expect(orcaPage.getByRole('button', { name: 'Enable Claude' })).toBeVisible() + await expect(orcaPage.getByRole('button', { name: 'Enable Codex' })).toBeVisible() + + await providerTabs.getByRole('button', { name: 'Codex', exact: true }).click() + await expect(orcaPage.getByRole('heading', { name: 'Codex Usage Tracking' })).toBeVisible() + }) +}) From 9217cf625d8d08daa482a1918dff9d19fe7c47ad Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 15 May 2026 16:17:35 -0400 Subject: [PATCH 2/4] Fix mobile TUI scroll gestures (#1940) Co-authored-by: Orca --- .../app/h/[hostId]/session/[worktreeId].tsx | 186 ++++++- mobile/src/terminal/TerminalWebView.tsx | 504 ++++++++++++++---- .../terminal/terminal-gesture-input.test.ts | 42 ++ mobile/src/terminal/terminal-gesture-input.ts | 71 +++ src/main/daemon/headless-emulator.test.ts | 191 +++++++ src/main/daemon/headless-emulator.ts | 141 ++++- src/main/daemon/types.ts | 11 +- .../use-worktree-activity-status.test.tsx | 6 +- .../sidebar/use-worktree-activity-status.ts | 2 +- .../terminal-pane/terminal-appearance.ts | 6 +- 10 files changed, 1028 insertions(+), 132 deletions(-) create mode 100644 mobile/src/terminal/terminal-gesture-input.test.ts create mode 100644 mobile/src/terminal/terminal-gesture-input.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index cab15d8e82c..6d03e194409 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -48,6 +48,7 @@ import { type TerminalModes, type TerminalWebViewHandle } from '../../../../src/terminal/TerminalWebView' +import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input' import { StatusDot } from '../../../../src/components/StatusDot' import { ActionSheetModal } from '../../../../src/components/ActionSheetModal' import { TextInputModal } from '../../../../src/components/TextInputModal' @@ -205,6 +206,28 @@ const STATUS_LABELS: Record = { 'auth-failed': 'Auth failed' } +const TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY = 64 +const TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND = 120 +const TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS = 16 +const TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES = 32 +const TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS = 250 + +type TerminalGestureInputBucket = { + tokens: number + lastRefillMs: number +} + +type TerminalGestureInputQueue = { + bytes: string + sequenceCount: number + timer: ReturnType | null + lastUpdatedMs: number +} + +function isWheelMouseTrackingMode(mode: TerminalModes['mouseTrackingMode'] | undefined): boolean { + return mode === 'vt200' || mode === 'drag' || mode === 'any' +} + function TerminalPaneView({ handle, active, @@ -216,7 +239,8 @@ function TerminalPaneView({ onSelectionEvicted, onModesChanged, onKeyboardAvoidanceMetrics, - onHaptic + onHaptic, + onTerminalInput }: { handle: string active: boolean @@ -229,6 +253,7 @@ function TerminalPaneView({ onModesChanged: (handle: string, modes: TerminalModes) => void onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void + onTerminalInput: (handle: string, bytes: string) => void }) { const setRef = useCallback( (ref: TerminalWebViewHandle | null) => { @@ -256,6 +281,7 @@ function TerminalPaneView({ onModesChanged={(m) => onModesChanged(handle, m)} onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)} onHaptic={onHaptic} + onTerminalInput={(bytes) => onTerminalInput(handle, bytes)} /> ) @@ -466,9 +492,13 @@ export default function SessionScreen() { // Why: WebView pushes terminal modes (bracketed-paste, alt-screen) on every // change so paste reads a synchronous snapshot — no round-trip required. const ptyModesRef = useRef>(new Map()) + const terminalGestureInputBucketsRef = useRef>(new Map()) + const terminalGestureInputQueuesRef = useRef>(new Map()) + const terminalGestureInputInFlightRef = useRef>(new Set()) const initialModesSeenRef = useRef>(new Set()) const deviceTokenRef = useRef(null) const clientRef = useRef(null) + const connStateRef = useRef(connState) // Why: measured once from TerminalWebView on mount, then passed with every // subscribe call so the server can auto-fit the PTY to phone dimensions. const viewportRef = useRef<{ cols: number; rows: number } | null>(null) @@ -1295,6 +1325,16 @@ export default function SessionScreen() { clientRef.current = client }, [client]) + useEffect(() => { + connStateRef.current = connState + if (connState === 'connected') return + for (const queued of terminalGestureInputQueuesRef.current.values()) { + if (queued.timer) clearTimeout(queued.timer) + } + terminalGestureInputQueuesRef.current.clear() + terminalGestureInputInFlightRef.current.clear() + }, [connState]) + // Why: only clear terminal cache on actual unmount. Running it whenever // `client` changes — including the initial null → real-client transition // from useHostClient's async open path — would unsubscribe terminals and @@ -1432,6 +1472,11 @@ export default function SessionScreen() { activeSessionTabTypeRef.current = null pendingActiveSessionTabIdRef.current = null pendingActiveTerminalHandleRef.current = null + for (const queued of terminalGestureInputQueuesRef.current.values()) { + if (queued.timer) clearTimeout(queued.timer) + } + terminalGestureInputQueuesRef.current.clear() + terminalGestureInputInFlightRef.current.clear() setActiveHandle(null) setTerminals([]) terminalsRef.current = [] @@ -1654,6 +1699,13 @@ export default function SessionScreen() { terminalRefs.current.set(handle, ref) } else { terminalRefs.current.delete(handle) + terminalGestureInputBucketsRef.current.delete(handle) + const queued = terminalGestureInputQueuesRef.current.get(handle) + if (queued?.timer) { + clearTimeout(queued.timer) + } + terminalGestureInputQueuesRef.current.delete(handle) + terminalGestureInputInFlightRef.current.delete(handle) } }, []) @@ -1754,6 +1806,132 @@ export default function SessionScreen() { } } + const allowTerminalGestureInput = useCallback( + (handle: string, sequenceCount: number): boolean => { + const now = Date.now() + const current = terminalGestureInputBucketsRef.current.get(handle) ?? { + tokens: TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY, + lastRefillMs: now + } + const elapsedSeconds = Math.max(0, now - current.lastRefillMs) / 1000 + const tokens = Math.min( + TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY, + current.tokens + elapsedSeconds * TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND + ) + + // Why: tokens represent terminal control sequences, not WebView messages; + // one legitimate gesture message may batch up to 32 wheel/key reports. + if (tokens < sequenceCount) { + terminalGestureInputBucketsRef.current.set(handle, { tokens, lastRefillMs: now }) + return false + } + + terminalGestureInputBucketsRef.current.set(handle, { + tokens: tokens - sequenceCount, + lastRefillMs: now + }) + return true + }, + [] + ) + + const flushTerminalGestureInput = useCallback(async (handle: string) => { + const queued = terminalGestureInputQueuesRef.current.get(handle) + if (!queued) return + if (queued.timer) { + clearTimeout(queued.timer) + queued.timer = null + } + if (terminalGestureInputInFlightRef.current.has(handle)) return + + terminalGestureInputQueuesRef.current.delete(handle) + const isActive = + handle === activeHandleRef.current && activeSessionTabTypeRef.current === 'terminal' + const isFresh = Date.now() - queued.lastUpdatedMs <= TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS + const rpc = clientRef.current + if (!rpc || connStateRef.current !== 'connected' || !isActive || !isFresh) return + + terminalGestureInputInFlightRef.current.add(handle) + try { + await rpc.sendRequest('terminal.send', { + terminal: handle, + text: queued.bytes, + enter: false, + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + } catch { + // Transient failure + } finally { + terminalGestureInputInFlightRef.current.delete(handle) + const next = terminalGestureInputQueuesRef.current.get(handle) + if (next) { + if (Date.now() - next.lastUpdatedMs > TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS) { + if (next.timer) clearTimeout(next.timer) + terminalGestureInputQueuesRef.current.delete(handle) + } else { + void flushTerminalGestureInput(handle) + } + } + } + }, []) + + const enqueueTerminalGestureInput = useCallback( + (handle: string, bytes: string, sequenceCount: number) => { + const now = Date.now() + const current = terminalGestureInputQueuesRef.current.get(handle) + if ( + current && + current.sequenceCount + sequenceCount <= TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES + ) { + current.bytes += bytes + current.sequenceCount += sequenceCount + current.lastUpdatedMs = now + return + } + + if (current) { + if (current.timer) clearTimeout(current.timer) + if (!terminalGestureInputInFlightRef.current.has(handle)) { + void flushTerminalGestureInput(handle) + } else { + terminalGestureInputQueuesRef.current.delete(handle) + } + } + + const queued: TerminalGestureInputQueue = { + bytes, + sequenceCount, + timer: null, + lastUpdatedMs: now + } + queued.timer = setTimeout(() => { + queued.timer = null + void flushTerminalGestureInput(handle) + }, TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS) + terminalGestureInputQueuesRef.current.set(handle, queued) + }, + [flushTerminalGestureInput] + ) + + const handleTerminalInput = useCallback( + async (handle: string, bytes: string) => { + if (!client || connState !== 'connected' || bytes.length === 0) return + if (handle !== activeHandleRef.current || activeSessionTabTypeRef.current !== 'terminal') + return + const modes = ptyModesRef.current.get(handle) + // Why: WebView messages can become PTY input here. Only TUI scroll paths + // generate gesture input, and the bridge is rate-limited for SSH safety. + if (!modes?.altScreen && !isWheelMouseTrackingMode(modes?.mouseTrackingMode)) return + const sequenceCount = countTerminalGestureInputSequences(bytes) + if (sequenceCount == null) return + if (!allowTerminalGestureInput(handle, sequenceCount)) return + enqueueTerminalGestureInput(handle, bytes, sequenceCount) + }, + [allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput] + ) + async function handleClearTerminal(target: Terminal) { if (!client) return getTerminalRef(target.handle)?.clear() @@ -1890,7 +2068,10 @@ export default function SessionScreen() { if (text.length === 0) return const modes = ptyModesRef.current.get(activeHandle) || { bracketedPasteMode: false, - altScreen: false + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false } const wrap = modes.bracketedPasteMode && !modes.altScreen // Why: strip embedded bracketed-paste markers from clipboard text so a @@ -2335,6 +2516,7 @@ export default function SessionScreen() { onModesChanged={handleModesChanged} onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics} onHaptic={handleHaptic} + onTerminalInput={handleTerminalInput} /> ))} {toastMessage && ( diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 0504c4523f2..e1afc25f0b0 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -4,9 +4,14 @@ import { WebView } from 'react-native-webview' import type { WebViewMessageEvent } from 'react-native-webview' import { colors } from '../theme/mobile-theme' +type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' + export type TerminalModes = { bracketedPasteMode: boolean altScreen: boolean + mouseTrackingMode: TerminalMouseTrackingMode + sgrMouseMode: boolean + sgrMousePixelsMode: boolean } export type TerminalKeyboardAvoidanceMetrics = { @@ -22,6 +27,7 @@ export type TerminalSelectionEvents = { onModesChanged?: (modes: TerminalModes) => void onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void + onTerminalInput?: (bytes: string) => void } export type TerminalWebViewHandle = { @@ -178,6 +184,8 @@ const XTERM_HTML = ` (function() { var surface = document.getElementById('terminal-surface'); var ESC = String.fromCharCode(27); + var C1_CSI = String.fromCharCode(155); + var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; var term = null; var writeQueue = []; var writesDraining = false; @@ -190,6 +198,10 @@ const XTERM_HTML = ` var initRows = 24; var terminalGeneration = 0; var activeAltScreenSnapshot = false; + var trackedMouseTrackingMode = 'none'; + var sgrMouseMode = false; + var sgrMousePixelsMode = false; + var mouseModeScanTail = ''; var handledMessageIds = []; // Why: after init() the initial scrollback applyFitScale may have run // against an empty buffer (or one without the widest line yet). Re-fit @@ -406,6 +418,58 @@ const XTERM_HTML = ` return on > 0 ? data.slice(on) : data; } + function updateMouseModeFromData(data) { + if (typeof data !== 'string' || data.length === 0) return; + var input = mouseModeScanTail + data; + mouseModeScanTail = extractMouseModeScanTail(input); + var re = new RegExp(ESC + 'c|' + ESC + '\\\\[\\\\?([0-9;]+)([hl])|' + C1_CSI + '\\\\?([0-9;]+)([hl])', 'g'); + var match; + while ((match = re.exec(input)) !== null) { + if (match[0] === ESC + 'c') { + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; + continue; + } + var enabled = (match[2] || match[4]) === 'h'; + var params = (match[1] || match[3]).split(';'); + for (var i = 0; i < params.length; i++) { + if (params[i] === '') continue; + var param = Number(params[i]); + if (!Number.isInteger(param)) continue; + if (param === 9) trackedMouseTrackingMode = enabled ? 'x10' : 'none'; + if (param === 1000) trackedMouseTrackingMode = enabled ? 'vt200' : 'none'; + if (param === 1002) trackedMouseTrackingMode = enabled ? 'drag' : 'none'; + if (param === 1003) trackedMouseTrackingMode = enabled ? 'any' : 'none'; + if (param === 1006) { + sgrMouseMode = enabled; + sgrMousePixelsMode = false; + } + if (param === 1016) { + sgrMouseMode = false; + sgrMousePixelsMode = enabled; + } + } + } + } + + function extractMouseModeScanTail(input) { + var start = Math.max(input.lastIndexOf(ESC), input.lastIndexOf(C1_CSI)); + if (start === -1) return ''; + var tail = input.slice(start); + // Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. + // Keep parser state far beyond normal mode lists while still bounding memory. + if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) return ''; + if (tail === ESC || tail === ESC + '[' || tail === C1_CSI) return tail; + if (tail.indexOf(ESC + '[?') === 0) { + return /^[0-9;]*$/.test(tail.slice(3)) ? tail : ''; + } + if (tail.indexOf(C1_CSI + '?') === 0) { + return /^[0-9;]*$/.test(tail.slice(2)) ? tail : ''; + } + return ''; + } + function pumpWrites(gen) { if (!ready || !term || writesDraining || gen !== terminalGeneration) return; var next = writeQueue.shift(); @@ -439,7 +503,21 @@ const XTERM_HTML = ` afterDrainCallbacks = []; initRows = rows || 24; firstDataPending = true; + mouseModeScanTail = ''; + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; + lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }; var replayData = normalizeInitialData(initialData); + // Why: normalizeInitialData can discard pre-alt-screen bytes. Keep the + // mirrored modes aligned with exactly what this mobile xterm replays. + updateMouseModeFromData(replayData); activeAltScreenSnapshot = isAltScreenActive(replayData); var oldTerm = term; var oldSurface = surface; @@ -453,6 +531,7 @@ const XTERM_HTML = ` nextSurface.style.top = '0'; document.getElementById('terminal-container').appendChild(nextSurface); surface = nextSurface; + attachSurfaceEventHandlers(surface); oldSurface.removeAttribute('id'); } @@ -522,6 +601,7 @@ const XTERM_HTML = ` } function write(data) { + updateMouseModeFromData(data); writeQueue.push(data); pumpWrites(terminalGeneration); // Why: first live data chunk after init may widen the buffer past @@ -623,7 +703,12 @@ const XTERM_HTML = ` writeQueue = []; afterDrainCallbacks = []; writesDraining = false; + mouseModeScanTail = ''; + trackedMouseTrackingMode = 'none'; + sgrMouseMode = false; + sgrMousePixelsMode = false; if (term) { term.clear(); term.reset(); } + emitModesIfChanged(); resetEvictionCounter(); if (selMode === 'select') { notify({ type: 'selection-evicted' }); @@ -715,13 +800,39 @@ const XTERM_HTML = ` if (!term) return; var bp = !!(term.modes && term.modes.bracketedPasteMode); var alt = false; + var mouseTrackingMode = getMouseTrackingMode(); try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {} - if (bp !== lastEmittedModes.bracketedPasteMode || alt !== lastEmittedModes.altScreen) { - lastEmittedModes = { bracketedPasteMode: bp, altScreen: alt }; - notify({ type: 'modes', bracketedPasteMode: bp, altScreen: alt }); + if ( + bp !== lastEmittedModes.bracketedPasteMode || + alt !== lastEmittedModes.altScreen || + mouseTrackingMode !== lastEmittedModes.mouseTrackingMode || + sgrMouseMode !== lastEmittedModes.sgrMouseMode || + sgrMousePixelsMode !== lastEmittedModes.sgrMousePixelsMode + ) { + lastEmittedModes = { + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }; + notify({ + type: 'modes', + bracketedPasteMode: bp, + altScreen: alt, + mouseTrackingMode: mouseTrackingMode, + sgrMouseMode: sgrMouseMode, + sgrMousePixelsMode: sgrMousePixelsMode + }); } } - var lastEmittedModes = { bracketedPasteMode: false, altScreen: false }; + var lastEmittedModes = { + bracketedPasteMode: false, + altScreen: false, + mouseTrackingMode: 'none', + sgrMouseMode: false, + sgrMousePixelsMode: false + }; function emitKeyboardAvoidanceMetrics() { if (!term) return; @@ -772,6 +883,147 @@ const XTERM_HTML = ` return { col: col, row: viewportRow + viewportY }; } + function viewportToMouseReportCell(clientX, clientY) { + if (!term) return null; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + if (cellW <= 0 || cellH <= 0) return null; + if (typeof clientX !== 'number') clientX = window.innerWidth / 2; + if (typeof clientY !== 'number') clientY = window.innerHeight / 2; + var total = getTotalScale(); + if (total <= 0) total = 1; + var sx = (clientX - panX) / total; + var sy = (clientY - panY) / total; + var maxX = Math.max(0, term.cols * cellW - 1); + var maxY = Math.max(0, term.rows * cellH - 1); + if (sx < 0) sx = 0; + if (sx > maxX) sx = maxX; + if (sy < 0) sy = 0; + if (sy > maxY) sy = maxY; + var col = Math.floor(sx / cellW); + var row = Math.floor(sy / cellH); + if (col < 0) col = 0; + if (col > term.cols - 1) col = term.cols - 1; + if (row < 0) row = 0; + if (row > term.rows - 1) row = term.rows - 1; + return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; + } + + function isAlternateBufferActive() { + try { + return !!(term && term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'); + } catch (e) { + return false; + } + } + + function getMouseTrackingMode() { + try { + if (term && term.modes && typeof term.modes.mouseTrackingMode === 'string') { + var mode = term.modes.mouseTrackingMode; + if (mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any') return mode; + return 'none'; + } + } catch (e) {} + if ( + trackedMouseTrackingMode === 'x10' || + trackedMouseTrackingMode === 'vt200' || + trackedMouseTrackingMode === 'drag' || + trackedMouseTrackingMode === 'any' + ) { + return trackedMouseTrackingMode; + } + return 'none'; + } + + function repeatSequence(sequence, count) { + var out = ''; + for (var i = 0; i < count; i++) out += sequence; + return out; + } + + function buildArrowScrollSequence(lines) { + var prefix = '['; + try { + if (term && term.modes && term.modes.applicationCursorKeysMode) prefix = 'O'; + } catch (e) {} + return ESC + prefix + (lines < 0 ? 'A' : 'B'); + } + + function buildMouseWheelSequence(lines, clientX, clientY) { + var cell = viewportToMouseReportCell(clientX, clientY); + if (!cell) return ''; + var eventCode = lines < 0 ? 64 : 65; + if (sgrMousePixelsMode) { + return ESC + '[<' + eventCode + ';' + cell.x + ';' + cell.y + 'M'; + } + if (sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + return ESC + '[<' + eventCode + ';' + (cell.col + 1) + ';' + (cell.row + 1) + 'M'; + } + // Why: xterm increments zero-based mouse cells before encoding reports. + var button = eventCode + 32; + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path. Fall back to keys for wide terminals. + if (button > 126 || col > 126 || row > 126) return ''; + return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function isWheelMouseTrackingMode(mode) { + return mode !== 'none' && mode !== 'x10'; + } + + function buildMouseWheelScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var sequence = buildMouseWheelSequence(lines, clientX, clientY); + if (!sequence) return ''; + return repeatSequence(sequence, count); + } + + function buildTuiScrollInput(lines, clientX, clientY) { + var count = Math.min(Math.abs(lines), 32); + if (count === 0) return ''; + var mouseTrackingMode = getMouseTrackingMode(); + var sequence = ''; + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + sequence = buildMouseWheelSequence(lines, clientX, clientY); + } + if (!sequence) sequence = buildArrowScrollSequence(lines); + return repeatSequence(sequence, count); + } + + function routeScrollLines(lines, clientX, clientY) { + if (!term || lines === 0) return; + var mouseTrackingMode = getMouseTrackingMode(); + var alternateBufferActive = isAlternateBufferActive(); + if (isWheelMouseTrackingMode(mouseTrackingMode)) { + // Why: xterm sends wheel events to mouse-aware TUIs before considering + // scrollback, even if the app stays on the normal buffer. + var mouseInput = buildMouseWheelScrollInput(lines, clientX, clientY); + if (mouseInput) { + notify({ type: 'terminal-input', bytes: mouseInput }); + return; + } + // Why: default mouse encoding can be unrepresentable in our ASCII-safe + // RPC path on wide terminals. Send bounded arrows instead of local + // scrollback/no-op while a mouse-aware app owns scroll gestures. + var fallbackInput = buildTuiScrollInput(lines, clientX, clientY); + if (fallbackInput) notify({ type: 'terminal-input', bytes: fallbackInput }); + return; + } + if (alternateBufferActive) { + // Why: alternate-screen TUIs own their scroll state and xterm has no + // scrollback there, so mobile scroll gestures must become terminal input. + var input = buildTuiScrollInput(lines, clientX, clientY); + if (input) notify({ type: 'terminal-input', bytes: input }); + return; + } + term.scrollLines(lines); + } + function cellToViewportPx(col, absRow) { if (!term) return { x: 0, y: 0 }; var cellW = getCellWidth(); @@ -1134,12 +1386,6 @@ const XTERM_HTML = ` } catch (err) {} }); - // Why: event listeners are registered once here (not inside init()) so - // they don't accumulate on re-init. They close over the mutable 'term' - // variable, so they always reference the current terminal instance. - surface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true); - surface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true); - var ts = { lastX: 0, lastY: 0, lastTime: 0, velY: 0, accumDelta: 0, momentumId: null, isPinching: false, @@ -1151,121 +1397,132 @@ const XTERM_HTML = ` return Math.sqrt(dx * dx + dy * dy); } - surface.addEventListener('touchstart', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (ts.momentumId) { - cancelAnimationFrame(ts.momentumId); - ts.momentumId = null; - } - if (e.touches.length === 2) { - ts.isPinching = true; - ts.pinchDist = getDistance(e.touches[0], e.touches[1]); - ts.pinchScale = userScale; - var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; - var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; - var total = getTotalScale(); - ts.pinchSurfX = (mx - panX) / total; - ts.pinchSurfY = (my - panY) / total; - } else if (e.touches.length === 1) { - ts.isPinching = false; - ts.lastX = e.touches[0].clientX; - ts.lastY = e.touches[0].clientY; - ts.lastTime = Date.now(); - ts.velY = 0; - ts.accumDelta = 0; - } - }, { capture: true, passive: true }); + function attachSurfaceEventHandlers(targetSurface) { + if (!targetSurface || targetSurface.__orcaSurfaceHandlersAttached) return; + targetSurface.__orcaSurfaceHandlersAttached = true; + // Why: init() swaps in a new hidden surface to avoid flicker; each + // replacement needs gesture handlers or tab-switch replays stop scrolling. + targetSurface.addEventListener('mousedown', function(e) { e.preventDefault(); e.stopPropagation(); }, true); + targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true); - surface.addEventListener('touchmove', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - e.preventDefault(); - e.stopPropagation(); - - if (e.touches.length === 2) { - ts.isPinching = true; - var dist = getDistance(e.touches[0], e.touches[1]); - var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; - var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; - - var ratio = dist / ts.pinchDist; - userScale = Math.max(1, Math.min(5, ts.pinchScale * ratio)); - - var total = getTotalScale(); - panX = mx - ts.pinchSurfX * total; - panY = my - ts.pinchSurfY * total; - clampPan(); - updateTransform(); - - } else if (e.touches.length === 1 && !ts.isPinching) { - var x = e.touches[0].clientX; - var y = e.touches[0].clientY; - var now = Date.now(); - var dt = now - ts.lastTime; - - if (userScale > 1.05) { - panX += x - ts.lastX; - panY += y - ts.lastY; - clampPan(); - updateTransform(); - } else { - var deltaY = ts.lastY - y; - if (dt > 0) ts.velY = deltaY / dt; - ts.lastTime = now; - var effectiveCellH = getCellHeight() * currentScale; - ts.accumDelta += deltaY; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - term.scrollLines(lines); - } + targetSurface.addEventListener('touchstart', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (ts.momentumId) { + cancelAnimationFrame(ts.momentumId); + ts.momentumId = null; } - ts.lastX = x; - ts.lastY = y; - } - }, { capture: true, passive: false }); - - surface.addEventListener('touchend', function(e) { - if (dispatcherShouldBlockSurface()) return; - if (!term) return; - - if (ts.isPinching && e.touches.length < 2) { - ts.isPinching = false; - if (userScale < 1.15) { - userScale = 1; panX = 0; panY = 0; - updateTransform(); - } - if (e.touches.length === 1) { + if (e.touches.length === 2) { + ts.isPinching = true; + ts.pinchDist = getDistance(e.touches[0], e.touches[1]); + ts.pinchScale = userScale; + var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; + var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; + var total = getTotalScale(); + ts.pinchSurfX = (mx - panX) / total; + ts.pinchSurfY = (my - panY) / total; + } else if (e.touches.length === 1) { + ts.isPinching = false; ts.lastX = e.touches[0].clientX; ts.lastY = e.touches[0].clientY; ts.lastTime = Date.now(); ts.velY = 0; ts.accumDelta = 0; } - return; - } + }, { capture: true, passive: true }); - if (e.touches.length === 0 && userScale <= 1.05) { - var vel = ts.velY; - var FRICTION = 0.95; - var MIN_VEL = 0.02; - function momentumStep() { - vel *= FRICTION; - if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } - var effectiveCellH = getCellHeight() * currentScale; - ts.accumDelta += vel * 16; - var lines = Math.trunc(ts.accumDelta / effectiveCellH); - if (lines !== 0) { - ts.accumDelta -= lines * effectiveCellH; - term.scrollLines(lines); + targetSurface.addEventListener('touchmove', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (!term) return; + e.preventDefault(); + e.stopPropagation(); + + if (e.touches.length === 2) { + ts.isPinching = true; + var dist = getDistance(e.touches[0], e.touches[1]); + var mx = (e.touches[0].clientX + e.touches[1].clientX) / 2; + var my = (e.touches[0].clientY + e.touches[1].clientY) / 2; + + var ratio = dist / ts.pinchDist; + userScale = Math.max(1, Math.min(5, ts.pinchScale * ratio)); + + var total = getTotalScale(); + panX = mx - ts.pinchSurfX * total; + panY = my - ts.pinchSurfY * total; + clampPan(); + updateTransform(); + + } else if (e.touches.length === 1 && !ts.isPinching) { + var x = e.touches[0].clientX; + var y = e.touches[0].clientY; + var now = Date.now(); + var dt = now - ts.lastTime; + + if (userScale > 1.05) { + panX += x - ts.lastX; + panY += y - ts.lastY; + clampPan(); + updateTransform(); + } else { + var deltaY = ts.lastY - y; + if (dt > 0) ts.velY = deltaY / dt; + ts.lastTime = now; + var effectiveCellH = getCellHeight() * currentScale; + ts.accumDelta += deltaY; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, x, y); + } } - ts.momentumId = requestAnimationFrame(momentumStep); + ts.lastX = x; + ts.lastY = y; } - if (Math.abs(vel) > MIN_VEL) { - ts.momentumId = requestAnimationFrame(momentumStep); + }, { capture: true, passive: false }); + + targetSurface.addEventListener('touchend', function(e) { + if (dispatcherShouldBlockSurface()) return; + if (!term) return; + + if (ts.isPinching && e.touches.length < 2) { + ts.isPinching = false; + if (userScale < 1.15) { + userScale = 1; panX = 0; panY = 0; + updateTransform(); + } + if (e.touches.length === 1) { + ts.lastX = e.touches[0].clientX; + ts.lastY = e.touches[0].clientY; + ts.lastTime = Date.now(); + ts.velY = 0; + ts.accumDelta = 0; + } + return; } - } - }, { capture: true, passive: true }); + + if (e.touches.length === 0 && userScale <= 1.05) { + var vel = ts.velY; + var FRICTION = 0.95; + var MIN_VEL = 0.02; + function momentumStep() { + vel *= FRICTION; + if (Math.abs(vel) < MIN_VEL) { ts.momentumId = null; return; } + var effectiveCellH = getCellHeight() * currentScale; + ts.accumDelta += vel * 16; + var lines = Math.trunc(ts.accumDelta / effectiveCellH); + if (lines !== 0) { + ts.accumDelta -= lines * effectiveCellH; + routeScrollLines(lines, ts.lastX, ts.lastY); + } + ts.momentumId = requestAnimationFrame(momentumStep); + } + if (Math.abs(vel) > MIN_VEL) { + ts.momentumId = requestAnimationFrame(momentumStep); + } + } + }, { capture: true, passive: true }); + } + + attachSurfaceEventHandlers(surface); window.addEventListener('message', function(e) { try { @@ -1310,7 +1567,8 @@ export const TerminalWebView = forwardRef(function onSelectionEvicted, onModesChanged, onKeyboardAvoidanceMetrics, - onHaptic + onHaptic, + onTerminalInput }, ref ) { @@ -1395,10 +1653,23 @@ export const TerminalWebView = forwardRef(function } else if (msg.type === 'selection-evicted') { onSelectionEvicted?.() } else if (msg.type === 'modes') { + const mouseTrackingMode = + msg.mouseTrackingMode === 'x10' || + msg.mouseTrackingMode === 'vt200' || + msg.mouseTrackingMode === 'drag' || + msg.mouseTrackingMode === 'any' + ? msg.mouseTrackingMode + : 'none' onModesChanged?.({ bracketedPasteMode: !!msg.bracketedPasteMode, - altScreen: !!msg.altScreen + altScreen: !!msg.altScreen, + mouseTrackingMode, + sgrMouseMode: !!msg.sgrMouseMode, + sgrMousePixelsMode: !!msg.sgrMousePixelsMode }) + } else if (msg.type === 'terminal-input') { + const bytes = typeof msg.bytes === 'string' ? msg.bytes : '' + if (bytes.length > 0) onTerminalInput?.(bytes) } else if (msg.type === 'keyboard-avoidance-metrics') { const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0 const rows = typeof msg.rows === 'number' ? msg.rows : 0 @@ -1430,7 +1701,8 @@ export const TerminalWebView = forwardRef(function onSelectionEvicted, onModesChanged, onKeyboardAvoidanceMetrics, - onHaptic + onHaptic, + onTerminalInput ] ) diff --git a/mobile/src/terminal/terminal-gesture-input.test.ts b/mobile/src/terminal/terminal-gesture-input.test.ts new file mode 100644 index 00000000000..48ad19e10bd --- /dev/null +++ b/mobile/src/terminal/terminal-gesture-input.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { + countTerminalGestureInputSequences, + isTerminalGestureInput +} from './terminal-gesture-input' + +const ESC = '\x1b' + +describe('isTerminalGestureInput', () => { + it('accepts repeated arrow scroll sequences', () => { + expect(isTerminalGestureInput(`${ESC}[A${ESC}[B${ESC}OA${ESC}OB`)).toBe(true) + expect(isTerminalGestureInput(`${ESC}[A`.repeat(32))).toBe(true) + expect(countTerminalGestureInputSequences(`${ESC}[A${ESC}[B${ESC}OA${ESC}OB`)).toBe(4) + expect(countTerminalGestureInputSequences(`${ESC}[A`.repeat(32))).toBe(32) + }) + + it('accepts repeated SGR wheel sequences', () => { + expect(isTerminalGestureInput(`${ESC}[<64;1;1M${ESC}[<65;120;40M`)).toBe(true) + expect(isTerminalGestureInput(`${ESC}[<64;0;0M`)).toBe(true) + }) + + it('accepts repeated default mouse wheel sequences', () => { + expect( + isTerminalGestureInput( + `${ESC}[M${String.fromCharCode(96, 97, 33)}${ESC}[M${String.fromCharCode(97, 126, 126)}` + ) + ).toBe(true) + }) + + it('rejects shell text and other terminal input', () => { + expect(isTerminalGestureInput('rm -rf .\r')).toBe(false) + expect(isTerminalGestureInput(`${ESC}[200~paste${ESC}[201~`)).toBe(false) + }) + + it('rejects malformed or oversized sequences', () => { + expect(isTerminalGestureInput(`${ESC}[<63;0;1M`)).toBe(false) + expect(isTerminalGestureInput(`${ESC}[M` + String.fromCharCode(97, 32, 33))).toBe(false) + expect(isTerminalGestureInput(`${ESC}[A`.repeat(33))).toBe(false) + expect(countTerminalGestureInputSequences(`${ESC}[A`.repeat(33))).toBeNull() + expect(isTerminalGestureInput(`${ESC}[A`.repeat(700))).toBe(false) + }) +}) diff --git a/mobile/src/terminal/terminal-gesture-input.ts b/mobile/src/terminal/terminal-gesture-input.ts new file mode 100644 index 00000000000..15b3a9d11df --- /dev/null +++ b/mobile/src/terminal/terminal-gesture-input.ts @@ -0,0 +1,71 @@ +const ESC = '\x1b' +const MAX_TERMINAL_GESTURE_INPUT_LENGTH = 2048 +const MAX_TERMINAL_GESTURE_INPUT_SEQUENCES = 32 +const SGR_MOUSE_WHEEL_SEQUENCE_RE = new RegExp(`^${ESC}\\[<(64|65);[0-9]{1,4};[0-9]{1,4}M$`) + +function isDefaultMouseWheelSequence(bytes: string, offset: number): number | null { + if (!bytes.startsWith(`${ESC}[M`, offset) || offset + 6 > bytes.length) { + return null + } + const button = bytes.charCodeAt(offset + 3) + const col = bytes.charCodeAt(offset + 4) + const row = bytes.charCodeAt(offset + 5) + if ((button === 96 || button === 97) && col >= 33 && col <= 126 && row >= 33 && row <= 126) { + return offset + 6 + } + return null +} + +function isSgrMouseWheelSequence(bytes: string, offset: number): number | null { + if (!bytes.startsWith(`${ESC}[<`, offset)) { + return null + } + const end = bytes.indexOf('M', offset) + if (end === -1) { + return null + } + const sequence = bytes.slice(offset, end + 1) + return SGR_MOUSE_WHEEL_SEQUENCE_RE.test(sequence) ? end + 1 : null +} + +function isArrowScrollSequence(bytes: string, offset: number): number | null { + const sequence = bytes.slice(offset, offset + 3) + if ( + sequence === `${ESC}[A` || + sequence === `${ESC}[B` || + sequence === `${ESC}OA` || + sequence === `${ESC}OB` + ) { + return offset + 3 + } + return null +} + +export function countTerminalGestureInputSequences(bytes: string): number | null { + if (bytes.length === 0 || bytes.length > MAX_TERMINAL_GESTURE_INPUT_LENGTH) { + return null + } + + let offset = 0 + let sequenceCount = 0 + while (offset < bytes.length) { + const next = + isArrowScrollSequence(bytes, offset) ?? + isSgrMouseWheelSequence(bytes, offset) ?? + isDefaultMouseWheelSequence(bytes, offset) + + if (next == null) { + return null + } + sequenceCount += 1 + if (sequenceCount > MAX_TERMINAL_GESTURE_INPUT_SEQUENCES) { + return null + } + offset = next + } + return sequenceCount +} + +export function isTerminalGestureInput(bytes: string): boolean { + return countTerminalGestureInputSequences(bytes) != null +} diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index fd4a4a15c5c..5e73efd57c5 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -160,6 +160,162 @@ describe('HeadlessEmulator', () => { await emulator.write('\x1b[?1049l') expect(emulator.getSnapshot().modes.alternateScreen).toBe(false) }) + + it('tracks mouse reporting mode', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + expect(emulator.getSnapshot().modes.mouseTracking).toBe(false) + + await emulator.write('\x1b[?1002;1006h') + expect(emulator.getSnapshot().modes.mouseTracking).toBe(true) + expect(emulator.getSnapshot().modes.mouseTrackingMode).toBe('drag') + expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(true) + expect(emulator.getSnapshot().modes.sgrMousePixelsMode).toBe(false) + + await emulator.write('\x1b[?1002;1006l') + expect(emulator.getSnapshot().modes.mouseTracking).toBe(false) + expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false) + }) + + it('tracks split SGR mouse reporting sequences', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b[?1002;100') + await emulator.write('6h') + expect(emulator.getSnapshot().modes.mouseTrackingMode).toBe('drag') + expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(true) + + await emulator.write('\x1b[?100') + await emulator.write('6l') + expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false) + }) + + it('tracks long split private mouse mode sequences', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + const fillerModes = Array.from({ length: 40 }, (_, i) => String(3000 + i)).join(';') + + await emulator.write(`\x1b[?1002;${fillerModes};100`) + await emulator.write('6h') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.mouseTrackingMode).toBe('drag') + expect(snapshot.modes.sgrMouseMode).toBe(true) + expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h') + expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h') + }) + + it('tracks private mouse modes with leading zero params', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b[?01002;01006h') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.mouseTrackingMode).toBe('drag') + expect(snapshot.modes.sgrMouseMode).toBe(true) + expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h') + expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h') + }) + + it('tracks C1 CSI private mouse mode sequences', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x9b?1002;1006h') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.mouseTrackingMode).toBe('drag') + expect(snapshot.modes.sgrMouseMode).toBe(true) + expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h') + expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h') + }) + + it('tracks split C1 CSI private mouse mode sequences', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x9b?1002;100') + await emulator.write('6h') + await emulator.write('\x9b?1002l') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.mouseTracking).toBe(false) + expect(snapshot.modes.sgrMouseMode).toBe(true) + expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h') + expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h') + }) + + it('tracks C1 CSI split before private marker', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x9b') + await emulator.write('?1002;1006h') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.mouseTrackingMode).toBe('drag') + expect(snapshot.modes.sgrMouseMode).toBe(true) + expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h') + expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h') + }) + + it('does not retain complete private CSI queries as scan tail', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b[?2004$p') + await emulator.write('1002;1006h') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.mouseTracking).toBe(false) + expect(snapshot.modes.sgrMouseMode).toBe(false) + expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h') + expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h') + }) + + it('does not expose mouse modes before xterm applies the same write', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + const writePromise = emulator.write('\x1b[?1049h\x1b[?1002;1006h') + const snapshot = emulator.getSnapshot() + await writePromise + + expect(snapshot.modes.alternateScreen).toBe(false) + expect(snapshot.modes.mouseTracking).toBe(false) + expect(snapshot.modes.sgrMouseMode).toBe(false) + expect(snapshot.rehydrateSequences).toBe('') + + const after = emulator.getSnapshot() + expect(after.modes.alternateScreen).toBe(true) + expect(after.modes.mouseTrackingMode).toBe('drag') + expect(after.modes.sgrMouseMode).toBe(true) + expect(after.rehydrateSequences).toContain('\x1b[?1049h') + expect(after.rehydrateSequences).toContain('\x1b[?1002h') + expect(after.rehydrateSequences).toContain('\x1b[?1006h') + }) + + it('clears SGR mouse reporting on full terminal reset', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b[?1002;1006h') + await emulator.write('\x1bc') + await emulator.write('\x1b[?1002h') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.mouseTrackingMode).toBe('drag') + expect(snapshot.modes.sgrMouseMode).toBe(false) + expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h') + expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h') + }) + + it('tracks SGR-pixels mouse reporting as a separate active encoding', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b[?1002;1006h') + await emulator.write('\x1b[?1016h') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.mouseTrackingMode).toBe('drag') + expect(snapshot.modes.sgrMouseMode).toBe(false) + expect(snapshot.modes.sgrMousePixelsMode).toBe(true) + expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h') + expect(snapshot.rehydrateSequences).toContain('\x1b[?1016h') + expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h') + }) }) describe('rehydration sequences', () => { @@ -178,6 +334,41 @@ describe('HeadlessEmulator', () => { const snapshot = emulator.getSnapshot() expect(snapshot.rehydrateSequences).toBe('') }) + + it('rehydrates mouse reporting after alternate screen activation', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?1049h\x1b[?1002;1006h') + + const snapshot = emulator.getSnapshot() + expect(snapshot.rehydrateSequences).toContain('\x1b[?1049h') + expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h') + expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h') + expect(snapshot.rehydrateSequences.indexOf('\x1b[?1049h')).toBeLessThan( + snapshot.rehydrateSequences.indexOf('\x1b[?1002h') + ) + }) + + it('preserves mouse modes after mobile normalizes an alternate-screen replay', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('normal buffer\r\n\x1b[?1049h\x1b[?2004h\x1b[?1002;1006halternate') + const snapshot = emulator.getSnapshot() + const payload = snapshot.rehydrateSequences + snapshot.snapshotAnsi + expect(payload.split('\x1b[?1049h')).toHaveLength(2) + expect(payload.slice(payload.lastIndexOf('\x1b[?1049h'))).toContain('\x1b[?1002h') + expect(payload.slice(payload.lastIndexOf('\x1b[?1049h'))).toContain('\x1b[?1006h') + }) + + it('rehydrates mouse encoding independently from reporting mode', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?1002;1006h') + await emulator.write('\x1b[?1002l') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.mouseTracking).toBe(false) + expect(snapshot.modes.sgrMouseMode).toBe(true) + expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h') + expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h') + }) }) describe('dispose', () => { diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 12645ec1e0a..90155072a94 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -14,6 +14,10 @@ export type HeadlessSnapshotOptions = { } const DEFAULT_SCROLLBACK = 5000 +// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. +// Keep parser state far beyond normal mode lists while still bounding memory. +const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096 +type MouseTrackingMode = NonNullable function parseFileUriPath(uri: string): string | null { try { @@ -47,6 +51,10 @@ export class HeadlessEmulator { private terminal: Terminal private serializer: SerializeAddon private cwd: string | null = null + private privateModeScanTail = '' + private mouseTrackingMode: MouseTrackingMode = 'none' + private sgrMouseMode = false + private sgrMousePixelsMode = false private disposed = false constructor(opts: HeadlessEmulatorOptions) { @@ -80,7 +88,12 @@ export class HeadlessEmulator { this.scanOsc7(data) return new Promise((resolve) => { - this.terminal.write(data, resolve) + this.terminal.write(data, () => { + // Why: snapshots combine serialized xterm state with mirrored mouse + // modes. Commit the mirror only after xterm has parsed the same bytes. + this.scanPrivateModes(data) + resolve() + }) }) } @@ -93,8 +106,12 @@ export class HeadlessEmulator { getSnapshot(opts: HeadlessSnapshotOptions = {}): TerminalSnapshot { const modes = this.getModes() + const snapshotAnsi = this.normalizeSnapshotAnsiForModes( + this.serializer.serialize({ scrollback: opts.scrollbackRows }), + modes + ) return { - snapshotAnsi: this.serializer.serialize({ scrollback: opts.scrollbackRows }), + snapshotAnsi, scrollbackAnsi: '', rehydrateSequences: this.buildRehydrateSequences(modes), cwd: this.cwd, @@ -133,6 +150,93 @@ export class HeadlessEmulator { } } + private scanPrivateModes(data: string): void { + const input = this.privateModeScanTail + data + this.privateModeScanTail = this.extractPrivateModeScanTail(input) + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g + let match: RegExpExecArray | null + while ((match = privateModeRe.exec(input)) !== null) { + if (match[0] === '\x1bc') { + this.mouseTrackingMode = 'none' + this.sgrMouseMode = false + this.sgrMousePixelsMode = false + continue + } + const params = match[1] ?? match[3] + const enabled = (match[2] ?? match[4]) === 'h' + for (const rawParam of params.split(';')) { + if (rawParam === '') { + continue + } + const param = Number(rawParam) + if (!Number.isInteger(param)) { + continue + } + if (param === 9) { + this.mouseTrackingMode = enabled ? 'x10' : 'none' + } + if (param === 1000) { + this.mouseTrackingMode = enabled ? 'vt200' : 'none' + } + if (param === 1002) { + this.mouseTrackingMode = enabled ? 'drag' : 'none' + } + if (param === 1003) { + this.mouseTrackingMode = enabled ? 'any' : 'none' + } + if (param === 1006) { + this.sgrMouseMode = enabled + this.sgrMousePixelsMode = false + } + if (param === 1016) { + this.sgrMouseMode = false + this.sgrMousePixelsMode = enabled + } + } + } + } + + private extractPrivateModeScanTail(input: string): string { + const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) + if (start === -1) { + return '' + } + const tail = input.slice(start) + if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) { + return '' + } + if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') { + return tail + } + if (tail.startsWith('\x1b[?')) { + return this.isIncompletePrivateModeParams(tail.slice(3)) ? tail : '' + } + if (tail.startsWith('\x9b?')) { + return this.isIncompletePrivateModeParams(tail.slice(2)) ? tail : '' + } + return '' + } + + private isIncompletePrivateModeParams(params: string): boolean { + return /^[0-9;]*$/.test(params) + } + + private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string { + if (!modes.alternateScreen) { + return snapshotAnsi + } + const alternateScreenMarker = '\x1b[?1049h' + const start = snapshotAnsi.lastIndexOf(alternateScreenMarker) + if (start === -1) { + return snapshotAnsi + } + // Why: rehydrateSequences already enters the alternate screen and restores + // mouse modes. Dropping SerializeAddon's duplicate ?1049h keeps mobile's + // "slice from last alt-screen marker" replay from discarding those modes. + return snapshotAnsi.slice(start + alternateScreenMarker.length) + } + private parseOsc7Uri(uri: string): void { const parsed = parseFileUriPath(uri) if (parsed) { @@ -142,9 +246,13 @@ export class HeadlessEmulator { private getModes(): TerminalModes { const buffer = this.terminal.buffer.active + const mouseTrackingMode = this.mouseTrackingMode return { bracketedPaste: this.terminal.modes.bracketedPasteMode, - mouseTracking: this.terminal.modes.mouseTrackingMode !== 'none', + mouseTracking: mouseTrackingMode !== 'none', + mouseTrackingMode, + sgrMouseMode: this.sgrMouseMode, + sgrMousePixelsMode: this.sgrMousePixelsMode, applicationCursor: buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false, alternateScreen: buffer.type === 'alternate' @@ -153,14 +261,37 @@ export class HeadlessEmulator { private buildRehydrateSequences(modes: TerminalModes): string { const seqs: string[] = [] + if (modes.alternateScreen) { + seqs.push('\x1b[?1049h') + } if (modes.bracketedPaste) { seqs.push('\x1b[?2004h') } if (modes.applicationCursor) { seqs.push('\x1b[?1h') } - if (modes.alternateScreen) { - seqs.push('\x1b[?1049h') + // Why: mobile alt-screen scroll gestures need xterm's mouse mode restored + // from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way. + switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { + case 'x10': + seqs.push('\x1b[?9h') + break + case 'vt200': + seqs.push('\x1b[?1000h') + break + case 'drag': + seqs.push('\x1b[?1002h') + break + case 'any': + seqs.push('\x1b[?1003h') + break + } + // Why: xterm tracks the mouse protocol and SGR encoding as independent + // modes, so snapshots must preserve the encoding even when reporting is off. + if (modes.sgrMousePixelsMode) { + seqs.push('\x1b[?1016h') + } else if (modes.sgrMouseMode) { + seqs.push('\x1b[?1006h') } return seqs.join('') } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 7808225393f..a88fb46c659 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -1,10 +1,10 @@ // ─── Protocol Version ──────────────────────────────────────────────── // Why: daemons can survive app updates. Bump for IPC wire-shape changes, or // when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. -// Why: bumped from 5 -> 6 for OSC 133 shell wrapper changes layered on top of -// OpenCode/Pi overlay restoration, both of which are baked into daemon spawns. -export const PROTOCOL_VERSION = 6 -export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5] as const +// Why: bumped from 6 -> 7 so existing daemons restart with the headless +// emulator's mouse-mode snapshot tracking for mobile alternate-screen TUIs. +export const PROTOCOL_VERSION = 7 +export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6] as const // ─── Session State Machine ────────────────────────────────────────── export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited' @@ -28,6 +28,9 @@ export type TerminalSnapshot = { export type TerminalModes = { bracketedPaste: boolean mouseTracking: boolean + mouseTrackingMode?: 'none' | 'x10' | 'vt200' | 'drag' | 'any' + sgrMouseMode?: boolean + sgrMousePixelsMode?: boolean applicationCursor: boolean alternateScreen: boolean } diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx index 1fc614f9acb..f3bb8066ec0 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.test.tsx @@ -1,9 +1,12 @@ import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/types' import { useWorktreeActivityStatus } from './use-worktree-activity-status' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + type MockState = { tabsByWorktree: Record browserTabsByWorktree: Record @@ -71,6 +74,7 @@ describe('useWorktreeActivityStatus', () => { it('keeps a restored offscreen working agent yellow from the hook snapshot', () => { const worktreeId = 'repo1::/path/wt1' + const paneKey = makePaneKey('tab-1', LEAF_ID) mockState = { ...mockState, tabsByWorktree: { @@ -80,7 +84,7 @@ describe('useWorktreeActivityStatus', () => { 'tab-1': ['pty-1'] }, agentStatusByPaneKey: { - 'tab-1:1': makeAgentStatusEntry({ paneKey: 'tab-1:1', state: 'working' }) + [paneKey]: makeAgentStatusEntry({ paneKey, state: 'working' }) } } diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts index 882fb13df96..66ffb16bc47 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -49,7 +49,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { done = true } } - for (const unsupported of Object.values(s.migrationUnsupportedByPtyId)) { + for (const unsupported of Object.values(s.migrationUnsupportedByPtyId ?? {})) { const entry = migrationUnsupportedToAgentStatusEntry(unsupported) if (!entry) { continue diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index b9b87976070..570dc848ec1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -78,9 +78,9 @@ export function installMode2031Handlers(deps: Mode2031HandlerDeps): IDisposable[ // scrollback (`SerializeAddon.serialize()`) never contain `?2031`: // SerializeAddon's _serializeModes whitelists only ?1h/?66h/?2004h/ // [4h/?6h/?45h/?1004h/?7l/mouse modes/?25l, and buildRehydrateSequences - // emits only ?2004h/?1h/?1049h. If xterm ever adds ?2031 to that - // whitelist, this guard would start suppressing legitimate - // subscribes during snapshot reattach — revisit then. + // emits only ?1049h/?2004h/?1h/mouse reporting modes. If xterm ever + // adds ?2031 to that whitelist, this guard would start suppressing + // legitimate subscribes during snapshot reattach — revisit then. if (deps.isReplaying()) { return false } From ac1a4e44e9bbda541ef6b47873480e29250653aa Mon Sep 17 00:00:00 2001 From: Honglei Liu Date: Fri, 15 May 2026 13:17:38 -0700 Subject: [PATCH 3/4] fix(sidebar): order repo headers by recency in Recent/Smart sort --- .../src/components/sidebar/WorktreeList.tsx | 42 +++++++--- .../src/components/sidebar/smart-sort.ts | 2 +- .../sidebar/worktree-list-groups.test.ts | 79 ++++++++++++++++++- .../sidebar/worktree-list-groups.ts | 21 +++-- 4 files changed, 124 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index be729c1a154..e44df69240e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -30,10 +30,12 @@ import { track } from '@/lib/telemetry' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { type GroupHeaderRow, + type RepoGroupOrdering, type Row, type WorktreeGroupBy, PINNED_GROUP_KEY, buildRows, + getRepoGroupOrdering, getGroupKeyForWorktree } from './worktree-list-groups' import { @@ -93,6 +95,7 @@ type VirtualizedWorktreeViewportProps = { rows: Row[] activeWorktreeId: string | null groupBy: WorktreeGroupBy + repoGroupOrdering: RepoGroupOrdering toggleGroup: (key: string) => void collapsedGroups: Set handleCreateForRepo: (repoId: string) => void @@ -132,6 +135,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp rows, activeWorktreeId, groupBy, + repoGroupOrdering, toggleGroup, collapsedGroups, handleCreateForRepo, @@ -156,10 +160,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const scrollRef = useRef(null) const [dragOverStatus, setDragOverStatus] = useState(null) const [pinDragOver, setPinDragOver] = useState(false) + const canReorderRepoHeaders = groupBy === 'repo' && repoGroupOrdering === 'manual' - // Drag is only meaningful when the user is grouping by repo. When inert - // (groupBy !== 'repo'), the controller is still constructed for hook order - // stability but the handle is never rendered. + // Drag is only meaningful when repo headers are using manual order. The + // controller is still constructed for hook order stability when inert. const repoDrag = useRepoHeaderDrag({ orderedRepoIds: allRepoIds, onCommit: reorderRepos, @@ -321,7 +325,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp prCache, new Set(), repoOrder, - workspaceStatuses + workspaceStatuses, + repoGroupOrdering ).filter((r): r is Extract => r.type === 'item') if (worktreeRows.length === 0) { return @@ -359,6 +364,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp activeWorktreeId, virtualizer, groupBy, + repoGroupOrdering, worktrees, repoMap, prCache, @@ -511,7 +517,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }} > - {repoDrag.state.draggingRepoId !== null && repoDrag.state.dropIndicatorY !== null ? ( + {canReorderRepoHeaders && + repoDrag.state.draggingRepoId !== null && + repoDrag.state.dropIndicatorY !== null ? (
repoDrag.onHandlePointerDown(e, repoIdForHeader) : undefined } @@ -1013,9 +1022,8 @@ const WorktreeList = React.memo(function WorktreeList() { const collapsedGroups = useAppStore((s) => s.collapsedGroups) const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup) - // Why: header order in groupBy='repo' is bound to state.repos array order so - // manual reorder is the single source of truth. The Map lets buildRows do an - // O(1) rank lookup per group without depending on Repo identity. + // Why: manual repo header order is bound to state.repos. Recent/Smart derive + // header order from the sorted visible worktree stream instead. const repos = useAppStore((s) => s.repos) const repoOrder = useMemo(() => { const map = new Map() @@ -1024,6 +1032,7 @@ const WorktreeList = React.memo(function WorktreeList() { }, [repos]) const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) const reorderReposAction = useAppStore((s) => s.reorderRepos) + const repoGroupOrdering = getRepoGroupOrdering(groupBy, sortBy) // Build flat row list for rendering const rows: Row[] = useMemo( @@ -1035,9 +1044,19 @@ const WorktreeList = React.memo(function WorktreeList() { prCache, collapsedGroups, repoOrder, - workspaceStatuses + workspaceStatuses, + repoGroupOrdering ), - [groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder, workspaceStatuses] + [ + groupBy, + worktrees, + repoMap, + prCache, + collapsedGroups, + repoOrder, + workspaceStatuses, + repoGroupOrdering + ] ) // Why: rows.length alone can stay the same when items migrate between // groups (e.g., PR cache loads on restart and a collapsed group absorbs @@ -1240,6 +1259,7 @@ const WorktreeList = React.memo(function WorktreeList() { rows={rows} activeWorktreeId={selectedSidebarWorktreeId} groupBy={groupBy} + repoGroupOrdering={repoGroupOrdering} toggleGroup={toggleGroup} collapsedGroups={collapsedGroups} handleCreateForRepo={handleCreateForRepo} diff --git a/src/renderer/src/components/sidebar/smart-sort.ts b/src/renderer/src/components/sidebar/smart-sort.ts index 96872ba9ff3..3ccc7ae383c 100644 --- a/src/renderer/src/components/sidebar/smart-sort.ts +++ b/src/renderer/src/components/sidebar/smart-sort.ts @@ -6,7 +6,7 @@ import type { import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { IDLE, buildAttentionByWorktree, type WorktreeAttention } from './smart-attention' -type SortBy = 'name' | 'smart' | 'recent' | 'repo' +export type SortBy = 'name' | 'smart' | 'recent' | 'repo' // Why: a newly-created worktree's lastActivityAt is stamped at the moment // createLocalWorktree finishes git + setup-runner prep (often several seconds diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 2f2c576520e..1dd46e3b41b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { buildRows, getPRGroupKey } from './worktree-list-groups' +import { buildRows, getPRGroupKey, getRepoGroupOrdering } from './worktree-list-groups' import type { Repo, Worktree } from '../../../../shared/types' const repo: Repo = { @@ -209,6 +209,7 @@ describe('buildRows repo grouping order', () => { [repoC.id, repoC] ]) const wA: Worktree = { ...worktree, id: 'wt-a', repoId: repoA.id, displayName: 'a' } + const wAStale: Worktree = { ...worktree, id: 'wt-a-stale', repoId: repoA.id, displayName: 'a2' } const wB: Worktree = { ...worktree, id: 'wt-b', repoId: repoB.id, displayName: 'b' } const wC: Worktree = { ...worktree, id: 'wt-c', repoId: repoC.id, displayName: 'c' } @@ -231,6 +232,82 @@ describe('buildRows repo grouping order', () => { const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key) expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c']) }) + + it('orders repo headers by first encounter when caller uses visible worktree order', () => { + // Caller already sorted worktrees by recency: C is freshest, then A, then B. + // Even though repoOrder pins B, A, C, dynamic sorts must follow the freshest + // worktree out of each repo so a just-active worktree's parent group + // bubbles to the top of the sidebar. + const repoOrder = new Map([ + [repoB.id, 0], + [repoA.id, 1], + [repoC.id, 2] + ]) + const rows = buildRows( + 'repo', + [wC, wA, wB], + map, + null, + new Set(), + repoOrder, + undefined, + 'visible-worktree-order' + ) + const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key) + expect(headerKeys).toEqual(['repo:repo-c', 'repo:repo-a', 'repo:repo-b']) + }) + + it('orders repo headers by each repo highest-ranked visible child', () => { + const repoOrder = new Map([ + [repoB.id, 0], + [repoA.id, 1], + [repoC.id, 2] + ]) + const rows = buildRows( + 'repo', + [wA, wB, wAStale, wC], + map, + null, + new Set(), + repoOrder, + undefined, + 'visible-worktree-order' + ) + + expect(rows).toMatchObject([ + { type: 'header', key: 'repo:repo-a' }, + { type: 'item', worktree: { id: 'wt-a' } }, + { type: 'item', worktree: { id: 'wt-a-stale' } }, + { type: 'header', key: 'repo:repo-b' }, + { type: 'item', worktree: { id: 'wt-b' } }, + { type: 'header', key: 'repo:repo-c' }, + { type: 'item', worktree: { id: 'wt-c' } } + ]) + }) + + it('keeps repoOrder for manual repo group ordering', () => { + const repoOrder = new Map([ + [repoB.id, 0], + [repoA.id, 1], + [repoC.id, 2] + ]) + const rows = buildRows('repo', [wC, wA, wB], map, null, new Set(), repoOrder) + const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key) + expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c']) + }) +}) + +describe('getRepoGroupOrdering', () => { + it.each([ + ['repo', 'recent', 'visible-worktree-order'], + ['repo', 'smart', 'visible-worktree-order'], + ['repo', 'name', 'manual'], + ['repo', 'repo', 'manual'], + ['none', 'recent', 'manual'], + ['pr-status', 'recent', 'manual'] + ] as const)('uses %s/%s -> %s', (groupBy, sortBy, expected) => { + expect(getRepoGroupOrdering(groupBy, sortBy)).toBe(expected) + }) }) describe('WorktreeList header styles', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 50e2d645719..fd292eadf9c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -9,10 +9,18 @@ import { getWorkspaceStatusVisualMeta } from './workspace-status' import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' +import type { SortBy } from './smart-sort' export { branchName } export type WorktreeGroupBy = 'none' | 'repo' | 'pr-status' +export type RepoGroupOrdering = 'manual' | 'visible-worktree-order' + +export function getRepoGroupOrdering(groupBy: WorktreeGroupBy, sortBy: SortBy): RepoGroupOrdering { + return groupBy === 'repo' && (sortBy === 'recent' || sortBy === 'smart') + ? 'visible-worktree-order' + : 'manual' +} export type GroupHeaderRow = { type: 'header' @@ -146,7 +154,8 @@ export function buildRows( prCache: Record | null, collapsedGroups: Set, repoOrder?: Map, - workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses() + workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), + repoGroupOrdering: RepoGroupOrdering = 'manual' ): Row[] { const result: Row[] = [] @@ -199,13 +208,11 @@ export function buildRows( } } } else { - // Why: header order must follow the canonical state.repos array order, not - // first-encounter from the smart-sorted worktree stream — otherwise sorting - // or filtering side effects could shuffle which repo header appears first, - // and manual reorder would have nothing to bind to. Unknown ids (no entry - // in repoOrder) sort last by label so they remain deterministic. + // Why: dynamic sorts need repo headers to follow their highest-ranked + // visible child. Manual ordering still uses the canonical state.repos + // order so repo-header drag has a stable source of truth. const entries = Array.from(grouped.entries()) - if (repoOrder) { + if (repoGroupOrdering === 'manual' && repoOrder) { const rankFor = (key: string): number => { const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key const rank = repoOrder.get(repoId) From 194e7d36d7f8683910979b7e9d4164f12c40cdfc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 13:18:05 -0700 Subject: [PATCH 4/4] feat(automations): manage external automation jobs (#1976) * feat(automations): manage external automation jobs * fix: restore worktree activity status compatibility --- src/main/automations/external-job-mappers.ts | 164 ++++++++++++ src/main/automations/external-manager.test.ts | 170 ++++++++++++ src/main/automations/external-manager.ts | 250 ++++++++++++++++++ src/main/ipc/automations.ts | 15 ++ src/preload/api-types.ts | 4 + src/preload/index.ts | 6 + src/relay/external-automations-handler.ts | 118 +++++++++ src/relay/relay.ts | 3 + .../automations/AutomationsPage.tsx | 124 ++++++++- .../ExternalAutomationManagers.tsx | 197 ++++++++++++++ .../src/components/settings/agents-search.ts | 1 + src/renderer/src/lib/agent-catalog.tsx | 7 + src/renderer/src/lib/agent-status.ts | 1 + src/shared/agent-detection.ts | 11 +- src/shared/agent-kind.ts | 1 + src/shared/automations-types.ts | 48 ++++ src/shared/telemetry-events.ts | 1 + src/shared/tui-agent-config.ts | 6 + src/shared/types.ts | 1 + 19 files changed, 1125 insertions(+), 3 deletions(-) create mode 100644 src/main/automations/external-job-mappers.ts create mode 100644 src/main/automations/external-manager.test.ts create mode 100644 src/main/automations/external-manager.ts create mode 100644 src/relay/external-automations-handler.ts create mode 100644 src/renderer/src/components/automations/ExternalAutomationManagers.tsx diff --git a/src/main/automations/external-job-mappers.ts b/src/main/automations/external-job-mappers.ts new file mode 100644 index 00000000000..98c2b51d567 --- /dev/null +++ b/src/main/automations/external-job-mappers.ts @@ -0,0 +1,164 @@ +import type { ExternalAutomationJob } from '../../shared/automations-types' + +type ExternalJobRecord = Record + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function formatDurationMs(value: unknown): string { + const ms = asNumber(value) + if (!ms) { + return '?' + } + if (ms % 86_400_000 === 0) { + return `${ms / 86_400_000}d` + } + if (ms % 3_600_000 === 0) { + return `${ms / 3_600_000}h` + } + if (ms % 60_000 === 0) { + return `${ms / 60_000}m` + } + if (ms % 1000 === 0) { + return `${ms / 1000}s` + } + return `${ms}ms` +} + +function isoFromMs(value: unknown): string | null { + const ms = asNumber(value) + if (!ms) { + return null + } + const date = new Date(ms) + return Number.isNaN(date.getTime()) ? null : date.toISOString() +} + +function hermesScheduleDisplay(job: ExternalJobRecord): string { + const direct = asString(job.schedule_display) + if (direct) { + return direct + } + const schedule = job.schedule + if (isRecord(schedule)) { + return ( + asString(schedule.display) ?? + asString(schedule.value) ?? + asString(schedule.expr) ?? + asString(schedule.run_at) ?? + '?' + ) + } + return asString(schedule) ?? '?' +} + +function hermesPromptPreview(job: ExternalJobRecord): string { + const prompt = asString(job.prompt) ?? '' + if (prompt) { + return prompt.length > 120 ? `${prompt.slice(0, 117)}...` : prompt + } + const script = asString(job.script) + if (script) { + return job.no_agent ? `Script: ${script}` : `Prompt uses script context: ${script}` + } + const skills = Array.isArray(job.skills) ? job.skills.map((skill) => String(skill)) : [] + return skills.length > 0 ? `Skills: ${skills.join(', ')}` : '' +} + +function openClawScheduleDisplay(job: ExternalJobRecord): string { + const schedule = job.schedule + if (!isRecord(schedule)) { + return asString(schedule) ?? '?' + } + const kind = asString(schedule.kind) + if (kind === 'at') { + return `at ${asString(schedule.at) ?? '?'}` + } + if (kind === 'every') { + return `every ${formatDurationMs(schedule.everyMs)}` + } + if (kind === 'cron') { + const expr = asString(schedule.expr) ?? asString(schedule.cron) ?? '?' + const tz = asString(schedule.tz) + return tz ? `cron ${expr} @ ${tz}` : `cron ${expr}` + } + return kind ?? '?' +} + +function openClawPromptPreview(job: ExternalJobRecord): string { + const payload = job.payload + if (!isRecord(payload)) { + return '' + } + const text = asString(payload.message) ?? asString(payload.text) ?? '' + return text.length > 120 ? `${text.slice(0, 117)}...` : text +} + +export function mapHermesJobs(managerId: string, rawJobs: unknown): ExternalAutomationJob[] { + if (!Array.isArray(rawJobs)) { + return [] + } + return rawJobs.filter(isRecord).map((job) => { + const id = asString(job.id) ?? 'unknown' + const enabled = job.enabled !== false && job.state !== 'paused' + const preview = hermesPromptPreview(job) + return { + id, + managerId, + provider: 'hermes', + name: (asString(job.name) ?? preview) || id, + schedule: hermesScheduleDisplay(job), + enabled, + state: asString(job.state) ?? (enabled ? 'scheduled' : 'paused'), + promptPreview: preview, + nextRunAt: asString(job.next_run_at), + lastRunAt: asString(job.last_run_at), + lastStatus: asString(job.last_status), + lastError: asString(job.last_error) ?? asString(job.last_delivery_error), + workdir: asString(job.workdir) + } + }) +} + +export function mapOpenClawJobs(managerId: string, rawJobs: unknown): ExternalAutomationJob[] { + const jobs = Array.isArray(rawJobs) + ? rawJobs + : isRecord(rawJobs) && Array.isArray(rawJobs.jobs) + ? rawJobs.jobs + : [] + return jobs.filter(isRecord).map((job) => { + const id = asString(job.id) ?? 'unknown' + const enabled = job.enabled !== false + const state = isRecord(job.state) ? job.state : {} + const preview = openClawPromptPreview(job) + const lastStatus = asString(state.lastRunStatus) ?? asString(state.lastStatus) + return { + id, + managerId, + provider: 'openclaw', + name: (asString(job.name) ?? preview) || id, + schedule: openClawScheduleDisplay(job), + enabled, + state: !enabled + ? 'disabled' + : asNumber(state.runningAtMs) + ? 'running' + : (lastStatus ?? 'idle'), + promptPreview: preview, + nextRunAt: isoFromMs(state.nextRunAtMs), + lastRunAt: isoFromMs(state.lastRunAtMs), + lastStatus, + lastError: asString(state.lastError) ?? asString(state.lastDeliveryError), + workdir: null + } + }) +} diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts new file mode 100644 index 00000000000..bb1af7be23c --- /dev/null +++ b/src/main/automations/external-manager.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { runExternalAutomationAction } from './external-manager' +import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers' + +const execFileMock = vi.hoisted(() => + vi.fn((...args: unknown[]) => { + const callback = args.at(-1) + if (typeof callback === 'function') { + const execCallback = callback as (error: Error | null, stdout: string, stderr: string) => void + execCallback(null, '', '') + } + }) +) + +vi.mock('child_process', () => ({ execFile: execFileMock })) + +vi.mock('../ipc/ssh', () => ({ + getActiveMultiplexer: vi.fn() +})) + +beforeEach(() => { + execFileMock.mockClear() +}) + +describe('mapHermesJobs', () => { + it('normalizes Hermes cron jobs into external automation rows', () => { + const jobs = mapHermesJobs('hermes:local', [ + { + id: 'job-1', + name: 'Nightly audit', + prompt: 'Audit the repo for risky dependency changes', + schedule_display: '0 9 * * 1-5', + enabled: true, + state: 'scheduled', + next_run_at: '2026-05-16T09:00:00Z', + last_run_at: '2026-05-15T09:00:00Z', + last_status: 'ok', + workdir: '/repo' + } + ]) + + expect(jobs).toEqual([ + { + id: 'job-1', + managerId: 'hermes:local', + provider: 'hermes', + name: 'Nightly audit', + schedule: '0 9 * * 1-5', + enabled: true, + state: 'scheduled', + promptPreview: 'Audit the repo for risky dependency changes', + nextRunAt: '2026-05-16T09:00:00Z', + lastRunAt: '2026-05-15T09:00:00Z', + lastStatus: 'ok', + lastError: null, + workdir: '/repo' + } + ]) + }) + + it('falls back to script and schedule fields for older Hermes records', () => { + const jobs = mapHermesJobs('hermes:local', [ + { + id: 'job-2', + script: 'disk-check.sh', + no_agent: true, + schedule: { display: 'every 30m' }, + enabled: false, + state: 'paused', + last_delivery_error: 'home channel missing' + } + ]) + + expect(jobs[0]).toMatchObject({ + id: 'job-2', + name: 'Script: disk-check.sh', + schedule: 'every 30m', + enabled: false, + state: 'paused', + promptPreview: 'Script: disk-check.sh', + lastError: 'home channel missing' + }) + }) +}) + +describe('runExternalAutomationAction', () => { + it('runs local Hermes lifecycle actions through the CLI', async () => { + await runExternalAutomationAction({ + managerId: 'hermes:local', + provider: 'hermes', + target: { type: 'local' }, + jobId: 'job-1', + action: 'run' + }) + + expect(execFileMock).toHaveBeenCalledWith( + 'hermes', + ['cron', 'run', 'job-1'], + { encoding: 'utf-8' }, + expect.any(Function) + ) + }) + + it('rejects job IDs that could be parsed as CLI options', async () => { + await expect( + runExternalAutomationAction({ + managerId: 'hermes:local', + provider: 'hermes', + target: { type: 'local' }, + jobId: '-help', + action: 'run' + }) + ).rejects.toThrow('Invalid external automation job ID.') + + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('maps OpenClaw lifecycle actions through its cron CLI names', async () => { + await runExternalAutomationAction({ + managerId: 'openclaw:local', + provider: 'openclaw', + target: { type: 'local' }, + jobId: 'job-1', + action: 'pause' + }) + + expect(execFileMock).toHaveBeenCalledWith( + 'openclaw', + ['cron', 'disable', 'job-1'], + { encoding: 'utf-8' }, + expect.any(Function) + ) + }) +}) + +describe('mapOpenClawJobs', () => { + it('normalizes OpenClaw cron jobs into external automation rows', () => { + const jobs = mapOpenClawJobs('openclaw:local', { + version: 1, + jobs: [ + { + id: 'claw-1', + name: 'Morning report', + enabled: true, + schedule: { kind: 'cron', expr: '0 9 * * *', tz: 'America/Phoenix' }, + payload: { kind: 'agentTurn', message: 'Summarize overnight alerts' }, + state: { + nextRunAtMs: Date.parse('2026-05-16T16:00:00Z'), + lastRunAtMs: Date.parse('2026-05-15T16:00:00Z'), + lastRunStatus: 'ok' + } + } + ] + }) + + expect(jobs[0]).toMatchObject({ + id: 'claw-1', + managerId: 'openclaw:local', + provider: 'openclaw', + name: 'Morning report', + schedule: 'cron 0 9 * * * @ America/Phoenix', + enabled: true, + state: 'ok', + promptPreview: 'Summarize overnight alerts', + nextRunAt: '2026-05-16T16:00:00.000Z', + lastRunAt: '2026-05-15T16:00:00.000Z', + lastStatus: 'ok' + }) + }) +}) diff --git a/src/main/automations/external-manager.ts b/src/main/automations/external-manager.ts new file mode 100644 index 00000000000..a1585778a95 --- /dev/null +++ b/src/main/automations/external-manager.ts @@ -0,0 +1,250 @@ +import { execFile } from 'child_process' +import { existsSync } from 'fs' +import { readFile } from 'fs/promises' +import { homedir } from 'os' +import { join } from 'path' +import { promisify } from 'util' +import type { + ExternalAutomationAction, + ExternalAutomationActionInput, + ExternalAutomationManager, + ExternalAutomationProvider +} from '../../shared/automations-types' +import type { SshTarget } from '../../shared/ssh-types' +import type { Store } from '../persistence' +import { getActiveMultiplexer } from '../ipc/ssh' +import { mapHermesJobs, mapOpenClawJobs } from './external-job-mappers' + +const execFileAsync = promisify(execFile) +const HERMES_JOBS_FILE = join(homedir(), '.hermes', 'cron', 'jobs.json') +const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') +const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function isCommandOnPath(command: string): Promise { + const finder = process.platform === 'win32' ? 'where' : 'which' + try { + await execFileAsync(finder, [command], { encoding: 'utf-8' }) + return true + } catch { + return false + } +} + +async function readLocalHermesJobs(): Promise { + if (!existsSync(HERMES_JOBS_FILE)) { + return [] + } + const content = await readFile(HERMES_JOBS_FILE, 'utf-8') + const parsed = JSON.parse(content) as unknown + return Array.isArray(parsed) ? parsed : [] +} + +async function readLocalOpenClawJobs(): Promise { + if (!existsSync(OPENCLAW_JOBS_FILE)) { + return [] + } + const content = await readFile(OPENCLAW_JOBS_FILE, 'utf-8') + const parsed = JSON.parse(content) as unknown + return isRecord(parsed) && Array.isArray(parsed.jobs) ? parsed.jobs : [] +} + +async function listLocalHermesManager(): Promise { + const [hermesAvailableResult, jobsResult] = await Promise.allSettled([ + isCommandOnPath('hermes'), + readLocalHermesJobs() + ]) + const hermesAvailable = + hermesAvailableResult.status === 'fulfilled' && hermesAvailableResult.value + const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : [] + const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null + if (!hermesAvailable && jobs.length === 0 && !readError) { + return null + } + const managerId = 'hermes:local' + return { + id: managerId, + provider: 'hermes', + label: 'Hermes on this computer', + target: { type: 'local' }, + status: readError ? 'unavailable' : 'available', + error: + readError ?? + (hermesAvailable ? null : 'Hermes jobs were found, but the hermes CLI is not on PATH.'), + canManage: !readError && hermesAvailable, + jobs: mapHermesJobs(managerId, jobs) + } +} + +async function listLocalOpenClawManager(): Promise { + const [openClawAvailableResult, jobsResult] = await Promise.allSettled([ + isCommandOnPath('openclaw'), + readLocalOpenClawJobs() + ]) + const openClawAvailable = + openClawAvailableResult.status === 'fulfilled' && openClawAvailableResult.value + const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : [] + const readError = jobsResult.status === 'rejected' ? String(jobsResult.reason) : null + if (!openClawAvailable && jobs.length === 0 && !readError) { + return null + } + const managerId = 'openclaw:local' + return { + id: managerId, + provider: 'openclaw', + label: 'OpenClaw on this computer', + target: { type: 'local' }, + status: readError ? 'unavailable' : 'available', + error: + readError ?? + (openClawAvailable ? null : 'OpenClaw jobs were found, but the openclaw CLI is not on PATH.'), + canManage: !readError && openClawAvailable, + jobs: mapOpenClawJobs(managerId, jobs) + } +} + +function remoteRelayErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('-32601') || /method not found/i.test(message)) { + return 'Remote relay does not support external automation management. Reconnect the SSH target to deploy the latest relay.' + } + return message +} + +async function listRemoteHermesManager(target: SshTarget): Promise { + return listRemoteManager(target, 'hermes') +} + +async function listRemoteOpenClawManager(target: SshTarget): Promise { + return listRemoteManager(target, 'openclaw') +} + +async function listRemoteManager( + target: SshTarget, + provider: ExternalAutomationProvider +): Promise { + const providerLabel = provider === 'hermes' ? 'Hermes' : 'OpenClaw' + const managerProviderId = `${provider}:ssh:${target.id}` + const mux = getActiveMultiplexer(target.id) + if (!mux || mux.isDisposed()) { + return { + id: managerProviderId, + provider, + label: `${providerLabel} on ${target.label}`, + target: { type: 'ssh', connectionId: target.id }, + status: 'unavailable', + error: 'SSH target is not connected.', + canManage: false, + jobs: [] + } + } + try { + const result = (await mux.request('externalAutomations.list', { provider })) as { + jobs?: unknown[] + hermesAvailable?: boolean + openclawAvailable?: boolean + error?: string | null + } + const commandAvailable = + provider === 'hermes' ? result.hermesAvailable === true : result.openclawAvailable === true + const readError = result.error ?? null + return { + id: managerProviderId, + provider, + label: `${providerLabel} on ${target.label}`, + target: { type: 'ssh', connectionId: target.id }, + status: readError ? 'unavailable' : 'available', + error: + readError ?? (commandAvailable ? null : `${providerLabel} CLI is not on the remote PATH.`), + canManage: !readError && commandAvailable, + jobs: + provider === 'hermes' + ? mapHermesJobs(managerProviderId, result.jobs ?? []) + : mapOpenClawJobs(managerProviderId, result.jobs ?? []) + } + } catch (error) { + return { + id: managerProviderId, + provider, + label: `${providerLabel} on ${target.label}`, + target: { type: 'ssh', connectionId: target.id }, + status: 'unavailable', + error: remoteRelayErrorMessage(error), + canManage: false, + jobs: [] + } + } +} + +export async function listExternalAutomationManagers( + store: Store +): Promise { + const [localHermes, localOpenClaw, remote] = await Promise.all([ + listLocalHermesManager(), + listLocalOpenClawManager(), + Promise.all( + store + .getSshTargets() + .flatMap((target) => [listRemoteHermesManager(target), listRemoteOpenClawManager(target)]) + ) + ]) + return [ + ...(localHermes ? [localHermes] : []), + ...(localOpenClaw ? [localOpenClaw] : []), + ...remote + ] +} + +function hermesCommandForAction(action: ExternalAutomationAction): string { + switch (action) { + case 'pause': + return 'pause' + case 'resume': + return 'resume' + case 'run': + return 'run' + case 'delete': + return 'remove' + } +} + +function openClawCommandForAction(action: ExternalAutomationAction): string { + switch (action) { + case 'pause': + return 'disable' + case 'resume': + return 'enable' + case 'run': + return 'run' + case 'delete': + return 'rm' + } +} + +export async function runExternalAutomationAction( + input: ExternalAutomationActionInput +): Promise { + if (!EXTERNAL_JOB_ID_PATTERN.test(input.jobId)) { + throw new Error('Invalid external automation job ID.') + } + const command = + input.provider === 'hermes' + ? hermesCommandForAction(input.action) + : openClawCommandForAction(input.action) + if (input.target.type === 'local') { + await execFileAsync(input.provider, ['cron', command, input.jobId], { encoding: 'utf-8' }) + return + } + const mux = getActiveMultiplexer(input.target.connectionId) + if (!mux || mux.isDisposed()) { + throw new Error(`SSH target "${input.target.connectionId}" is not connected.`) + } + await mux.request('externalAutomations.act', { + provider: input.provider, + action: input.action, + jobId: input.jobId + }) +} diff --git a/src/main/ipc/automations.ts b/src/main/ipc/automations.ts index e01d2767455..f5c762d265c 100644 --- a/src/main/ipc/automations.ts +++ b/src/main/ipc/automations.ts @@ -5,9 +5,15 @@ import type { Automation, AutomationCreateInput, AutomationDispatchResult, + ExternalAutomationActionInput, + ExternalAutomationManager, AutomationRun, AutomationUpdateInput } from '../../shared/automations-types' +import { + listExternalAutomationManagers, + runExternalAutomationAction +} from '../automations/external-manager' export function registerAutomationHandlers(store: Store, service: AutomationService): void { ipcMain.handle('automations:list', (): Automation[] => store.listAutomations()) @@ -16,6 +22,15 @@ export function registerAutomationHandlers(store: Store, service: AutomationServ (_event, args?: { automationId?: string }): AutomationRun[] => store.listAutomationRuns(args?.automationId) ) + ipcMain.handle('automations:listExternalManagers', (): Promise => { + return listExternalAutomationManagers(store) + }) + ipcMain.handle( + 'automations:runExternalAction', + (_event, input: ExternalAutomationActionInput) => { + return runExternalAutomationAction(input) + } + ) ipcMain.handle( 'automations:create', (_event, input: AutomationCreateInput): Automation => store.createAutomation(input) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index eb41cd2e376..d02b9fad694 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -217,6 +217,8 @@ import type { AutomationCreateInput, AutomationDispatchRequest, AutomationDispatchResult, + ExternalAutomationActionInput, + ExternalAutomationManager, AutomationRun, AutomationUpdateInput } from '../shared/automations-types' @@ -1599,6 +1601,8 @@ export type PreloadApi = { automations: { list: () => Promise listRuns: (args?: { automationId?: string }) => Promise + listExternalManagers: () => Promise + runExternalAction: (input: ExternalAutomationActionInput) => Promise create: (input: AutomationCreateInput) => Promise update: (args: { id: string; updates: AutomationUpdateInput }) => Promise delete: (args: { id: string }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 3eb695a9d2c..bbbc8942d7c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -100,6 +100,8 @@ import type { AutomationCreateInput, AutomationDispatchRequest, AutomationDispatchResult, + ExternalAutomationActionInput, + ExternalAutomationManager, AutomationRun, AutomationUpdateInput } from '../shared/automations-types' @@ -2641,6 +2643,10 @@ const api = { list: (): Promise => ipcRenderer.invoke('automations:list'), listRuns: (args?: { automationId?: string }): Promise => ipcRenderer.invoke('automations:listRuns', args), + listExternalManagers: (): Promise => + ipcRenderer.invoke('automations:listExternalManagers'), + runExternalAction: (input: ExternalAutomationActionInput): Promise => + ipcRenderer.invoke('automations:runExternalAction', input), create: (input: AutomationCreateInput): Promise => ipcRenderer.invoke('automations:create', input), update: (args: { id: string; updates: AutomationUpdateInput }): Promise => diff --git a/src/relay/external-automations-handler.ts b/src/relay/external-automations-handler.ts new file mode 100644 index 00000000000..82d2c6d2799 --- /dev/null +++ b/src/relay/external-automations-handler.ts @@ -0,0 +1,118 @@ +import { execFile } from 'child_process' +import { existsSync } from 'fs' +import { readFile } from 'fs/promises' +import { homedir } from 'os' +import { join } from 'path' +import { promisify } from 'util' +import type { RelayDispatcher } from './dispatcher' + +const execFileAsync = promisify(execFile) +const HERMES_JOBS_FILE = join(homedir(), '.hermes', 'cron', 'jobs.json') +const OPENCLAW_JOBS_FILE = join(homedir(), '.openclaw', 'cron', 'jobs.json') +const EXTERNAL_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ + +type ExternalProvider = 'hermes' | 'openclaw' +type HermesAction = 'pause' | 'resume' | 'run' | 'delete' + +export class ExternalAutomationsHandler { + constructor(private readonly dispatcher: RelayDispatcher) { + this.dispatcher.onRequest('externalAutomations.list', (params) => this.listJobs(params)) + this.dispatcher.onRequest('externalAutomations.act', (params) => this.runAction(params)) + } + + private async isCommandAvailable(command: string): Promise { + try { + await execFileAsync('/bin/sh', ['-lc', `which ${command}`], { + encoding: 'utf-8', + timeout: 5000 + }) + return true + } catch { + return false + } + } + + private async readJobs(provider: ExternalProvider): Promise { + const jobsFile = provider === 'hermes' ? HERMES_JOBS_FILE : OPENCLAW_JOBS_FILE + if (!existsSync(jobsFile)) { + return [] + } + const content = await readFile(jobsFile, 'utf-8') + const parsed = JSON.parse(content) as unknown + if (Array.isArray(parsed)) { + return parsed + } + return typeof parsed === 'object' && + parsed !== null && + !Array.isArray(parsed) && + Array.isArray((parsed as { jobs?: unknown }).jobs) + ? (parsed as { jobs: unknown[] }).jobs + : [] + } + + private async listJobs(params?: Record): Promise<{ + jobs: unknown[] + hermesAvailable: boolean + openclawAvailable: boolean + error: string | null + }> { + const provider = params?.provider === 'openclaw' ? 'openclaw' : 'hermes' + const [commandAvailable, jobsResult] = await Promise.allSettled([ + this.isCommandAvailable(provider), + this.readJobs(provider) + ]) + const jobs = jobsResult.status === 'fulfilled' ? jobsResult.value : [] + const available = commandAvailable.status === 'fulfilled' && commandAvailable.value + return { + jobs, + hermesAvailable: provider === 'hermes' && available, + openclawAvailable: provider === 'openclaw' && available, + error: jobsResult.status === 'rejected' ? String(jobsResult.reason) : null + } + } + + private hermesCommand(action: HermesAction): string { + switch (action) { + case 'pause': + return 'pause' + case 'resume': + return 'resume' + case 'run': + return 'run' + case 'delete': + return 'remove' + } + } + + private openClawCommand(action: HermesAction): string { + switch (action) { + case 'pause': + return 'disable' + case 'resume': + return 'enable' + case 'run': + return 'run' + case 'delete': + return 'rm' + } + } + + private async runAction(params: Record = {}): Promise<{ ok: true }> { + const provider = params.provider === 'openclaw' ? 'openclaw' : 'hermes' + const action = params.action + const jobId = params.jobId + if (action !== 'pause' && action !== 'resume' && action !== 'run' && action !== 'delete') { + throw new Error('Unsupported external automation action.') + } + if (typeof jobId !== 'string' || !EXTERNAL_JOB_ID_PATTERN.test(jobId)) { + throw new Error('Invalid external automation job ID.') + } + const command = + provider === 'hermes' ? this.hermesCommand(action) : this.openClawCommand(action) + await execFileAsync('/bin/sh', ['-lc', `${provider} cron ${command} ${jobId}`], { + encoding: 'utf-8', + timeout: 30_000 + }) + return { ok: true } + } +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index b550b490fd1..3ac7554f486 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -31,6 +31,7 @@ import { PtyHandler } from './pty-handler' import { FsHandler } from './fs-handler' import { GitHandler } from './git-handler' import { PreflightHandler } from './preflight-handler' +import { ExternalAutomationsHandler } from './external-automations-handler' import { PortScanHandler } from './port-scan-handler' import { AgentExecHandler } from './agent-exec-handler' import { WorkspaceSessionHandler } from './workspace-session-handler' @@ -235,7 +236,9 @@ async function main(): Promise { void _gitHandler const _preflightHandler = new PreflightHandler(dispatcher) + const _externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) void _preflightHandler + void _externalAutomationsHandler const _portScanHandler = new PortScanHandler(dispatcher) void _portScanHandler diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 09d5c9ebbb9..0027ee7d9eb 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -28,6 +28,9 @@ import { useRepoMap, useWorktreeMap } from '@/store/selectors' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import type { Automation, + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager, AutomationRun, AutomationUpdateInput } from '../../../../shared/automations-types' @@ -36,6 +39,7 @@ import { buildAutomationRrule, parseAutomationRrule } from '../../../../shared/a import { formatAutomationDateTimeWithRelative } from './automation-page-parts' import { AutomationDetail } from './AutomationDetail' import { AutomationEditorDialog, type AutomationDraft } from './AutomationEditorDialog' +import { ExternalAutomationManagers } from './ExternalAutomationManagers' const AGENTS = AGENT_CATALOG.map((agent) => agent.id) const DEFAULT_TIME = '09:00' @@ -72,6 +76,8 @@ export default function AutomationsPage(): React.JSX.Element { const [automations, setAutomations] = useState([]) const [runs, setRuns] = useState([]) + const [externalManagers, setExternalManagers] = useState([]) + const [externalActionKey, setExternalActionKey] = useState(null) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) const [createOpen, setCreateOpen] = useState(false) @@ -79,6 +85,10 @@ export default function AutomationsPage(): React.JSX.Element { const [relativeNow, setRelativeNow] = useState(Date.now()) const [draftAtOpen, setDraftAtOpen] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) + const [externalDeleteTarget, setExternalDeleteTarget] = useState<{ + manager: ExternalAutomationManager + job: ExternalAutomationJob + } | null>(null) const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false) const editRequestRef = useRef(0) const deleteConfirmButtonRef = useRef(null) @@ -129,12 +139,14 @@ export default function AutomationsPage(): React.JSX.Element { const refresh = useCallback(async () => { setIsLoading(true) try { - const [nextAutomations, nextRuns] = await Promise.all([ + const [nextAutomations, nextRuns, nextExternalManagers] = await Promise.all([ window.api.automations.list(), - window.api.automations.listRuns() + window.api.automations.listRuns(), + window.api.automations.listExternalManagers() ]) setAutomations(nextAutomations) setRuns(nextRuns) + setExternalManagers(nextExternalManagers) const currentSelectedId = useAppStore.getState().selectedAutomationId const hasCurrentSelection = nextAutomations.some( (automation) => automation.id === currentSelectedId @@ -464,6 +476,59 @@ export default function AutomationsPage(): React.JSX.Element { toast.message('Automation run queued.') } + const runExternalAction = async ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ): Promise => { + const key = `${manager.id}:${job.id}:${action}` + setExternalActionKey(key) + try { + await window.api.automations.runExternalAction({ + managerId: manager.id, + provider: manager.provider, + target: manager.target, + jobId: job.id, + action + }) + await refresh() + toast.success( + action === 'delete' + ? 'External automation deleted.' + : action === 'run' + ? 'External automation queued.' + : action === 'pause' + ? 'External automation paused.' + : 'External automation resumed.' + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'External automation action failed.') + } finally { + setExternalActionKey(null) + } + } + + const requestExternalAction = ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ): void => { + if (action === 'delete') { + setExternalDeleteTarget({ manager, job }) + return + } + void runExternalAction(manager, job, action) + } + + const confirmDeleteExternalAutomation = async (): Promise => { + if (!externalDeleteTarget) { + return + } + const target = externalDeleteTarget + setExternalDeleteTarget(null) + await runExternalAction(target.manager, target.job, 'delete') + } + const openRunWorkspace = (run: AutomationRun): void => { if (!run.workspaceId || !activateAndRevealWorktree(run.workspaceId)) { toast.error('Workspace is not available.') @@ -613,6 +678,55 @@ export default function AutomationsPage(): React.JSX.Element { + { + if (!open) { + setExternalDeleteTarget(null) + } + }} + > + { + event.preventDefault() + deleteConfirmButtonRef.current?.focus() + }} + > + + Delete External Automation + + Delete{' '} + + {externalDeleteTarget?.job.name} + {' '} + from {externalDeleteTarget?.manager.label}. + + + {externalDeleteTarget ? ( +
+
+ {externalDeleteTarget.job.name} +
+
{externalDeleteTarget.job.schedule}
+
+ ) : null} + + + + +
+
+
@@ -716,6 +830,12 @@ export default function AutomationsPage(): React.JSX.Element { onToggle={(automation) => void toggleAutomation(automation)} onDelete={requestDeleteAutomation} /> +
diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx new file mode 100644 index 00000000000..865422a30cb --- /dev/null +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -0,0 +1,197 @@ +import React from 'react' +import { Pause, Play, RefreshCw, Trash2 } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import type { + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import { formatAutomationDateTimeWithRelative } from './automation-page-parts' + +type ExternalAutomationManagersProps = { + managers: ExternalAutomationManager[] + now: number + runningActionKey: string | null + onAction: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ) => void +} + +function formatExternalDate(value: string | null, now: number): string { + if (!value) { + return 'Never' + } + const parsed = Date.parse(value) + if (!Number.isFinite(parsed)) { + return value + } + return formatAutomationDateTimeWithRelative(parsed, now) +} + +function actionKey( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction +): string { + return `${manager.id}:${job.id}:${action}` +} + +function ExternalActionButton({ + label, + disabled, + className, + onClick, + children +}: { + label: string + disabled: boolean + className?: string + onClick: () => void + children: React.ReactNode +}): React.JSX.Element { + return ( + + + + + + {label} + + + ) +} + +export function ExternalAutomationManagers({ + managers, + now, + runningActionKey, + onAction +}: ExternalAutomationManagersProps): React.JSX.Element { + return ( +
+
+
+
External managers
+
+ + {managers.reduce((sum, manager) => sum + manager.jobs.length, 0)} jobs + +
+
+ {managers.map((manager) => ( +
+
+
+
{manager.label}
+
+ {manager.status === 'available' + ? manager.canManage + ? 'Manageable' + : 'Read-only' + : 'Unavailable'} + {manager.error ? ` - ${manager.error}` : null} +
+
+ + {manager.provider} + +
+
+ {manager.jobs.map((job) => ( +
+
+
+ {job.name} + + {job.enabled ? 'Active' : 'Paused'} + +
+
+ {job.schedule} · next {formatExternalDate(job.nextRunAt, now)} +
+ {job.promptPreview || job.lastError ? ( +
+ {job.lastError ?? job.promptPreview} +
+ ) : null} +
+
+ Last {formatExternalDate(job.lastRunAt, now)} + {job.lastStatus ? ` · ${job.lastStatus}` : null} +
+
+ onAction(manager, job, 'run')} + > + {runningActionKey === actionKey(manager, job, 'run') ? ( + + ) : ( + + )} + + onAction(manager, job, job.enabled ? 'pause' : 'resume')} + > + {runningActionKey === + actionKey(manager, job, job.enabled ? 'pause' : 'resume') ? ( + + ) : job.enabled ? ( + + ) : ( + + )} + + onAction(manager, job, 'delete')} + > + {runningActionKey === actionKey(manager, job, 'delete') ? ( + + ) : ( + + )} + +
+
+ ))} + {manager.jobs.length === 0 ? ( +
+ No {manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw'} jobs found. +
+ ) : null} +
+
+ ))} + {managers.length === 0 ? ( +
+ No external automation managers found. +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/agents-search.ts b/src/renderer/src/components/settings/agents-search.ts index b82fe2e1d1c..3d384e529c1 100644 --- a/src/renderer/src/components/settings/agents-search.ts +++ b/src/renderer/src/components/settings/agents-search.ts @@ -29,6 +29,7 @@ export const AGENTS_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [ 'qwen', 'rovo', 'hermes', + 'openclaw', 'copilot', 'github', 'github copilot', diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 37a8713d9f1..30cac2a6870 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -182,6 +182,13 @@ export const AGENT_CATALOG: AgentCatalogEntry[] = [ cmd: 'hermes', faviconDomain: 'nousresearch.com', homepageUrl: 'https://hermes-agent.nousresearch.com/docs/' + }, + { + id: 'openclaw', + label: 'OpenClaw', + cmd: 'openclaw', + faviconDomain: 'openclaw.ai', + homepageUrl: 'https://github.com/openclaw/openclaw' } ] diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 7c02d7bf06d..7f38952f24d 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -165,6 +165,7 @@ const ICONABLE_AGENT_TYPES: Record = { 'qwen-code': true, rovo: true, hermes: true, + openclaw: true, copilot: true } diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index e4181e30468..c2ea5e881aa 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -21,7 +21,16 @@ const GEMINI_PERMISSION = '\u270B' // ✋ // unsafe under the substring-based detector and would classify ordinary shell // titles like "timestamp ready" as agent activity. Product telemetry uses the // explicit launch/session facts Orca owns, not this inference path. -export const AGENT_NAMES = ['claude', 'codex', 'copilot', 'cursor', 'gemini', 'opencode', 'aider'] +export const AGENT_NAMES = [ + 'claude', + 'codex', + 'copilot', + 'cursor', + 'gemini', + 'opencode', + 'openclaw', + 'aider' +] // Why: `android` contains `droid`; unlike the legacy agent names above, Droid // must be token-matched so Android terminal titles do not become agent status. diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index efd1da48318..ef26793cb0f 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -37,6 +37,7 @@ const TUI_AGENT_KIND_BY_AGENT = { 'qwen-code': 'qwen-code', rovo: 'rovo', hermes: 'hermes', + openclaw: 'openclaw', copilot: 'copilot' } satisfies Record diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index e45ec5027d1..fcbc5d32dba 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -103,3 +103,51 @@ export type AutomationDispatchResult = { terminalSessionId?: string | null error?: string | null } + +export type ExternalAutomationProvider = 'hermes' | 'openclaw' +export type ExternalAutomationManagerStatus = 'available' | 'unavailable' +export type ExternalAutomationAction = 'pause' | 'resume' | 'run' | 'delete' + +export type ExternalAutomationTarget = + | { + type: 'local' + } + | { + type: 'ssh' + connectionId: string + } + +export type ExternalAutomationJob = { + id: string + managerId: string + provider: ExternalAutomationProvider + name: string + schedule: string + enabled: boolean + state: string + promptPreview: string + nextRunAt: string | null + lastRunAt: string | null + lastStatus: string | null + lastError: string | null + workdir: string | null +} + +export type ExternalAutomationManager = { + id: string + provider: ExternalAutomationProvider + label: string + target: ExternalAutomationTarget + status: ExternalAutomationManagerStatus + error: string | null + canManage: boolean + jobs: ExternalAutomationJob[] +} + +export type ExternalAutomationActionInput = { + managerId: string + provider: ExternalAutomationProvider + target: ExternalAutomationTarget + jobId: string + action: ExternalAutomationAction +} diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 0d7ad55339c..d5694770e55 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -58,6 +58,7 @@ export const AGENT_KIND_VALUES = [ 'qwen-code', 'rovo', 'hermes', + 'openclaw', 'copilot', 'other' ] as const diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index dd41fc17e82..ad868812de8 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -211,6 +211,12 @@ export const TUI_AGENT_CONFIG: Record = { expectedProcess: 'hermes', promptInjectionMode: 'stdin-after-start' }, + openclaw: { + detectCmd: 'openclaw', + launchCmd: 'openclaw', + expectedProcess: 'openclaw', + promptInjectionMode: 'stdin-after-start' + }, copilot: { detectCmd: 'copilot', launchCmd: 'copilot', diff --git a/src/shared/types.ts b/src/shared/types.ts index 1a2e09757fc..78d96705b9f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1143,6 +1143,7 @@ export type TuiAgent = | 'qwen-code' // Qwen Code | 'rovo' // Rovo Dev | 'hermes' // Hermes Agent + | 'openclaw' // OpenClaw | 'copilot' // GitHub Copilot CLI export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs'